0006 — One session-state classifier: six states, a wake path, and null for everything else

The failure: five tools answer one question, and they disagree

Five programs on this machine independently answer "what is this session doing?"fleet-pulse.sh, watch6.sh (both in ~/.claude/scripts/), session_babysitter.py, phase_check.py (both in ~/.claude/scripts/propflow-status/) and operator-heartbeat.py. Four of the five also ACT on their own answer: they send keystrokes into panes and post to Slack.

The disagreements are not cosmetic. Measured on this machine, 2026-08-30:

Four "done" stores, three key types. A session's completion is recorded in ~/.claude/jobs/nudge/DONE-<sid8>; in ~/.claude/scripts/propflow-status/state/done/<tmux-name>; in the phase-doc registry state/phase-docs.json (sid8 → an HTML doc, whose items are the real answer); and in a task's task.json["state"]. Three of those are keyed differently from each other, so no two can be compared without a resolution step none of them performs. The two sid8-keyed stores share exactly one key — 592da2d2 — and that session is the only one on the box whose completion is genuinely certified. The rest of each store is invisible to the other. The babysitter's own store makes the point in miniature: of the three tmux names it holds a done-marker for, two name a tmux it does not track (its TRACKED list carries claude-fresh-1788019192; the marker on disk says claude-fresh-1787940467).

Three rosters that disagree about who exists. fleet-pulse.sh tracks four full session UUIDs; watch6.sh tracks seven sid8s — a superset, plus three the pulse never looks at; session_babysitter.py tracks seven tmux names, which cannot be compared to either without resolving a name to a session, an act CLAUDE.md already warns is not available by name matching. A session in one roster and not another is not reported missing anywhere. It is simply not asked about.

A live false-COMPLETE, and it is the sharp one. phase_check.py is four-valued and fails closed: DONE (items exist, none open, no unmerged "done" PR), NOT_DONE, NO_PHASE_DOC — whose own message is "not provable, therefore NOT DONE" — and NO_ITEMS, commented "an empty doc proves nothing". Only DONE exits 0.

fleet-pulse.sh inverts it. Its ladder tests for the literal NOT_DONE, and everything else — NO_PHASE_DOC, NO_ITEMS, and the empty string a crashed subprocess leaves in a shell variable — falls into the *) arm and is rendered :checkered_flag: complete, detail "declared done — no phase doc registered (unverified)", then counted into NC and subtracted from the open tally Gera reads. Re-measured while writing this ADR, over the six live DONE-<sid8> markers: five of six return NO_PHASE_DOC4ce58d91, 9612b05f, 548f1bdd, 6eb9b2f8, c55b3e4a. One, 592da2d2, is genuinely DONE. Five sessions were reported COMPLETE on a verdict whose own author refuses to certify it, by a tool whose header records Gera deciding the opposite the day before.

The 2026-08-30 phase-tracker incident. Session d3c29371: an idle composer, a registered phase doc reading NOT_DONE with 1 open item — "Waiting on Gera" — no armed nudge watcher, and a statusline showing ○ no operator. fleet-pulse.sh classified it idle and sent it "continue driving it to completion now — do not wait for a human." Every signal was individually benign. Read together they describe a session that will sit at a prompt until a person happens to look.

And a rule that was learned in one file and not the other. fleet-pulse.sh narrowed its esc to interrupt check to the last five non-blank lines with a source-line filter, after a session with that script's own source open was pinned working for 3+ hours by the literal string in its own code. watch6.sh carries the composer-region discipline in its header — "judge park from the COMPOSER REGION only" — applies it to its park check, and then greps the whole pane for esc to interrupt. The header and the code are on the same screen and disagree.

Three files, three answers to whether parked is terminal. ~/.local/bin/watcher-doctor says {done, parked, abandoned}; operator-heartbeat.py uses an eight-word set that excludes parked and includes failed, with a comment explaining why ("Terminal means finished, not blocked"); phase_check.py lists parked among the non-terminal words alongside blocked, raised and waiting. All three are defensible in their own scope, and no surface says which scope it is speaking from.

The decision

