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:

  1. one taskops_activity call returns spec, thread, commits with numstat, standing and merged_into for every card of a chapter, and since=<seq> returns only what moved;
  2. a report registered on chapter X appears in the UI's list for X and renders from the local clone — while taskops serve still serves no UI and no report;
  3. events.jsonl grows by less than 1KB per report, whatever the report's size;
  4. 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

agent:berna/w1 · 43m · be8a563 · 10 files, +572 −5 · landed FIRST and alone

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

agent:berna/act1 · 27m · fa2e181 · 11 files, +766 −14

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

agent:berna/door1 · 22m · 09c48fb · 4 files, +324 −6

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

agent:berna/ui1 · 18m · 77fb358 · 12 files, +1163 −130

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

agent:berna/close1 · docs, the one bundle rebuild, and this file

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
The fourth acceptance criterion, compressed into one line against the SHIPPED bundle bytes. A minifier keeps string literals, so this is what would notice a rebuild from a tree where the sandbox had been widened — which no render of a well-behaved fixture ever could.

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>
One guard, one function, asked at both ends: by the verb that registers a report and by the door that later reads its bytes. That is what stops the two from drifting into a file server behind the dashboard's token.

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

cardworkerworkedfileslines
tk-ca31e0 the seamagent:berna/w143m10+572 −5
tk-dd304f taskops_activityagent:berna/act127m11+766 −14
tk-9ebbc3 the file dooragent:berna/door122m4+324 −6
tk-535807 the Reports tabagent:berna/ui118m12+1163 −130
the chapter, base to tip4 workers110m32+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

Where the pieces live

concernfile
the shape rule, the fold, the event kindcore/reports.py
the write door and its refusalsverbs/filed.py
a chapter's story, cappedverbs/activity.py · verbs/_stories.py
the file question on /githttp/gitdoor.py · gitwork/patch.py
the sandbox, and only thereui/src/components/reports/ReportFrame.tsx
the tab and its pageui/src/pages/Reports.tsx
the token is unreachable, headlesslyui/smoke/sections/report-sandbox.tsx
the shipped bundle carries ittests/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'