0007 — The phase table crosses by snapshot: Smith derives, the board displays

The rule

  1. Agent Smith writes a versioned JSON phase snapshot, one per task, at ~/.claude/jobs/tasks/<slug>/phases.json (agent-smith/src/agent_smith/phase_snapshot.py, #348). Atomic tmp+rename. It never creates the task directory — a missing task dir is a real state (the thread binding outlived its task, or session-gc took it), and mkdir -p would answer that by manufacturing a row on the board.
  2. The relay transports it as two additive fields on rows it already servesphases and phasesUnreadable on TaskSummary (relay/src/digest/phases.ts, wired in digest/digester.ts, #83). No new route. The board reads it through the existing /api/feed payload.
  3. The board displays rows; it never computes them. The Phases card (propflowai #6644) maps a row's state to a glyph and nothing else. There is no phase resolution in TypeScript.
  4. The canonical close-up is /agents/[sessionId]. Not ?item=<sid> — that opens the drawer and survives only for old bookmarks. Any doc or plan that says otherwise is stale; this line is the authority.

Why the transport was FORCED, not chosen

This is the part worth keeping. It reads like duplication — Smith renders a table, and now a file carries the same table — so the reason it is not duplication has to be on the record, or the next session will "clean it up".

The board physically cannot re-derive these rows. The receipt, run in this repo on 2026-08-30:

$ grep -rn SMITH_STATE_DIR relay/src supervisor/src
$ echo $?
1

Nothing. The relay reads ~/.claude/jobs/tasks/<slug>/task.json, events.jsonl, acceptance.json. But half of what a phase row is derived from — pr_url, review_at, merged_at, deployed_at, preview_url — lives in Smith's own evidence store under SMITH_STATE_DIR, which no process the board can reach has ever opened. The two share no store. That is why the board has never shown a phase, and it is a structural fact, not a missing feature.

And the fix is not to teach the relay to read that store. It would need a second implementation of agent_smith/phase_table.py, whose entire value is being the ONE resolver over those facts — that module was rewritten precisely to end two rows answering one question from two files (the header said live while the verdict row said closed, for eight hours). A relay that re-derived the rows in TypeScript would be that same incident with a network hop in the middle, and the disagreement would be between Slack and the board, where nobody can see both at once.

So: derive once, in the process that owns the facts; ship the already-rendered rows. If a row is wrong, phase_table.py is wrong, and both surfaces move together.

Why a file and not a call (ADR-0003)

The obvious alternative — the board asks the mini for a task's phases over HTTP — is the thing ADR-0003 forbids: rungs integrate by signals, never by calls. A live RPC from Vercel into the mini would put a laptop-resident agent process on the request path of a page render, and make a Smith restart look like a board outage. The snapshot is a signal in exactly ADR-0003's sense: the writer emits, the reader consumes if it is there, and neither knows whether the other is running.

It also satisfies agent-smith's Constitution §1 (DRY): one resolver, in Python, and phases / states ride along inside the document as the vocabulary the rows were built against — so a consumer in another language can assert it renders the whole row set without keeping its own copy of a derived list.

The contract: versioned envelope, and refuse the whole table

Two repos deserialize this document and they deploy independently, so the reader is deliberately strict.

What is inherited and what is new, stated precisely — the precedent is relay/src/blocks/stars.ts, and it is a narrower precedent than it is tempting to claim. readStars contributes the unreadable discriminant and the absence-is-not-unreadable split (ENOENT is an empty set; EACCES, EPARSE and a wrong shape are unreadable). It does not version-check — it writes { version: 1, … } and never inspects version on the way back in — and it drops a malformed row with continue rather than refusing the set. Version-pinning and whole-table refusal are new here, introduced by phases.ts in #83, because this document crosses a repo boundary and a stars file does not.

condition result
no file (ENOENT) undefined — the ordinary case. Most tasks are not Smith threads; "this task has no phase table" is true.
EACCES / EIO / truncated / unparseable phasesUnreadable — the table could not be read
version this build does not know phasesUnreadable (EVERSION) — refused, never parsed optimistically for the fields that happen to look familiar
one row of N unreadable phasesUnreadablethe WHOLE table is refused, never the N−1 survivors

The last two rows are the ones people would soften, and they are the two stars.ts does not have. A table with a hole must look like one: serving the survivors renders a shorter table that looks complete, and a card told "no phases" would show a blank where there is a live one. Absence of a readable value is not a value — the rule blocks.ts carries the four incidents for.

Adding a field is not a version bump (readers ignore what they do not know). Removing or re-meaning one is.

What a future change must not do

Consequences