One classifier, in the relay, as a pure function. relay/src/pulse/: classify.ts takes a PulseSignals and returns one of six states — or null. drive.ts decides, separately, whether a nudge actually landed. Neither file imports fs, tmux or child_process; the collector that fills PulseSignals is a different layer with a different name, and this is the same shape csrf.ts, ask/addressee.ts and clampViewport already use.

The six are working, paused, waiting, needs-kickoff, done, dead. The reasoning for each branch, the ladder's order, and the park-string table with per-entry provenance live in classify.ts's header, which is the near copy. The five decisions below are the ones that are cross-system, and they are the reason this is an ADR and not a comment.

1. waiting requires a live wake path. Without one the state is needs-kickoff.

This is the load-bearing decision.

waiting carries an instruction — "do not nudge; the answer wakes it" — and that instruction is only true if something is actually listening. So the two states read the same evidence about the work and differ on one signal: is a nudge watcher armed for this sid, or a live Operator bound to its task.

The precedent is 2026-08-03, and it is why the Operator role exists at all: an approved decision sat unexecuted for hours because executing it was nobody's job. The answer landed; nothing was listening for it. A two-state model (blocked vs idle) cannot express this — it names the session's posture and says nothing about whether the world can still reach it, so a session parked forever and a session parked with an Operator watching render identically. The first is the one that needs a human, and it is the one that looks calmest.

2. unknown is null. It is not a seventh state.

The vocabulary is the six. A signal set that supports none of them yields null, which is COUNTED and SURFACED with the reason and the list of signals that could not be read — never folded into a neighbour, never promoted to working or done.

The shape is relay/src/roster/completion.ts's, adopted deliberately and for its stated reason: that module serves a report or null and has no done: boolean anywhere in it, because a boolean has a false branch, and a false branch is where "we did not hear" gets written down as a fact.

The losing option had real merit, and it came with an incident attached. A seventh state — stale, unknown, whatever it were called — is more legible on a dashboard: it sorts, it counts, it gets a colour, and a human scanning a board can see it without reading an evidence string. The argument for it is not aesthetic. On 2026-08-08, one transient bad read of a shared instrument retired nine watchers in 35 seconds; a system that had to name the bad read as a first-class thing would have had somewhere to put it, and someone might have seen nine sessions go quiet.

It lost for a reason specific to this system's failure direction: a state in the vocabulary invites being rendered as a state. It becomes a bucket, then a default, then an else — which is precisely the arc fleet-pulse.sh's ladder already completed, ending in a bare else that assigns idle and nudges on it, and readBlocks's state !== 'resolved' → open one layer over. This system's recurring defect is absence rendered as fact. The legibility is bought back where it costs nothing: null gets its own bucket in the tally and its own because clause, so a surface can show it prominently without the classifier having to claim it.

3. Classification is separated from actuation.

Four of the five legacy tools both classify and act — send-keys into a pane, a Slack post, a msg call. Every "nudged a session that shouldn't have been nudged" incident on this box is a coupling bug, not a classification bug: the phase-tracker nudge above was sent by the same elif chain that decided the state, in the same pass, with no seam where a human or a test could stand.

classify() is a pure function and returns a verdict. Acting on a verdict is a separate layer, and it is where the -t =name grammar, the permission classifier, and the delivery check live. This is the same rule ADR-0003 states for the supervision ladder — rungs integrate by signals, never calls — and intake.ts's: the relay writes a file, something else acts.

4. done fails closed on absent evidence.

Bucket on a verdict you RECOGNISE (DONE, matched exactly), never on the absence of one (!== 'NOT_DONE'). NOT_DONE is recognised too, as open work. Every other verdict, the crash, and the never-run case are UNCERTIFIABLE: they block done, they do not assert open work, and they push the verdict to null where a human looks.

This is the rule relay/src/blocks/blocks.ts already learned and states at length — read it there. What is new is only the direction: readBlocks's negative predicate manufactured questions, and this one manufactures completions. A false done is the one a human acts on.

A done therefore requires two independent things: a CLAIM (a DONE-<sid8> marker touched, or a task contract driven to a terminal state) and a CERTIFICATION (phase_check.py printing DONE). Silence supplies neither.

