0006 — One session-state classifier: six states, a wake path, and null for everything else
- Status: Proposed — the implementing PR (agentflow-relay #81, the ⚠️ VOCABULARY: the role this ADR calls the Operator is the Driver since 2026-09-10 —
operatoris the product's word for a PM company. Decision text below is unchanged; ids, binaries, trigger phrases and theOperatorTaskWorkflowtype that still sayoperatorare live invocations and deliberately lag. See ADR-0021. (Appended toStatuson purpose: this adds NO line, so every line-number citation into this file stays true.) classifier and theneedsOwnerflag) is green and unmerged, and the catalog entry this ADR ships declares"status": "proposed"for the same reason: nothing collects signals into the classifier yet, so no classification has been observed to happen. Flips to Accepted with the collector, in the PR that flips the catalog entry — one change, not two. - Date: 2026-08-30
- Deciders: The Operator of task
session-state-classifier, holding the criterion. It extends a decision Gera already made once — "DONE MARKER ALONE IS NOT COMPLETE" (recorded infleet-pulse.sh, 2026-08-29, "after a night of false COMPLETEs") — which was implemented in one tool, on one branch, and is inverted by the branch beside it.
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_DOC — 4ce58d91, 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.
- either signal
true→waiting, and the wake path is named in the verdict; - both signals
false→needs-kickoff; - an unread signal is neither, and the verdict is
null.waitingon an unread wake path publishes "do not nudge" on evidence nobody looked at;needs-kickoffasserts an absence nobody measured.
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 |
ABSORBED — executed 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:
co.propflow.watch6is loaded in launchd and running. Absorbing it means unloading a live job, not deleting a dead one — the JSONL has no reader, but the writer is very much alive.co.propflow.session-babysitteris not loaded (launchctl listshows no such label), but~/Library/LaunchAgents/co.propflow.session-babysitter.plistis present on disk. An unloaded job with a plist is onelaunchctl loadfrom running again, so "currently not loaded" is a fact about right now and not a removal.
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:
fleet-pulse.sh's running copy was committed first (dotclaudedaca315). Git held7b6c55f; 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.co.propflow.watch6was booted out of launchd (launchctl bootout, exit 0), verified absent fromlaunchctl list, absent fromlaunchctl print, and with nowatch6.shin the process table;~/.claude/watch6.jsonlstopped advancing at its 15:47:41 CDT write. Its plist is renamedco.propflow.watch6.plist.disabled— a rename rather thanlaunchctl disable, because the disable flag lives in/var/dbwhere nothing on this machine reads it, and a fence should be visible inls. Reversal: rename back,launchctl bootstrap gui/$(id -u) <plist>.co.propflow.session-babysitter.plistwas renamed.plist.disabled. It was already unloaded; the plist was the onelaunchctl loadfrom running again.session_babysitter.pyandwatch6.share untracked and were not touched — deleting either would be irreversible.fleet-pulse.shwas reduced to its actuator half (dotclaude4d56978): it renders~/.claude/jobs/pulse/snapshot.json— the tally copied from the snapshot, one row per session with a DETERMINED state,unknownas 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 themsgnudges: 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 inrelay/src/pulse/drive.tsis 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
Nothing here deletes a tool. Recording the decision comes first; removal is a separate, sequenced change, and it is blocked on the collector existing — ripping out
fleet-pulse.shbefore something else posts to that Slack thread replaces five disagreeing answers with none. ADR-0004's part 3 ("removal or fencing, in the same effort") is therefore an open obligation on this effort, and it is enumerated: unloadco.propflow.watch6; remove the babysitter plist or load-fence it; reducefleet-pulse.shto its actuator half. Until each of those lands, the tools stay listed here, which is the fencing available today — the inventory is the thing that was missing when tmux "was deleted intentionally" and three implementations survived.The four done-stores are not consolidated by this decision and a consolidation is not proposed.
phase_check.pystays the arbiter; the other three remain CLAIMS, which is all they ever were. What changes is that a claim can no longer certify itself.A collector must resolve a sid to a pane through the process table.
fleet-pulse.shresolves bycapture-pane | grep <sid8>, which self-matches: any pane merely displaying the id matches, including a log, a spec, and the tool's own source.relay/src/tmux/paneMap.tsalready derives the mapping from pid ancestry (or theclaude attach <sid>viewer), measured 63/63 on 2026-08-04.classify.tstakes the resolved observation as an INPUT and cannot re-open the hole; the collector can, and must not.kind === 'interactive'is not a liveness test.deadis a POSITIVE reading of the process table, kind-agnostically — the rulesupervisor/src/alive.tsstates, where 11 of 12 background sessions had no pane ancestor at all.The park-string table is data with provenance, because the sources disagree.
fleet-pulse.sh's header prescribes strings its own live regex does not contain. Neither can be settled without a genuinely capped session to look at, so the classifier takes the UNION and records per entry which source claims it. The asymmetry justifies it: a falsepausedcosts one glance at a pane; a missed cap is a session sitting dead while a classifier reports a healthy fleet.needsOwneris a backlog, and THE THING THAT WOULD STOP IT BEING REPLENISHED IS NOT WRITTEN DOWN ANYWHERE. A companion effort (kickoff-table-to-agentflow) is making an operator sid part of the kickoff binding rule, so that NEW work arrives owned; the flag defined here enumerates the sessions that predate that rule, for a retrofit sweep. Neither is sufficient alone: a binding rule does not reach backwards, and a worklist refilled from the front never empties. That rule is now recorded as ADR-0008, which is the document to read for it — verified onmainto actually carry the claims cited here, not merely to exist: it namesstart-operator's exit 3 as the authority for the double-spawn guard (§"Layer 2"), quotes the~/.local/bin/start-operator:413tmux has-session -t "=$TMUX_NAME"guard, and states the prefix hazard the=closes. It is itselfProposedwhile its implementing PR is unmerged, so cite it for the DECISION and not yet for deployed behaviour. ⚠️ The earlier revision of this passage cited ADR-0007 for it, and that was wrong. ADR-0007 is the phase-snapshot transport; it contains no operator-sid binding rule and no auto-spawn path. The error is worth recording because of its shape: ADR-0007 was verified to EXIST and its title read, then cited for content learned from a peer's chat messages. That is presence checked in place of substance — the same defect this document catalogues twice elsewhere (a read-back that proves a watcher exists but not that it can reach; adonethat buckets on the absence of a word). A link resolving is not the claim holding. ⚠️ The flag is advisory and a sweep must re-verify before it spawns. It reportstrueon a bound operator whose statusline still renders the marker (stated atneedsOwnerFor), and/api/rosteralready treats coverage as a CONJUNCTION — a task naming anoperatorSidAND that session being alive. Retrofitting onto a session that already has a live Operator is the two-loopers failure this repo names everywhere else; the flag narrows who to look at, and settles nothing on its own.The re-verification to use is
start-operator's own exit 3, not a reimplemented conjunction: exit 3 means a liveop-<slug>tmux already exists, which is a PROCESS fact rather than a render. Verified directly in~/.local/bin/start-operator, which is the authority —tmux has-session -t "=$TMUX_NAME"guardingexit 3with "refusing: tmux session already exists — that is another task's Operator." Note the=exact-match: a bare-t op-fooresolves by PREFIX and would reach a liveop-foo-bar. Call the launcher and read the code. Two limits, stated because inheriting them silently is the hazard: the task-directory check in front of it is a cheap filter and explicitly not the guarantee (a GC'd task dir passes it and then hits exit 3 at the launcher), and the slug-claim mutex that covers retries and racing surfaces is a WITHIN-PROCESS lock — an out-of-process sweep gets nothing from it, so exit 3 is the whole guarantee there.A WAKE PATH COUNTS ONLY IF IT CAN REACH THE SESSION. PRESENCE IS NOT REACHABILITY. This is the sharpest correction to land on this decision, and it came from the lane it would have broken.
wakePathFortreats an armed watcher as a live wake path, which is true for a bash watcher (it types into the pane) and not automatically true for the Cloud half: that wake path runsagent-send <name>, which resolves DAEMON-HOSTED jobs only. An interactive tmux lane — which is whatstart-operatorproduces — is not a daemon job, so the Cloud nudge returnsno sessionforever and never arrives. Measured 2026-08-30 on two lanes:agent-sendreturned exit 3 (no daemon-hosted match) on one and exit 4 (no daemon control socket at all) on the other. Only exit 0 is evidence of reachability; 3 and 4 are different causes with the same consequence.Two things follow, and the second is the one that bites:
watcher-doctor'sCLOUD+BASHverdict — "stacked, stand the bash one down" — is correct for daemon-hosted lanes and inverted for interactive tmux ones, where the bash watcher is the ONLY waker. Following it there converts a noisy-but-working lane into a quiet-and-broken one, and then reports it healthy, becauseSTOOD-DOWNis a clean state. The verdict should be conditioned on the Cloud half being reachable (agent-sendreturning 0), which is a process fact of the same kind as the exit-3 primitive above.operator-here's read-back has the same blind spot one layer up. It printsARMED — Temporal watchingon a live workflow DESCRIBE, which proves the watcher EXISTS and says nothing about whether it can reach the session it watches. A lane can hold a green read-back and no waker at all. This ADR's own author did: stood the bash watcher down as a duplicate on a green read-back, then stalled on an account cap and was restarted by a HUMAN, because nothing else could. An instrument that cannot fail in the direction of the thing it checks is the defect this whole document is about, and it was sitting in the tool used to verify the document's own premise.
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.json — the 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.