Reports — the narration becomes part of the board
A chapter report about the chapter that made chapter reports possible.
milestone ms-6f7a24 · five cards · four workers · branch ms/reports-the-narration-becomes-pa
written by agent:berna/close1 on tk-37ac05, 2026-08-10 · every figure below is re-derivable
What it set out to do
A report is the one artefact a machine cannot regenerate. A commit can be re-derived from a diff, a status from the log, a board from its events — but what an agent understood after a day of work exists once, and until this chapter it existed in a chat transcript that nobody would ever open again.
The chapter's goal was to give it the same standing a commit has, under one
constraint that decided everything else: the log stores references and
measures, never content. That rule already keeps patches out of
events.jsonl — a commit is recorded as a sha and a numstat, and the
reader goes to git for the bytes. Applied to prose it gives the whole design:
the report is a file, committed; the board holds
{path, title, milestone, sha}; and the reader renders it from its own
clone.
Four acceptance criteria, and they are what the five cards were cut against:
- one
taskops_activitycall returns spec, thread, commits with numstat, standing andmerged_intofor every card of a chapter, andsince=<seq>returns only what moved; - a report registered on chapter X appears in the UI's list for X and renders
from the local clone — while
taskops servestill serves no UI and no report; events.jsonlgrows by less than 1KB per report, whatever the report's size;- a report carrying a
<script>tag cannot read the dashboard's token.
What each card actually built
tk-ca31e0 — the report event, the fold, and the registration door
The seam. One event kind, report, history-only — replay never
folds it into state because there is nothing to fold. core/reports.py
(116 lines, layer 1, pure) owns three things: DIR, the
under() guard that decides whether a path is a report path, and
of(), which answers "which reports does this chapter have" as a
fold over the events, newest first, on every read. The write door is a verb of
its own, filed. Registering the same path at the same sha twice
writes nothing and says recorded: false.
It landed alone because two cards were about to consume its vocabulary, and a concept named by two cards is a seam — the fan-out rule the project already had a scar for.
tk-dd304f — taskops_activity, a chapter's whole story in one read
A read verb over N cards: the header once, then a story per card — state,
review standing verbatim, commits with their numstat, merged_into,
branch, the released note, the seconds. since=<seq> reuses the
cache's existing keyset pager, and cards_total keeps counting, so
"nothing moved" never reads as "empty chapter".
It is the one read in the codebase with a declared payload
budget, and the number was measured rather than assumed: 76 cards at
the default depth=headline come back at 88–96 KB against the real
board's two largest chapters. It is paid for by dropping the two unbounded
prose fields — the spec and the thread, which becomes
thread_total — and capping commits at ten with
commits_total. depth=full is roughly 13 KB per card,
about 1 MB for the same 76, which is exactly why it is not the default.
The same card added taskops_filed, the write half, after the
seam worker flagged that without it an agent could commit a narration and had
no way to put it on the board.
tk-9ebbc3 — the /git door serves one committed file at a rev
GET /<board>/git/file/<rev>?path=<repo-relative>.
The door could already resolve refs and read patches; it could not hand back
one file's bytes, which is precisely what rendering a report from the reader's
own clone needs. Bytes come from git show <sha>:<path>
— object syntax, never the working copy — capped on the same constant a patch
is capped on, through one shared capped() so the two answers cannot
disagree about what truncated means.
The walls run in order: under() — the seam's guard, the same
call the register verb makes — then addressability, then resolve,
still the only string-to-sha door. Nine tests walk every shape a traversal can
take, with a real secret planted one directory up, asserting its contents
appear nowhere in the answer.
tk-535807 — the Reports tab, and the sandbox
A fifth tab. The list costs nothing: it is a slice of the one board
answer the dashboard already fetches, since the fold put reports and
reports_total into that payload scoped to the header's chapter
picker — so no new fetcher, no new filter, and no wait on
taskops_activity. A row opens a full-width surface that replaces the
index and reads one report at a time through the file door.
The card also declined to rebuild the committed bundle, and declined to add the marker assertions that would prove the rebuild happened — both correctly. That is the next section.
tk-37ac05 — this close
The rebuild, the marker row, the workflow in the README, this section of ARCHITECTURE — and this report, written with the tools the chapter built, filed the way the chapter says to file one.
The decisions that were argued
The sandbox pair is not two permissions
A report is untrusted HTML somebody's agent wrote, and the dashboard's origin
holds the token. An html report is read inside
<iframe sandbox="allow-scripts" srcdoc=…>, and
scripts run on purpose — a panorama report is a self-contained
page with inline behaviour, and rendering it dead ships a broken document that
looks fine. The question was never whether script runs; it is what the script can
reach.
allow-scripts beside allow-same-origin is not a
slightly wider sandbox. It is the absence of one: with both, the frame
reads parent.localStorage, writes parent.document, and
can strip its own sandbox attribute from the inside. Without
allow-same-origin the frame is an opaque origin — no parent, no
storage, no cookie, no form, no popup, no top navigation.
So the value is a module constant, not a prop: there is no argument a caller
can pass to widen it. And the proof is not a well-behaved fixture. The test
fixture commits a hostile report that reads the token out of
parent.localStorage, overwrites parent.document.title,
removes its own frame's sandbox attribute and POSTs the loot; the headless smoke
section then asserts those exact bytes reach the parent document escaped inside
an attribute, so the dashboard's own document contains no script element of the
report's at all.
assert "allow-scripts" in app and "allow-same-origin" not in app
content_type is a field, never a header
The file door answers the same JSON envelope every other question answers, and
it reports content_type as data: text/html only for a
literal .html, otherwise text/plain. It is never set as
the HTTP response header, and that is the point — the token lives in this origin,
so no file may make this origin serve HTML. What to do with
text/html is the reader's decision, taken in exactly one place. A
text/plain report is therefore not framed at all: srcdoc
parses as HTML, so text goes into a <pre> as a React text node,
and there is no dangerouslySetInnerHTML anywhere in this
dashboard.
Why filed is its own verb
Registration is an act, not a side effect of committing. A hook that
noticed a file appear under .taskops/reports/ and registered it would
be a Claude hook that decides and writes — the thing this project bans by name —
and it would file every draft, every rename, every experiment. Committing prose is
cheap; saying "this is the chapter's report" is a claim, and a claim gets a door
with a refusal attached to it.
The refusal is the interesting half. under() never repairs
a path — a traversal normalised into something acceptable is the bug, not the
fix — and the refusal names the directory and shows the call that works:
'…' is not a report path — a report is a file COMMITTED under .taskops/reports/ (no '..', no absolute path). Move it there, commit it, and file it: filed path=".taskops/reports/<name>.md" title="…" sha=<the commit>
Nothing that can be derived is stored
There is no reports table and no column on Milestone. The list is
a fold over the report events on every read, newest first, capped with
its honest total beside it. A stored list would be a second fact somebody has to
keep in step with the events that produced it, which is this project's oldest bug
and has its own module docstring elsewhere.
What it cost, and what it bought
| card | worker | worked | files | lines |
|---|---|---|---|---|
| tk-ca31e0 the seam | agent:berna/w1 | 43m | 10 | +572 −5 |
| tk-dd304f taskops_activity | agent:berna/act1 | 27m | 11 | +766 −14 |
| tk-9ebbc3 the file door | agent:berna/door1 | 22m | 4 | +324 −6 |
| tk-535807 the Reports tab | agent:berna/ui1 | 18m | 12 | +1163 −130 |
| the chapter, base to tip | 4 workers | 110m | 32 | +2822 −152 |
110 minutes of card time, one card at a time on the seam and three in parallel
after it. The suite went from 468 to 485 passing.
Every new module is inside the 200-line budget: core/reports.py 116,
verbs/filed.py 114, verbs/activity.py 145,
verbs/_stories.py 94, mcp/activity.py 131,
http/gitdoor.py 188.
Against the criteria: (1) is taskops_activity, with its budget
measured rather than asserted; (2) and (4) are pinned headlessly and at the byte
level in the shipped bundle; (3) is one line — the log's size before and after a
filed call, differing by under 1024 bytes for a report of any size —
and it is a test, not a claim.
Writing this report took four taskops_card calls, one per
card, because the tool that collapses them into one is the tool this chapter
built and it is not reachable from the session that built it. That is the next
section, and it is the most useful thing this exercise found.
What the dogfood found
A chapter that adds MCP tools cannot fully dogfood itself
This card was written to close the loop for real: read the chapter with
taskops_activity, write the report, commit it, register it with
taskops_filed, and watch it render. Three of those four ran. The two
that name the new tools could not, and the reason is structural rather than a
defect in anything the chapter built.
The host starts one MCP server per session, from the
installed taskops, and it loads once — at session start. The
code that defines taskops_activity and taskops_filed
lives on this chapter's branch. It is not in the installed tool and will not be
until the chapter lands and that tool is upgraded, and the running server will not
see it even then until the session restarts. So the tools are unreachable to every
actor in this session, orchestrator included — and the board is deliberately not
drivable from a shell, which is the correct refusal and not a gap to route
around.
Nothing here needs fixing; it needs saying. A chapter whose deliverable
is an MCP tool has a verification step that belongs to the session after
the landing, not to the close. The remaining half of criterion 2 — this report
appearing in the board payload's reports — is that step, and it is one
call.
What was verified for real, in this worktree, with no board involved: the file
below is committed, and the /git file door reads it back at that
commit through gitdoor.answer() — the same function the dashboard
calls — returning the resolved 40-hex sha, content_type: text/html,
truncated: false, and the bytes. The half of criterion 2 that is about
the reader's own clone is therefore closed here; the half that is about the
board's list is closed by one taskops_filed call in the next
session.
One defect, found by the read itself and fixed
Reading this report back through the door turned up a copy-paste slip in
gitwork/patch.py::show(): the git show <sha>:<path>
line appeared twice, so every report read forked git twice and
threw the first result away. Nothing was wrong with the answer, which is exactly
why nothing caught it — the behaviour is identical, ruff sees a reassignment
rather than an unused name, and pyright sees valid code. It took running the real
door against a real committed file and looking at the function to notice. Removed
in this close; the topology suite is unchanged and green either side of it.
The general form is worth keeping: a test suite proves the answer, and only exercising the thing proves the work done to produce it. That is an argument for the dogfood step, not against it.
Two smaller findings, flagged not fixed
core/types.pyis at exactly 200 lines. Zero headroom against the budget is a finding, not a pass — the next card that touches it has nowhere to put a line. Splitting it while two chapters were in flight over it would have been a conflict on purpose, so it was flagged during the chapter and is still flagged now: it wants a card of its own.- The MCP handshake's
INSTRUCTIONSdoes not mention the two new tools, and this close deliberately left it that way. The budget is a cliff, not a slope: the panorama gets whatever the protocol leaves under a 2900-character cap, and below 300 characters of room it is dropped whole. It currently has 1040 and is already being cut.INSTRUCTIONSis the role protocol — it names the tools a role can be refused — and neither new tool is either role's alone, while both arrive fully described throughtools/list. Spending the panorama to repeat a description the host already has is a bad trade.
Where the pieces live
| concern | file |
|---|---|
| the shape rule, the fold, the event kind | core/reports.py |
| the write door and its refusals | verbs/filed.py |
| a chapter's story, capped | verbs/activity.py · verbs/_stories.py |
| the file question on /git | http/gitdoor.py · gitwork/patch.py |
| the sandbox, and only there | ui/src/components/reports/ReportFrame.tsx |
| the tab and its page | ui/src/pages/Reports.tsx |
| the token is unreachable, headlessly | ui/smoke/sections/report-sandbox.tsx |
| the shipped bundle carries it | tests/test_ui.py::REPORTS |
Re-derive anything above: uv run pytest ·
git diff --numstat 665e37b..a85182f ·
find src/taskops -name '*.py' -exec wc -l {} + | awk '$2!="total" && $1>=190'