5. re-driving is printed only after a verified pickup.

A claim that a session was re-driven is a claim that a keystroke landed. In fleet-pulse.sh it is not: the short Slack line Gera reads is rendered from the state alone (*idle*) … sd="idle, re-driving"), while the send's success is &&-ed onto a different variable that lands in the long table. If msg fails, the short line still says "re-driving".

And exit 0 is not delivery either — CLAUDE.md's own measurement: send-keys against a pane-type target with a =name prefix fails while the session is alive, and display-message -p prints EMPTY with exit 0.

So drive.ts is five-valued and exactly one value may render as re-driving: delivered (sent, and pickup OBSERVED afterwards, independently of the sender's return value), no-pickup (sent, we looked, nothing moved — a measured failure, which must not blur into the next one), unconfirmed (sent, we never looked), failed (non-zero exit), not-attempted (we cannot say it ran). no-pickup is the shape a silently-failed send-keys produces, and not-attempted is kept distinct from failed because only one of the two justifies retrying blind.

Inventory of the superseded surface

ADR-0004 requires a superseding record to carry three parts. Parts 1 and 2 — the decision, and an inventory of what implements the old way — are here. Part 3 is stated as an obligation below rather than performed, and that is a deliberate sequencing, not an omission.

Tool Verdict Why
~/.claude/scripts/fleet-pulse.sh ABSORBED (classification only) — executed 2026-09-10, see the execution record below Its state ladder is replaced. Its Slack posting and nudging are the ACTUATOR layer and survive separately — they are a different job, and decision 3 is that they must be.
~/.claude/scripts/watch6.sh ABSORBED entirely — executed 2026-09-10, co.propflow.watch6 booted out and its plist fenced Its only output is an appended JSONL line at ~/.claude/watch6.jsonl and a copy on stdout. A sweep of ~/.claude/scripts, ~/.local/bin and ~/.claude/hooks finds no reader for that file other than the script itself and its own .bak.
~/.claude/scripts/propflow-status/session_babysitter.py ABSORBEDexecuted 2026-09-10, plist fenced Same question, a third roster, a fourth done-store.
~/.claude/scripts/propflow-status/phase_check.py NOT superseded — it is the arbiter It answers "is this work done", and the classifier consumes its verdict rather than replacing it. It is the one tool here with a real contract and fail-closed semantics: four verdicts, exit 0 on DONE alone. Nothing in this decision weakens it; decision 4 exists to stop a caller from inverting it again.
~/.claude/scripts/propflow-status/operator-heartbeat.py NOT superseded It answers a different question — is this task's Slack thread being kept current — not what state is this session in. Its TERMINAL_STATES set is scoped to that job and is correct for it.

Two corrections to the inventory as it was drafted, both from the live machine:

Execution record — ADR-0004 part 3, 2026-09-10

The open obligation named under Consequences was executed on 2026-09-10, on Gera's answer to block b88760351 ("Yes — switch off the three older tools now, keeping the Slack-posting half", answered via /agents at 2026-09-10T20:20Z). Switching off live background jobs is infra, which is why it waited a week for a person rather than being done on the collector's first green. What was done, in order, with the reversal for each:

  1. fleet-pulse.sh's running copy was committed first (dotclaude daca315). Git held 7b6c55f; the disk had 90 uncommitted lines, so the promised reversal — restore one script from git — named a commit that did not hold what was running. Now it does.
  2. co.propflow.watch6 was booted out of launchd (launchctl bootout, exit 0), verified absent from launchctl list, absent from launchctl print, and with no watch6.sh in the process table; ~/.claude/watch6.jsonl stopped advancing at its 15:47:41 CDT write. Its plist is renamed co.propflow.watch6.plist.disabled — a rename rather than launchctl disable, because the disable flag lives in /var/db where nothing on this machine reads it, and a fence should be visible in ls. Reversal: rename back, launchctl bootstrap gui/$(id -u) <plist>.
  3. co.propflow.session-babysitter.plist was renamed .plist.disabled. It was already unloaded; the plist was the one launchctl load from running again. session_babysitter.py and watch6.sh are untracked and were not touched — deleting either would be irreversible.
  4. fleet-pulse.sh was reduced to its actuator half (dotclaude 4d56978): it renders ~/.claude/jobs/pulse/snapshot.json — the tally copied from the snapshot, one row per session with a DETERMINED state, unknown as its own visible count, a missing/unparseable/stale snapshot rendered as exactly that — and posts to the thread. The ladder is gone and so are the msg nudges: the chosen option names the Slack half and nothing else, and with no send this script cannot claim "re-driving" (decision 3; the five-valued outcome in relay/src/pulse/drive.ts is the only honest way to make that claim, and it belongs to a layer that sends). Verified dry against the live snapshot and against a missing one.

A found fact, not an action: co.propflow.fleet-pulse had been unloaded since 2026-08-30 — before this decision — so the Slack line had already been silent for eleven days and nobody had asked for it. The plist is on disk, untouched; loading it is one launchctl bootstrap and posts every 300s to the thread PULSE_CHANNEL/PULSE_THREAD_TS name (env-overridable, defaulting to the August thread). It was deliberately not loaded here: 288 posts a day into a stale DM thread is outward-facing and was not requested.

The inventory table above stays — ADR-0004's rule is that the catalog is the fence, and a retired tool that is still listed with its retirement date is how a reader learns it was retired on purpose rather than lost.

Consequences

Closed: the catalog can now prove this is reached

⚠️ THIS SECTION USED TO BE Open, AND IT IS REWRITTEN RATHER THAN APPENDED TO — a decision doc whose open question is answered three screens below the question is a doc that reads as open forever. What it said: the entry sat as proposed with a file-age signal on a path nothing wrote, rendering unknown; the decision was implemented and tested but nothing collected signals into it, so no evidence existed that a classification had ever happened on this machine. Its closing line was the spec: "the collector, writing that snapshot on a fixed cadence. The entry flips to active in the same PR that ships the collector — not after, and not on a promise."

That is what shipped. relay/src/pulse/collector.ts runs inside the relay, every 120s, and stage-and-renames one document to ~/.claude/jobs/pulse/snapshot.jsonthe same path the signal already declared. The signal was not edited. The catalog entry is active in the same PR, and its liveness now rests on the collector actually running rather than on this document saying it does.

Why inside the relay and not a sixth launchd job. The relay already IS four of the ten signals PulseSignals declares — the roster, the pane map, the block-ledger reader, the task join — each with its own scar tissue (a null pid is not a dead process; a gateUnreadable errno is not an unarmed gate; an unreadable ledger root is not an empty one). A separate daemon would be a second implementation of all four, deploying independently and free to disagree with /api/roster about the same session on the same second. That is the failure digest/phases.ts refuses one directory away, and adding a fifth process would also be one more of exactly the thing this classifier exists to absorb.

What the collector does NOT do, and this is the load-bearing half. It observes and it writes one file. It never types, kills, resizes or creates a tmux; the only tmux command on its path is capture-pane, taken with no -S so the composer region — not the replayed scrollback that produced the 11-parked-sessions-when-1-was measurement — is what the park check reads. It also never ACTS on a verdict: rung 3 decides, rung 2 actuates, and every "nudged a session that shouldn't have been" incident this replaces came from one elif chain that did both. driveVerdict() remains the separate decision about whether a keystroke landed, and nothing in the collector calls it.

The one place this could still rot. The cadence (120s) is a promise to the entry's staleAfterS: 900, and a promise stated in prose is the stale sentence this repo keeps finding. So relay/tests/pulseCollector.test.ts parses SYSTEMS.md, locates this entry, and asserts both the margin and the active status — with its first assertion being that it FOUND the entry, because a parser that matches nothing agrees with every number.

And one honest limit, since a failure path that has never fired is not a tested one. A cycle that throws writes NOTHING and lets the file age, which is what turns the catalog signal red; that branch is exercised by a unit test and has not yet been observed in production. The collector's own health is also summarised on /health as pulse / pulseError, so a stopped collector is visible from two directions rather than one.