Systems catalog

Rendered from SYSTEMS.md in the agentflow repo — the registry the Systems tab evaluates live. If a system is not on this page, it does not exist: check here before building a tool, and add an entry (with liveness signals) when you ship one. Each section carries the catalog entry, how its liveness is read, the prose beside it, and an appendix of everything linked from it.

This file is the registry. If a system is not in here, it does not exist — check this file before building a tool, because the failure mode this catalog exists to kill is two sessions independently building the same thing (the disease Spotify built Backstage's Software Catalog for; this is the same pattern at fleet scale, and it sits alongside PropFlow's existing metrics catalog and components catalog — one consistent answer to "what exists, who owns it, is it alive?").

Rendered live at propflowai.co/agents → Systems tab: the relay's GET /api/systems parses this file and evaluates each entry's signals against the machine's real state (file ages, log lines, launchd jobs), so the page always shows derived truth — nobody "updates the page". Editing this file in a reviewed PR is the only way a system enters, changes, or leaves the catalog.

Format: each ## section is one system, carrying one fenced json block the relay parses. Prose around it is for humans. Fields:

Decision records live in docs/adr/ in this repo. A decision that changes how systems relate is not done until it is written there — decisions made in conversation die in conversation (learned 2026-08-29, the tmux→SendMessage decision that existed nowhere).


nightly eval queue

What it doesRuns the maintenance, turnover and morpheus benches against the prod bench property every night, serialised so they cannot race
Kindpipeline
Repohttps://github.com/PropFlow-Technologies/propflowai
Deployed atTemporal tools-prod :: NightlyQueueWorkflow
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → nightly eval queue

Liveness signals

SignalHow it is read
turnover scope-sync greenlog-match-age · ~/Library/Logs/turnover-eval-scope-sync.out.log · matches VERDICT: ALL GREEN · stale after 1800 min
turnover multi-trade greenlog-match-age · ~/Library/Logs/turnover-eval-multi-trade.out.log · matches VERDICT: ALL GREEN · stale after 1800 min

This entry exists because the catalog's silence nearly retired four working detectors. On 2026-09-02 decision b88033792 proposed retiring turnover-eval-daily, maintenance-eval-daily, morpheus-nightly-daily and git-hygiene-main-drift as "not scheduled since July". Every instrument agreed: no plists, nothing in launchctl, nothing in ~/.claude/jobs (the registry was retired 2026-07-04), and nothing in temporal schedule list. All four were running the whole time.

They moved off launchd into NightlyQueueWorkflow in the tools-prod namespace in early July — propflowai-smith/config/automations.toml is the source of truth, and its own comment records morpheus's standalone 05:00 LaunchAgent being retired on 2026-07-12 when it joined the queue. The queue exists to SERIALISE them: as separate launchd jobs they raced on the appfolio-45 bench. Reinstalling the plists would double-run them nightly against the same property.

⚠️ temporal schedule list DEFAULTS TO THE APP NAMESPACE. --namespace tools-prod returns Request unauthorized from an ordinary shell, so an unauthorised read renders as an empty list and then as "not scheduled". That is could not look collapsed into no — the error liveness exists to forbid, arriving through a scheduler CLI instead of a roster.

⚠️ git-hygiene-main-drift is NOT a job. It is a detector riding MorningQueueWorkflow step 0 with review_by = "permanent" and no cron of its own. "Reinstall it" and "retire it" are both category errors.

Why only turnover carries signals, and why that is deliberate. The two turnover legs end their logs with VERDICT: ALL GREEN at a FIXED path — a word that means success, which is what ADR-0009 requires. Morpheus and maintenance-eval write date-keyed outputs (~/Library/Logs/morpheus-nightly/<ISO>-<scenario>.log, /tmp/maintenance-eval-state/golden-<date>/), so there is no stable path to match a verdict against. A file-age over those directories would prove only that something wrote a file — and morpheus is currently failing (grid RED on gen-i3-pivot-ambiguity-sink-leak-pooling, 2026-09-02), so that signal would paint a green row over a real regression. That is precisely the lie ADR-0009 exists to prevent, so the gap is named here rather than papered over: giving those two benches a stable verdict file is the work that would earn them a row.

Appendix

host headroom + session cap

What it doesSamples whether the Mac mini can still host the fleet — free memory, compressor, swap, and the fleet's own footprint — REFUSES a new session past the concurrency ceiling, and asks sessions idle past the window to close themselves
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/host-headroom, ~/.local/bin/session-cap, ~/.local/bin/session-exit
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → host headroom + session cap

Liveness signals

SignalHow it is read
sampler scheduledlaunchd · co.propflow.host-headroom
expiry scheduledlaunchd · co.propflow.session-expire
sampler reportinglog-match-age · ~/.claude/jobs/host/host-headroom.log · matches HOST · stale after 30 min
snapshot freshfile-age · ~/.claude/jobs/host/snapshot.json · stale after 30 min

Detail document: ~/.claude/jobs/host/snapshot.json (the Systems tab opens this row into it).

Why the pattern is the literal "HOST " (2026-09-03). The evaluator matches log patterns as SUBSTRINGS, not regexes — HOST (OK|PRESSURE) matched nothing and the row read down for a sampler that was writing every 5 minutes. Every sample line begins HOST , OK and PRESSURE alike, which is exactly the liveness question this row asks.

Why the liveness signal matches HOST PRESSURE too (2026-09-03). The sampler's OK/PRESSURE split is the WARN tier (swap >2G, compressor >25%, free <4G) — bars a 64G mini crosses in ordinary 30-session operation, by session-cap's own calibration (its DANGER tier, measured against the real panic, is compressor ≥50%, swap ≥16G, free ≤1G, 2-of-3, and it refuses starts there). Keying this row on HOST OK alone rendered the system "stale" — which reads as the instrument broke — for every busy half-hour: on 2026-09-02/03 it sat red for a day of legal load. Stale now means what it says (the sampler stopped writing); danger-tier pressure is the bake's job, which reads the same log against the cap's own bars.

Every other entry in this catalog describes something that restarts a part of the fleet. This one describes the floor they all stand on, and it is here because the floor gave way. On 2026-09-01 at 13:06 the mini kernel-panicked after 81 hours of uptime. The panic string named watchdogd ("no checkins in 90 seconds"), which is the symptom; the stackshot named the cause: 88 Claude Code processes holding 114.8 GB resident on a 64 GB machine, 14.9 MB of free memory, a file cache evicted to 1.8 MB, 35.6 GB held by the compressor across 65 swapfiles, and a pageout path that asked for 3,090 pages and reclaimed 23. Nothing could be scheduled, so nothing checked in, so the kernel panicked on purpose. ccswitch was healing wedged sessions and rotation was spreading credentials the whole time — every rung was working, inside a machine none of them could see.

Session age is the variable, not session count. A fresh session is ~157 MB; the ones that died were ~1.6 GB each, because a Claude process grows with its context. Eighty-eight fresh sessions are harmless and eighty-eight mature ones are fatal, which is why the snapshot carries eldestSessionHours alongside the count.

Measuring was never going to be enough, and on 2026-09-02 Gera said so. Decision b88294643: "Cap only: refuse to start new sessions beyond a concurrency ceiling (~35 on this hardware), never killing a running one" — and in his note: cap, log the refusal, do not create more until they are done, and clean them up when they finish. session-cap (local-bin#64) is those four clauses. start-operator, start-architect and spawn-pair all consult session-cap check before tmux new-session; session-cap reap returns slots by removing tmux sessions whose panes are ALL ALREADY DEAD.

⚠️ It must never touch a running session. The reaper was the rejected option, and it was rejected for a reason with a date: on 2026-08-08 this fleet's idleness detection misread a transient probe failure and retired 7 of 10 loops whose tasks were still active. reap's predicate is pane_dead — tmux reporting a process has EXITED, a fact rather than an inference.

⚠️ An undeterminable count ALLOWS, loudly, and this is deliberate. Same three-valued rule as liveness: "could not look" is not "no". A cap that failed closed on its own bug would wedge every spawn path on the machine — including the session someone would start to fix it. The panic cost one reboot; a wedged fleet costs every session. The spawn sites branch on exit code 1 specifically, never on plain command failure, because if ! session-cap check reads exit 2 (could not look) as a refusal and reintroduces exactly that.

There is deliberately no new signal row for the cap. Its log (~/.claude/jobs/host/session-cap.log) records refusals, and a refusal is a NORMAL, healthy outcome — a log-match-age over it would colour the row red precisely when the cap was working. The honest reading of "is the cap alive" is the same one that answers "is the host alive": the sampler's HOST OK line.

HOST (the sample prefix) is the load-bearing string, and that is a liveness choice. Until 2026-09-03 this signal matched HOST OK alone, so a degrading-but-reporting host (lines flowing, all of them PRESSURE) went red here — and a day of ordinary WARN-tier load rendered as stale, the word that means the instrument broke (review verdict on #105, landed as #106). Now this row answers exactly one question — is the sampler alive and writing — and degradation is judged where the bars are calibrated: the session-cap DANGER tier refuses new starts, and fleet-bake's danger-band rule (the cap's own thresholds, 2-of-3) puts a true panic in the regression list by name.

⚠️ Free-memory percentage is the trap, and is deliberately not the primary threshold. At panic inactive stood at 12.2 GB — nominally reclaimable, enough to read as ~19% available on a machine seconds from death. The kernel asked for 3,090 pages of it and got 23. Inactive memory is a promise, not a reserve. The signals that moved first were swap and the compressor, so those carry the thresholds (>2 GB swapped, >25% of RAM compressed, >40 GB of Claude RSS) and free memory is the last resort rather than the headline. Thresholds are regression-tested against that panic's real measurements in ~/.local/bin/tests/host-headroom.py.

host-headroom measures and nothing else, and that stays true: reaping an idle session is a judgement about whether work is worth continuing — rung 3's question under ADR-0003, and Gera's call, never a sampler's. session-cap is where the judgement lives, and it now has two verbs rather than one.

⚠️ reap ALONE WAS A NO-OP FOR MEMORY, AND THE DAY IT SHIPPED PROVED IT. Its predicate is pane_dead — the process has ALREADY EXITED — so the pages it held are already gone and the slot it returns is a tmux slot. Measured 2026-09-02, hours after the cap landed: the cap REFUSED at 36/35, 14 finished sessions were holding 6.4 GB, and reap found nothing to remove, because every one of those processes was still running. Closing them by hand took free memory from 0.8 GB to 15.5 GB. Clause 4 needs something that ends a PROCESS.

So a finished session now leaves, by its own exit path. Two callers, one primitive:

  • agentflow-supervisor's exits.ts phase, for a session whose task is terminal — it runs after releases.ts, asks once, requests at most three times, then raises session-will-not-exit for a person.
  • session-cap expire (launchd, every 30 min), for the sessions no task names — 48 hours of transcript silence, tmux inactivity and session age, all three, with the roster only ever able to VETO.
  • Both call session-exit <sid>, the single implementation of how a session leaves: claude stop for background, /exit in the pane for interactive. Getting that split wrong is silent in both directions, which is why there is exactly one copy of it.

⚠️ THIS AMENDS b88294643, WHICH DECLINED A REAPER — see agentflow ADR-0010 for the full argument. In short: nothing here signals. Every exit is the session's own, so Stop hooks run, the transcript flushes and the conversation resumes (verified live 2026-09-02 — a 355 MB session stopped, its transcript GREW on the flush, and claude --resume returned with full context). Nothing touches a session whose task is still open, awaiting-human included. And the clock is a TIMESTAMP, never a status word: on 2026-08-08 a transient probe failure produced a plausible wrong STATUS and 7 of 10 live loops were retired in one burst, so here a failed read yields no reading, at most 3 sessions expire per run, and if more than a quarter of the fleet qualifies at once the run expires NONE and says so.

⚠️ expire FIRED ON NOTHING FOR ITS FIRST FIVE DAYS, AND THE REASON WAS THE CLOCK, NOT THE FLEET. The old note here read the zero as inconclusive — 28 live sessions on 2026-09-02, longest silence 4.5 h, the box only up 27 hours. That explanation was wrong, and the instrument said so once anyone counted: 223 runs, one session expired, ever.

transcript_age_h() asked stat for the transcript's mtime, and something on this box touches those files without appending to them. Measured 2026-09-07: 5581af18 had gained no record in 114 hours while stat reported a write 215 seconds earlier; nine of the 49 held sessions read wrote 0.0h ago for that reason, and 0eff9218 — silent 55 h — was already past the very window it was being held under. A 48-hour threshold cannot be crossed by a clock that something resets every few minutes. Fixed in local-bin#83: the clock is now the last timestamped record, which ctx.stale_hours() had always read — one instrument where there had been two that disagreed — the same consolidation session-id (local-bin) made when nine fleet tools each carried their own answer to "who am I" and three consecutive calls returned three strangers. First honest dry run surfaced exactly two abandoned sessions (121 h and 62 h) while the six that had been lying now report honest ages of 2–21 h and correctly stay held.

A VETO MUST STAY CORROBORATED TO KEEP VETOING (local-bin#84). The clock was one of four latches. The other three shared a single cause: they ran BEFORE the clocks, so none was ever checked against evidence. Measured 2026-09-07 — four sessions held on a roster waiting set 120–143 h earlier, eight on stand-down markers up to 119 h old (26 exist, the oldest 457 h; the marker carries no expiry of its own), seven on awaiting-human tasks nobody had touched in 93–158 h. A marker with no expiry is not a person holding a session; it is a file nobody deleted.

The three clocks are now read first and the latchable vetoes are asked to agree with them. ⚠️ This does not make a status word a trigger — the 2026-08-08 inversion holds, nothing expires because of a status. All three clocks must independently be old before a stale veto stops counting, and the verdict names what it ignored, so the log never drops a hold silently:

DRY would expire 367e7813 — idle 120h (last turn), 122h old, nobody attached;
                            stale holds ignored: stand-down 121h old

What stays absolute, and no clock outvotes: a session pinned in the Agent view (pins.json is the deliberate keep, and the one to use for a hold that should outlive any window), undrained mail, an attached tmux client, recent tmux activity, an absent status, and a task in active or in a state this tool does not recognise. Among task states only awaiting-human is de-latched — it is the one state where no agent acts without a person, so a warm process buys nothing a resume would not restore, and expiring does not touch the task, which stays awaiting-human on the board exactly as it was.

A CLOCK MUST STILL BE TRUE AT THE MOMENT YOU ACT ON IT (local-bin#85). Selection and action were separated by a roster read — which has taken the full 30 s and timed out — plus a transcript read per candidate, with nothing re-checked in between. Measured 2026-09-07: c9a4b20d was 122 h idle when it was selected, a nudge wake landed in the gap, and /exit was typed at a session that had begun working seconds earlier. Nothing was lost, because a busy composer swallows the keystrokes — which is also why the run logged ISSUED for a session that never left. The session's own clock is now re-read immediately before the exit is issued, and one that has spoken since selection is stood down for that run:

EXPIRE STOOD DOWN c9a4b20d (bin-91) — it took a turn 0.0h ago, inside the 48h
                                      window; not typing at a working session

An unreadable transcript at that moment counts as woken, not as permission. The summary line carries woke=N, so a stood-down session is never silent.

session-cap expire -n prints which hold spared each session, and is the instrument for all of it.

And the brake can blind itself under load. Five runs logged EXPIRE UNKNOWN roster unreadable (claude agents --json timed out after 30s) — the fuller the fleet, the slower the roster read, so the pressure that makes expiry necessary is what stops it happening. Failing to no-reading is correct; needing to is the finding.

Appendix

Fleet stability check

What it doesEvery 2h, reads what host-headroom and session-cap already measured, adds the four checks the 09-17 spawn storm proved were missing, and posts to Slack when something is wrong
Kindreflex
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/fleet-stability
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → Fleet stability check

Liveness signals

SignalHow it is read
check scheduledlaunchd · co.propflow.fleet-stability
check reportinglog-match-age · ~/.claude/logs/fleet-stability.log · matches FLEET · stale after 360 min

This exists because the instrument was right and nobody heard it. On 2026-09-18 08:02 the mini kernel-panicked for the second time that month, on VM compressor segment exhaustion. host-headroom had been reporting HOST PRESSURE continuously since 2026-09-16 21:31 — 34.5 hours — and sampled last at 07:53:36, eight minutes before the panic. Nothing was wrong with the detector. Measured the same day: no path on this box joined it to Slack, a canvas, or any other human-facing surface. It wrote a log nobody opened.

So this member adds no measurement of the host. It reads host-headroom's snapshot — its state and reasons verbatim, so the panic-calibrated thresholds live in exactly one place and cannot drift into a second copy — plus session-cap's live= series. On top of that it carries the four checks the 2026-09-17 storm proved were missing: burst (the fleet grew +90 in two hours to a peak of 134 against a ceiling of 35), roster-as-prompt (new jobs whose intent is the session roster — the nudge:1568 bug, local-bin #231, returning), armed copies (files still carrying the unescaped nudge backtick anywhere, including worktrees, where it outlived the working-copy patch by a day), and stuck-blocked (live sessions parked on a question nobody will answer, which session-expire cannot reach because they are younger than its 48h window and their status is not idle).

⚠️ IT ONLY READS AND REPORTS, AND IT MUST NEVER SPAWN. No killing, throttling or reaping — ending a session is rung 3's judgement and Gera's call under ADR-0003. The no-spawn rule is stricter than politeness: a monitor that can start a session is the exact shape of the bug this one watches for.

⚠️ TWO HOURS IS THE HUMAN CADENCE, NOT THE DETECTION CADENCE. The 09-17 storm went 16 to 134 sessions in 2h30m, which a two-hour poll would have caught once. Fast detection stays with the 300s host-headroom sampler in the host entry; this rung's only job is that a person finds out. Slack carries exceptions plus one all-clear a day, because twelve green posts a day is wallpaper and a muted channel is worse than no channel — and the log line is written on EVERY run, which is what the signal above reads, so silence in Slack never has to be taken as health on faith. staleAfterS is 21600: three missed ticks before the row changes colour, so a reboot or a laptop lid does not read as a dead checker.

⚠️ A JOB RECORD IS NOT A SESSION. The first dry run announced "30 sessions parked in blocked >12h holding memory" while the roster held exactly ONE live session: 181 records said state: blocked because that word is written once and never revised, so a rebooted box reads as a graveyard. The check now intersects with the live roster, an unreadable roster yields unknown rather than zero, and the regression is pinned in ~/.local/bin/tests/fleet-stability-alarms-are-not-vacuous.py — whose cases all CREATE the bad state and assert the alarm fires, because two guards written during the same investigation looked fine and were not.

Appendix

ccswitch

What it doesCredential reflexes — account pool, per-session switching that interrupts (80% on the 5h/weekly axes, 95% on Fable), wall healing, headroom-aware spawn assignment that will not send a session to a seat it cannot use, wedged-session healing, and a move that REWRITES a session's credential file in place rather than killing its pane (the DEPRECATED respawn still HOLDS while the pane has live subagents); mint is the only credential door
Kindreflex
Repohttps://github.com/PropFlow-Technologies/ccswitch
Deployed at~/.local/bin/ccswitch
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → ccswitch

Liveness signals

SignalHow it is read
pool cache freshfile-age · ~/.claude/ccswitch/cache.json · stale after 60 min
session registrations flowingfile-age · ~/.claude/ccswitch/reg · stale after 60 min
rotation telemetryfile-age · ~/.claude/ccswitch/rotation.log · stale after 2880 min
account snapshot freshfile-age · ~/.claude/ccswitch/snapshot.json · stale after 5 min
swappable sessions being bornfile-age · ~/.claude/ccswitch/session-cfg · stale after 1440 min
subagent-hold guard consultedfile-age · ~/.claude/ccswitch/subagent-holds/.consulted · stale after 2880 min

Detail document: ~/.claude/ccswitch/snapshot.json (the Systems tab opens this row into it).

The subagent-hold guard consulted signal, and why it is shaped this way (2026-09-10, ccswitch#86). Every respawn path runs tmux respawn-pane -k, and -k kills the pane's whole process group — so a switch or a wall heal takes the session's Task-tool subagents with it. Measured that day: session ab6d89b8 (tmux claude-arch) was respawned three times in under two hours and lost five Workers mid-task, two of them twice. Gera: "whenever we do CC Switch, it kills the sub-agents." A respawn whose session has live subagents is now HELD — re-decided every tick, released at zero, and given way to after an hour so a wall can still heal.

⚠️ The signal watches whether the guard is REACHED, not whether it fires. ADR-0004's inert-mechanism clause is explicit that tests prove the rule and cannot prove the rule is ever reached, and a guard that quietly stopped being consulted would look exactly like a fleet whose panes never have subagents — indistinguishable from working. So subagent_hold stamps subagent-holds/.consulted every time it reaches a VERDICT, held or allowed, and the fail-soft bailouts deliberately do not. A hold itself is an event, not a heartbeat: holds are rare and should be, so a signal keyed on one would sit red in the healthy case and train everyone to ignore it. The two-day window is the same generosity rotation telemetry gets, for the same reason — a quiet weekend fleet takes no respawn decisions and is not broken. Held sessions themselves render from the detail block, which now carries sections.subagentHolds alongside the account rows.

A lane that hit the wall stops vouching for the pane that needs rescuing (2026-09-13, ccswitch#105). live_subagents() dates a lane by its transcript's mtime, and a 429 wall leaves a fresh file on a dead lane. Measured 2026-09-13 02:58Z on the mini, session 266081f7: the hold read ten live lanes, and all ten carried the wall as their OWN last record (isApiErrorMessage: true, apiErrorStatus: 429, "You've hit your weekly limit"), aged 846–955s — comfortably inside the freshness window, and every one already dead, because a 429 terminates a lane with no retry. On that reading the respawn that session needed would have been refused: a session held out of its own rescue by ten casualties of the very wall it needed healing from. A lane whose own last word is a 429 is now retired from the list. It reuses walled_detected() rather than re-deciding what "walled" means; it is never given the session's drill id, so a drill planted for the SESSION cannot put words in a lane's mouth; and it fails soft — an unreadable lane has no opinion and keeps whatever the mtime rule said, never the other way round. Only a 429: a 500 or a 529 is retried by the harness, so such a lane may be about to speak again. This is worth the most on the respawn path, which kills every lane; a swap harms none.

⚠️ A lane has no process of its own, so process-based lane liveness cannot be built — recorded in PITFALLS.md §6 Do NOT build, so the next reader does not spend another night on it. Measured 2026-09-13: a lane's tool call is a direct child of the PARENT with nothing in between, its environment names the parent's CLAUDE_CODE_SESSION_ID and carries no agentId at all, and session 266081f7's ten concurrent lanes ran under one pid — sampled every 2s for 16s its child count was a constant 3, two MCP servers and one long gh poll loop, so the count tracks TOOL CALLS, not lanes. Neither agent-<id>.jsonl nor agent-<id>.meta.json records a pid. The coarse fallback — does this session have a non-MCP tool child — is worse than useless: the one observed was an until MERGED poll loop, which would have held its session's respawn indefinitely.

The swappable sessions being born signal. ccswitch exec gives every new session its own CLAUDE_CONFIG_DIR under ~/.claude/ccswitch/session-cfg/ with a .credentials.json in it, so that directory's mtime advances every time the new birth path is exercised — the same anti-herd shape as session registrations flowing, one level earlier. Deleting swap_sessions (below) removed the one thing that made the birth path's state legible at a glance, and a birth path that quietly stopped producing config dirs would look exactly like a fleet that spawned nothing. 24 dirs existed at 2026-09-13 03:36Z, the newest still being written.

The respawn cluster is DEPRECATED, and the gate that retires it is CHECKABLE (ccswitch#103). respawn_losses_capture, respawn_handoff_note, respawn_handoff_deliver, the restart_notice re-arm injection, move_session's respawn branch and the blanket subagent hold above are marked THE LEGACY RESPAWN PATH — DEPRECATED 2026-09-12, STILL LOAD-BEARING: a pre-cutover session is env-pinned for life, and a respawn is the only thing that can rescue it. The cluster may be deleted when

ccswitch doctor --pinned-remaining

reads pinned 0 · unclassified 0 on BOTH machines, on a normal working day with the fleet up. The counts are DERIVED per run — pinned_sessions_live() reads each live Claude Code process's OWN environment out of ps eww and applies the same three conditions swap_target() does — and unclassified counts AGAINST the sunset, because a session that cannot be proven swappable still has only a respawn. Exit 1 while pinned remain, exit 2 when the answer cannot be derived, never a quiet zero (ADR-0016's rule again).

⚠️ The gate cannot be answered on the Mac today, and the reason is the last pitfall in the section below. Measured 2026-09-13 03:36Z: the mini reads pinned 27 · unclassified 0 · swappable 7, exit 1, on artifact ccswitch-7b9d053efad4. The Mac reports ccswitch-deploy: CURRENT — against a repo checkout 6 commits behind origin/main, at #101 — and its deployed copy contains the string pinned-remaining zero times, so it ignores the flag, prints ordinary doctor output and exits 0. A both-machines gate whose unimplemented machine answers zero is worse than one that refuses: CURRENT means the copy equals THIS machine's checkout, never that the checkout equals origin/main.

⚠️ Why the gate is not a signals entry, and what would make it one. The catalog is read-only by construction: relay/src/systems/catalog.ts evaluates four signal types (file-age, log-match-age, launchd, self) and never runs a command — the same safety property that keeps the per-account probes off this page's poll interval. Nothing ccswitch writes carries these counts: snapshot.json has no pinned field, and rotation.log's lines are JSON objects while log-match-age needs a leading YYYY-MM-DD HH:MM:SS, which is why rotation telemetry is a file-age in the first place. A sections.pinned block in snapshot.json, or a stamp file on the subagent-holds/.consulted model, would make it a signal. Until one exists it is a command a human runs, like fleet-e2e.

What it is. The fleet's credential reflex. Every account is a 1-year setup token minted by ccswitch mint <email> and shared through the pool (bearer-only since 2026-09-03 — the browser-login path, its 10-minute refresher and the keychain bank were deleted, not switched off; see PITFALLS.md §8). The pool holds 8 accounts as of 2026-09-13 03:36Z; propflow.clara@gmail.com joined on 2026-09-12 and is the picker's top choice at 4% of its week — the only seat in the bank under 90% weekly at that reading. A session that carries its bearer in its ENVIRONMENT binds it at exec and is moved between accounts only by being respawned. The pin is a property of that path and not of Claude Code, which re-reads its credential FILE per request; see ADR-0024. Since ccswitch#103 that path is no longer how a session is BORN — merged and deployed 2026-09-12, ccswitch exec births a swappable session unconditionally, and there is no config key to turn it off — so the env-pinned fleet is now a shrinking population of pre-cutover sessions rather than "the fleet". This entry describes the rules that survive as of Round 12 (2026-09-07), each one bought by a measured incident.

What the two signals and the detail block read. ccswitch snapshot (launchd co.propflow.ccswitch-snapshot, ticking every 60s — a fast re-probe of the 5h axis for accounts with live sessions, and a full build every 600s) writes everything the tool knows: every account with live 5h/weekly/Fable usage, pool state, who is on it by session name, and the one remedy; every token file with its probe result; an activity feed of every decision with its numbers; sections.snapshot (fast/full timestamps, which accounts were probed) and sections.walled (sessions found at a usage wall). The Systems tab opens the row into that file. The relay only READS it: a probe is one API request per account, and a sweep hung off a page's poll is what rate-limited four accounts on 2026-08-26. Five missed ticks (300s) and the row goes Stale; the signal and the detail block name the same file and the same 300s on purpose and must move together. The session registrations flowing signal is the anti-herd canary (ADR-0004's inert-mechanism clause): every live session renews its stamp every 10 minutes via temp-file + rename, so the directory mtime advances with the flow — an hour of silence while sessions run means the writer went inert again.

How a session gets and keeps an account.

  • Bound at exec, once — because the bearer goes in the environment. ccswitch exec picks the token before the process exists (#45); rotation never reaches into a running session. Read that as a fact about the env path, NOT about the CLI: a process with no CLAUDE_CODE_OAUTH_TOKEN in its environment reads $CLAUDE_CONFIG_DIR/.credentials.json and can be moved while running — measured 2026-09-12, PID unchanged across the swap with 23 post-swap requests from a live subagent (ADR-0024). The spawn gate offers one file per ACCOUNT, ranked by 5h utilisation plus 8% per live session already on it (session_reserve), so leavers fan out instead of dogpiling the emptiest account; files the fresh snapshot saw capped sort last, unnamed copies last of all (#60, #61, #68).
  • Two kinds of session, and the kind is DERIVED not remembered. Since ccswitch#99 a session is either env-pinned — a bearer in its environment, which is every session born before the 2026-09-12 cutover — or swappable: no bearer in its environment, its own CLAUDE_CONFIG_DIR, and a .credentials.json in it. swap_target() answers the question from ps eww of the live process every time it is asked, because a stale "yes" kills a session that could have been swapped and a stale "no" is the five-Workers incident again. What the kind decides is the remedy, not the trigger: the switch fires on the same lines for both (§ the rule above), then a pinned session is respawned (the deprecated path, byte for byte, blanket subagent hold included) and a swappable one has its credential file rewritten in place — no kill, no lane loss. The swap is gated by swap_bar: it refuses a destination that preflight grades 2, and with live subagents it demands a clean verdict rather than a marginal one, because a subagent carries no retry (one died 7.7s after its credential went bad, while a clean swap carried another through 23 requests). Live, and the only way a session is born (ccswitch#103, merged and deployed 2026-09-12). swap_sessions is gone — the key, its one reader, and the docs that promised it — because a key made the fleet's shape a value on disk on one machine, and flipping it once was a fleet-wide change with a one-word commit. The only escape hatch is --no-swappable, a flag a HUMAN types for a test or an emergency, and it is loud on stderr and in rotation.log when it re-pins a session that has a credential dir. Measured on the mini 2026-09-13 03:36Z: pinned 27 · unclassified 0 · swappable 7, with 18 completed in-place swaps in rotation.log since the cutover — e.g. session 01ad649f at 2026-09-12 22:26 CDT, "swapped IN PLACE — pid 63987 keeps running, subagents keep their lanes … 4 live subagent(s) ride along, unharmed". The fleet converts by TURNOVER and by nothing else: nothing can move a running process off the env path, so the pinned count falls only as those sessions end. The sunset gate for the respawn cluster is above. ⚠️ ccswitch hold does not hold a swap. It is read at exactly one call site, inside hard_restart_now(), so it gates the RESPAWN — and a swappable session is still swapped in place under it. A fix is in flight as of 2026-09-13; until it lands, read a hold as protection against the kill, not against the credential rewrite.
  • A respawn acts on the pane that HOSTS the session, never on a stranger's (#91). 2026-09-10 20:57:47Z: session 775cc26b — an sdk-cli session born twelve seconds earlier whose first prompt was ls — crossed a switch line and was respawned, and in that same second the fleet's Operator 9e9f7a53 and all eleven of its subagent lanes wrote their last line and stopped forever. Claude Code exports TMUX_PANE into every tool subprocess and every descendant inherits it, so a session started INSIDE another session's tool call read the HOST's pane out of its own environment, and respawn-pane -k kills that pane's whole process group. The hold did not fail; it was asked about the wrong session, and subagents: 0 on that line is a true answer about the probe. The @claude_session_id stamp cannot arbitrate it either — a SessionStart hook writes it from inside the pane, so the probe re-stamps the pane with its own id on the way in, which is how the earlier claude-arch incident was misread as "the respawn resumed the wrong sid". The occupant is now read from the process table (pane_session_occupant() walks DOWN from the pane's own pid; the first claude it meets is the occupant, and a probe sits three levels below), checked at hard_restart_now and at wedge_heal, which respawns with -k without passing through it. ⚠️ Only a positive mismatch refuses — an unresolvable pane stays permissive, because refusing on unknown would strand exactly the sessions the wall healer exists to rescue (ADR-0016, one layer down: could not read the evidence and read it and said no must not share an exit path).
  • The 80% rule, and it interrupts. Every turn boundary, a session judges its OWN account — never just the active symlink's — on a snapshot at most a minute old. Any axis at its line → the session moves now, busy or not. For an env-pinned session — a pre-cutover one, counted in the bullet above — moving means being respawned through the spawn gate onto a different account with a primed "continue" (#68); for a swappable one it means its credential file is rewritten under it (same bullet). The TRIGGER is the same either way; only the remedy differs. All destinations at or past the line → hold the least-bad and say why. A restart already in flight short-circuits every other watcher before it binds or logs, so one move is one switch line (#70).
  • Fable's line is 0.95, not 0.80, and that is the opposite intent (#71). The other axes move a session BEFORE it is walled; Fable moves it as late as responsibly possible, because Fable is the best model and leaving it early is a loss, not a save. So a Fable-only move must PRESERVE: the destination has to have measured Fable room, and when nothing does the honest answer is to stay and say so — moving would trade a nearly-spent Fable window for another one. Unmeasured is not room.
  • A destination has to be able to run THIS session's model (#74). Measured 2026-09-08: a session running Fable was evacuated on 5h into an account at 101% of its Fable window — logged in its own words, "moving to gera@propflowai.co (worst axis 101%)" — and walled 80 seconds later. The seat could take a turn; it could not take that session's turn. The session's model now orders the usable seats. ⚠️ A preference, never a veto: over its line Fable means the account cannot serve the BEST model (a downgrade), while 5h/weekly mean it cannot serve ANY turn (an outage). A hard filter would pin a Fable session to an account at 99% of its 5h window whenever the bank is all-Fable-hot, trading the second for the first.
  • The restart cap is a runaway guard, not a ration (#75). It was three per day, and on 2026-09-08 that parked three live sessions on an account at ~100% Fable while test@ sat at 7% / 4% / 7% — a seat the picker was choosing correctly. The cap counted RESTARTS while its own rule claimed to count FAILURES ("a session that needs a fourth respawn has a problem respawning cannot fix"), and every restart it had counted was caused by a bug in the destination picker, not by the session. It now counts consecutive rapid restarts — an attempt older than hard_restart_loop_window_s (300s) reads as zero, because a rescue that bought that much working time demonstrably worked — with the ceiling at 12: try every account twice, then stop. Gera's rule: "the goal is to get it working ... the counter is just there so we don't hit like a thousand."
  • A drill spends its own budget (#73). switch-drill is refused when the production budget is gone — you do not rehearse on a session whose real rescues are spent — but it no longer SPENDS it. Session 70690853 escaped the Fable wall by running a drill on itself and was charged a production restart for it; two real rescues later it was stranded by the cost of its own escape.
  • The wall is a transcript event, healed like a wedge. Claude Code writes "Usage limit reached · continuing automatically at …" into the transcript the instant it happens. autoContinueAtUsageLimit: false (dotclaude, both machines) makes the turn END there instead of parking on the SAME account until reset — the native wait was the exact state that never yields the Stop a respawn needs. ccswitch auto (hooks: UserPromptSubmit, Stop, SubagentStop, StopFailure) reads its own transcript tail and respawns onto the least-bad USABLE account (anything not itself at the wall); the 60s snapshot sweep does the same for a session whose hooks never fire. Feed events: walled-heal, hard-restart (#68).
  • Identity is a fact. Every token file resolves to an email through the sha12 mint index; a file no index names is unnamed and its sessions never register (#58, #60). A pane's SESSION id comes from the claude process's CHILDREN's environment (CLAUDE_CODE_SESSION_ID on the MCP servers and tool subprocesses) — the claude process itself carries none, and argv holds an id only for resumed sessions (#69).
  • Death is a server verdict. Only a 401 retires a bearer; the sha12 tombstone under the pool's retired makes it stick (#58). The one remedy every label names is ccswitch mint <email> — never /login, which rotates the refresh token under every session on the account. ⚠️ mint itself was broken four ways until #107 (2026-09-12), and each one turns a correct paste into an unexplained failure: tty.setraw means only a CR submits, so the pasted code was never actually submitted; a failure invented a cause instead of keeping the CLI's own transcript; the authorize-URL regex swallowed the OSC-8 hyperlink's BEL; and a token could be filed with no index entry, which is the unnamed state above.

"Which account am I on?" — ccswitch whoami (2026-09-11, ccswitch#94). Reads the CLAUDE PROCESS's own bearer and names the account, in milliseconds: no probes, no AWS, so a session that suspects it is about to wall never waits on the bank. It exits 1 and says socannot determine, or not a session when there is no claude ancestor — rather than guessing; a bearer reading that is right most of the time is worse than a refusal, because it gets trusted. ⚠️ A subagent spends its PARENT's bearer, so whoami from a lane reports the parent's account, and a fan-out CONCENTRATES rate-limit risk instead of spreading it: fifteen lanes on one account wall in the same instant. ccswitch switch-me is the move procedure, and since #108 it answers the session that asked, per kindswap_target() derives which from the asking process's own environment, every time. The pinned branch keeps its wording verbatim (which wall you actually hit — a Fable wall needs /model, not an account move; what a respawn costs, namely the pane's whole process group; what to land first; and why spawning a fresh session beside the loaded one usually beats moving it). The swappable branch is a reference, not just a correction: what to run (ccswitch switch-drill --sid <me> --self); that there is no --to — no verb takes a destination for a loaded session, best/list only show the picker's view and checkout binds nothing; what will refuse you and why a refusal is the tool working; that a walled session should say so in one line rather than go quiet, because a walled session is alive and reads as dead from outside, a confusion that cost two operators about an hour on 2026-09-12; and that with no live process a swap is the wrong tool — that session needs ccswitch exec -- claude --resume <sid>, which binds a bearer before the process exists. It exists because a helper session rescuing a walled operator that night could not learn the swap route from the tool, read the source instead, and then did it right by hand with all ten lanes alive. Long form: PITFALLS.md §1 and §2.

What still lies, and how to read it (the instrument caveats that cost real hours this week):

  • Bare ccswitch and the statusline's account field show the FLEET DEFAULT, not your own account. Measured 2026-09-11: a coordinator with 15 running subagents was told it was at 98% of its session limit, read three surfaces, concluded 34% and fine, and argued the point twice. All three readings were correct and none was about the caller — bare ccswitch ranks the BANK and puts its arrow on ~/.zprofile-token's account; ccswitch leases was EMPTY, which is the normal state of a healthy fleet and reads as "nothing is wrong"; the statusline names the right account but AS A NUMERAL, decoded by running ccswitch — i.e. by landing on the fleet default. The 34% and 101% it reported were gera@propflowai.co's 5h and Fable rows, verbatim; the session was on propflow.evals@ at 8% of its week. Forty minutes later the account hit 100%: seven subagents died mid-task on 429s and the rest died with the session. ⚠️ The capability existed the whole timesession_account() resolved it from 2026-09-01 and was called at exactly one site — and no command printed it. A built capability with no surface is indistinguishable from an absent one. Fixed in #94: whoami, and list/doctor/leases/preflight relabelled so the default can no longer be read as the caller.

  • "new sessions rotate" means NEW ones. The statusline says that when the account a session is bound to has crossed a line, and it is telling the exact truth: the spawn gate will send new sessions elsewhere, while this one moves only at its next turn boundary or on the 60s sweep — §1, an env-carried process cannot change its token, so for a PRE-CUTOVER session the only move is a respawn (that limit is the env path, not the CLI — ADR-0024). Asked on 2026-09-08 why a session reading ⚡ ACCT s38% w99% — new sessions rotate had not rotated, the answer was that it had been evaluated correctly and was blocked further down (the restart cap). And a session not launched through ccswitch exec — an interactive claude on the Mac, say — never rotates at all: no fleet bearer is bound to it, so nothing in ccswitch can move it, even though it spends the same per-account quota the fleet is competing for.

  • EVERY DETECTOR HERE IS A CAUSE-DETECTOR; THERE IS NO EFFECT-DETECTOR. Each thing on this page watches for a specific known failure. Nothing watches for the outcome those failures produce — a session sitting on an over-line account while a usable seat exists. That is why 2026-09-08 unfolded as four separate discoveries in one night, each hidden by the one before it (the blind Fable axis masked the bad destinations; the bad destinations masked the counter), and why a third stuck session was found only because it moved in the same sweep as the two being investigated. One assertion over the outcome would have caught all four at once. It does not exist yet.

  • A registration is not a process. The snapshot counts sessions by their reg stamps; a walled session had rotated its registration off morpheus@ while its process never moved (2026-09-06). The process table is the truth — sessionNames say who claims an account, ps says who runs on it.

  • The meter can read over 100%. Anthropic's own 5h header reports the ratio of spend in flight, so 108% is a soft overage, not a wall we punched through. The 80% line is a trigger; with a 60s meter it moves a session ~10 points before the wall, but it is not a ceiling and nothing should claim "we cannot hit a limit" — claim "no session stays walled".

  • A fresh session with no child process yet has no readable id (no MCP servers configured, no tool call made): the drill, the sweep and the respawn cannot find it. Every mini session carries two MCP children from boot, so this is a documented blind spot, not a live one.

  • The deployed copy must equal origin/main in BOTH directions. On 2026-09-03 it was 142 lines AHEAD (a hand-deployed feature in no commit) and only diff against main said so (#64). Copying the file is the deploy; the launchd plists in local-bin carry a __HOME__ placeholder and install through the README recipe (sed "s|__HOME__|$HOME|g") — a raw cp left the 60s timer pointing at a path that does not exist (2026-09-07). ⚠️ And CURRENT is a narrower claim than it reads as — it means the copy equals THIS machine's checkout. See the sunset gate above, where the Mac was CURRENT on a checkout 6 commits behind origin/main and so could not answer the gate at all.

Proving it works — on demand, never by waiting for a cap. ccswitch switch-drill --sid <sid> / walled-drill --sid <sid> plant a synthetic reading for one session and run the REAL path (every event stamped drill: true); fleet-e2e --switch / --walled spawn a throwaway session, drill it, and pass only if it comes back on a DIFFERENT account, takes a second real turn there, re-registers, and the feed carries the drill-stamped switch (or walled-heal) → hard-restart: restarted. Four consecutive passes on 2026-09-07. ⚠️ A drill names the remedy that actually fired (#108). On a swap it used to print "the pane respawns in ~2s … watch for hard-restart in rotation.log"; measured on a real session the entry read "how": "swap", pid 99958 unchanged, and no hard-restart line was ever written — the operator was sent to watch for a signal that could not appear. The armed line now reads the remedy off the rotation entries the drill itself wrote, and names the signal that will really show up. Same fix on walled-drill. And the swap debounce stamp moved out of the check it sat inside: _switch_due stamped as it READ, before the destination probe and before the write, so being ASKED counted as a swap HAPPENING — a birth counted as a swap, and every attempt for the next 120s was refused with "swapped within the last 120s" for a swap that had never occurred. fleet-e2e alone is PITFALLS §0 for any credential change; a run costs a real session start, so never on a timer.

Knobs (ccswitch config <k> <v>; defaults in DEFAULTS): switch_at 0.80 · switch_at_axes {"fable": 0.95} · session_reserve 0.08 · hard_restart_max_per_day 12 · hard_restart_loop_window_s 300 · snapshot_full_every_s 600 · auto_walled_heal true. ⚠️ swap_sessions was here and is deleted, not defaulted false (#103); the name exists in no code and no string. Swap-in-place is the only birth path, so there is nothing to configure — see the bullet above rather than looking for the knob. ⚠️ The DEFAULTS dict is built at import, before those constants exist, so it carries literals — a drift guard in tests/wedge-heal.py asserts the pair agrees. That pair silently disagreeing is how the cap stayed at 3 while the constant read 12, during the change that raised it. ⚠️ snapshot_full_every_s and auto_walled_heal are constant fallbacks, not DEFAULTS entries — read as cfg.get(key, CONSTANT). The effective values are as listed (verified against the deployed tool, not the source), but ccswitch config will not print them until one is set, so a blank there is not the same as an absent knob.

Where the history lives. PITFALLS.md §8 in the repo is the dated incident log (one row per defect, the fix beside it); the phase tracker carries Rounds 8–12; ccswitch-never-walled is the write-up of why 108% happened with the 80% rule live and what closed it.

Appendix — everything linked from this entry

agent specialists

What it doesThe named JOBS a Driver delegates to instead of doing itself — decider, nudger, cleaner, optimizer, finisher, updater, slacker, fixer. Seven are ACTIVITIES and exactly one (the fixer) is an AGENT, settled 2026-09-12 by Astra's test (bounded contract -> activity; investigative ownership -> agent) and ADR-0025 decision 8: a determination a Driver makes repeatedly has to be an activity with a script behind it or a Temporal workflow cannot replay it, because a model asked twice may answer twice. ALL EIGHT are catalogued as their own systems (decision-ladder, nudge, cleaner, optimizer, finisher, updater, slacker, red-main) and are NOT duplicated here; `updater` and `slacker` were the last two built, both on 2026-09-12, and both have their own entries below.
Kindorchestration
Repohttps://github.com/PropFlow-Technologies/agentflow-relay
Deployed at
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → agent specialists

Liveness signals

SignalHow it is read
roster snapshot freshfile-age · ~/.claude/jobs/specialists.json · stale after 1440 min

Detail document: ~/.claude/jobs/specialists.json (the Systems tab opens this row into it).

The convention (Gera, 2026-09-12): <verb>er specialist. It names the FUNCTION rather than a personality, which is the only form you can ask a liveness question about — "is the updater alive?" has an answer, "is the audit agent alive?" does not.

Specialist What it does Housing Catalogued as
decider asks Fable and Astra INDEPENDENTLY on one snapshot (ADR-0019), returns a ruling or NEEDS HUMAN activity decision-ladder — active, has a canary. Answers 61% without a human against a ~95% target
nudger pokes an idle session; carries no claim about its state activity nudge — active
updater writes the closing prs ref onto the action item so status can derive — it RECORDS the sub-agent's determination, it does not judge full-vs-partial closure activity updater — built 2026-09-12, its own entry below. Rung 4, historically ownerless; housed as an agent until 2026-09-12, when Gera removed the judgment it was housed on
slacker posts when an action item is observably done — per CLOSED ROW, never per PR, as the Agent Smith bot activity slacker — built 2026-09-12, its own entry below. ZERO automated callers: it sweeps the published dock when run
cleaner tears down sensors, processes and worktrees a closed task left activity cleaner — built and on PATH, but ZERO automated callers: it runs only when typed. Close signals must trigger it
optimizer says which rows a Driver may work AT ONCE, only where independence is PROVABLE activity optimizer — built and on PATH, ZERO automated callers. This is the Driver's parallel-vs-serial decision (ADR-0025 §3): wire it, do not rebuild it
finisher recounts the published dock and exits 0 ONLY at 100% — the one thing allowed to say the work is done, and the one specialist that may never be switched off at setup activity finisher — active, wired to the Stop hook at drive-gate.py:726; 64 runs on 2026-09-12, every one exit 1, correctly refusing a stop at 48%
fixer takes a red main, fixes it and merges — scope is the RED, not the work that caused it agent red-main — active; it appoints the fixer rather than being one. --allow-push was already passed at the mint (redmain.ts:1363); since 2026-09-12 the merge authority is stated too

Which of them are ON, and what each one points at, is driver-setup — the SETUP table a /driver run starts through (its own entry below). All eight ON by default, opt-outs by number, finisher never off, and every parameter resolved against a real referent at setup and re-checked on use. An enabled specialist whose ref does not resolve — or that has no implementation at all — refuses the start, which is the mechanical answer to the updater failure this table records twice. It reads the box, not this file — builtness is resolved at setup time, so the roster rows above stay the record of intent and the table stays the record of fact.

One agent, seven activities (settled 2026-09-12; this line read "Two agents, four scripts" and miscounted its own table). The fixer is the only member that genuinely needs investigative judgment; the other seven are bounded contracts and are Temporal ACTIVITIES with scripts behind them. The Driver is not on this roster at all — it is the coordinator that calls it, handed a dock and assigning its action items to sub-agents. Astra's test stands unchanged — bounded contract → activity, investigative ownership → agent — and applying it to the updater after Gera removed its judgment is what moved that one across. That is the point, not an accident of scale.

⚠️ The fixer announces, and it announces by WRITING, not by messaging. ONE message when it takes a red, ONE when main is green, so no other lane tackles it — as a write to the shared surface (~/.claude/jobs/red-main/claim.json + status.json), never N messages. A fixer that messages every agent IS a caller (ADR-0003) and cannot know who "everybody" is anyway.

Why the fixer is an agent and red-main is not it. red-main holds ONE atomic claim naming the single fixing lane, so every other would-be fixer — Smith's alert lane, a second session, a person — can read who owns it and stand down. It appoints; it does not repair. The lane it names is the fixer, and diagnosing a red build is the one job here no script can do: the failure is different every time, and the first question is always whether the red is real or infra (a reclaimed runner, an OOM) — a judgement that has been got wrong in both directions on this fleet. So red-main answers who owns this, which is mechanical and correctly a reflex, and the fixer answers what is wrong, which is not.

There is deliberately no auditor. It was a workaround for the Driver grading its own homework; make the done-check observable (pr:owner/repo#N merged is an OBSERVED kind, file:/exit: are ATTESTED) and the job disappears. Its derivable half belongs in sensors, which cannot be switched off. A decider answers a question somebody put to it; an auditor asks questions nobody put, and only the first is a job you can schedule.

The routing rule this roster exists to enforce — Gera, 2026-09-12: "it needs to stop asking for decisions and use the decider specialist." Ask the decider first; raise a block only on NEEDS HUMAN, or when the 2026-09-12 escalation test says a human: is it IN THE PLAN, and is it REVERSIBLE? In-plan AND reversible — the decider rules, including deleting or adjusting data when the plan calls for it. Out of plan, OR irreversible — a human (ADR-0025 decision 7). The older category list below is superseded as an independent gate; its entries survive as INSTANCES of out-of-plan or irreversible, so an in-plan reversible production write is now the decider's. The old wording: when a human is structurally required (a production write, a customer promise, money, or a session changing its own acceptance bar). Full reasoning and the worked examples are in docs/the-specialists.md.


Appendix — everything linked from this entry

driver-setup (the table a /driver run starts through)

What it doesThe SETUP step of a `/driver` run: prints one row per specialist — `# | specialist | enabled | parameter-ref | validation` — with every specialist ON by default, opt-outs BY NUMBER, `finisher` never off, and the ONE parameter each specialist needs resolved against a REAL referent (a dock slug fetched from propflow-docs@main and parsed to rows with the finisher's own parser, a Slack channel conversations.info answers for, a repo GitHub answers for, a task dir, a readable directory, a ladder fable-decide really has a flag for). `driver-setup --start` is what "driver start" runs, and an enabled specialist that cannot work REFUSES it. Mid-run, `--check <name>` re-validates one row, raises an invalid ref with the decider and ALWAYS exits 0 — nothing in this flow waits for a human.
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/driver-setup
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → driver-setup (the table a /driver run starts through)

Liveness signals

SignalHow it is read
run recentlylog-match-age · ~/.claude/jobs/driver-setup.log · matches driver-setup · stale after 10080 min

The failure it exists to prevent, which is on this record. updater was on every mental checklist in this fleet for weeks and was never built. Nothing ever wrote a closing prs ref onto a dock and nobody noticed, because "the updater is on" was a REMEMBERED fact. Remembered facts go false silently. Every parameter in the table is therefore a REFERENCE, resolved at setup and re-checked on use, never free text.

It had nowhere to live. task.json has ten keys and none names a specialist; "specialist" appears zero times in start-operator, operator-here, canon/drive-gate.py, task-event or register-work. The record is a sidecar at ~/.claude/jobs/tasks/<slug>/driver-setup.json, beside acceptance.json and for the same reason — the Supervisor round-trips task.json through an allowlist that erases fields it does not know.

Two refusals, because they are two repairs.

exit what it means what to fix
5 an enabled specialist has an INVALID ref, or no implementation at all the ref, or opt it out by number
6 an enabled specialist's ref could NOT BE MEASURED (gh missing, Slack unreachable) the checker — nothing is known to be wrong

Collapsing 6 into 5 sends somebody to repair something that is fine, which is this catalog's most repeated failure shape.

A valid ref on an unbuilt specialist is the SAME failure as a bad ref — a live Slack channel in front of a slacker nobody wrote is still "on and silently doing nothing" — so builtness is checked as a separate fact and blocks start too. Which specialists that catches is resolved against the box at setup time, never copied from this page: a catalog row is a claim about the past and the whole point of the check is that such claims go false.

--start also writes the loop's FINISH marker from the validated finisher slug. drive-gate.py:726 reads that file to refuse a Stop while the board is short, and nothing on this machine ever wrote it — the one specialist that was wired was wired to a marker nobody produced.

finisher can never be switched off, by flag or by editing the record: a record hand-edited to enabled: false is coerced back ON at load, because the file is the easier thing to change than the flag.

Arming cannot route around it. operator-here refuses to arm (exit 8) while a task dir carries a driver-setup.json with no startedAt (gera-propflow/dotclaude#55). Opt-in by construction: the record exists only where driver-setup wrote one, so every other session is untouched.

Pinned by tests/driver-setup-an-unresolvable-ref-cannot-start.py — a real 404ing dock slug against real GitHub, in an isolated jobs root, with five named sabotages each proven to turn the suite red.


Appendix — everything linked from this entry

cleaner (retire what finished)

What it doesArchives task rows that reached a terminal state, so the things that walk the fleet stay cheap. Work that scales with item count on a fixed cadence, with nothing retiring the dead ones, is how /api/systems reached 30s and how the AppFolio flood was shaped; this is the retirement half.
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/cleaner
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → cleaner (retire what finished)

Liveness signals

SignalHow it is read
run recentlylog-match-age · ~/.claude/jobs/specialists.log · matches cleaner · stale after 1440 min

--mode has no default and that is the design. A default of dry-run is what let a previous backfill in this fleet be "run" for a year without writing anything — a default is what makes it ran and it printed a plan the same observation. Terminal states are an allowlist, never status != open: a status nobody has seen before must not be swept because it failed a negative test.

Appendix — everything linked from this entry

optimizer (provable parallelism)

What it doesReads the dock and says which rows a Driver may work AT ONCE — only where independence can be PROVEN from the rows' own declared paths. Exists so a Driver's sub-agent fan-out comes from evidence instead of from a guess about overlap; ADR-0025 decision 3 routes the Driver's parallel-vs-serial decision through this tool rather than rebuilding it. ZERO automated callers as of 2026-09-12.
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/optimizer
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → optimizer (provable parallelism)

Liveness signals

SignalHow it is read
run recentlylog-match-age · ~/.claude/jobs/specialists.log · matches optimizer · stale after 1440 min

"I could not find a conflict" is not independence. A pair is reported parallel-safe only when both rows name paths and those paths are disjoint; a row that names none is UNKNOWN, which is a third answer and not a pass. First honest run over 147 rows: 39 parallel, 27 conflicting, 81 unknown — and the 81 is the useful number, because an earlier cut reported 145 of 147 as naming no file by reading a hand-written list of fields instead of deriving them from the row.

Appendix — everything linked from this entry

finisher (the finish line)

What it doesRecounts the PUBLISHED portfolio-architecture dock over the API and exits 0 only when every row reads merged or shipped. The only thing allowed to say the board is done; everything else describes it. Ships with dock-claimed-items-merged, the per-task gate two Driver task.json files and canon/operator-SKILL.md had named for weeks while no such command existed on PATH.
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/finisher
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → finisher (the finish line)

Liveness signals

SignalHow it is read
run recentlylog-match-age · ~/.claude/jobs/specialists.log · matches finisher · stale after 1440 min

Gera, 2026-09-12, after a Driver reported its goal met while the dock read 130/285: "we only stop until we hit the completion 285 or whatever the number is... we shouldn't literally claim done until we run a script and it returns a hundred percent."

What it exists to prevent. The goal given was "the dock reads N of N done on origin/main". It was rewritten to "every closing PR named on this Driver's acceptance list is merged" — on the true observation that the original N (222) miscounted prose as rows. But the fix for a wrong count is to RECOUNT. What replaced it was a list the Driver writes for itself, which no input can make come out false, so it scored 14/14 with every PR genuinely merged while the board had not moved. finisher recounts. That is the whole job.

Why the drift was structural, not a Driver going rogue. An exit:<job> acceptance item is attested, not observed: the evaluator runs no command and looks for the check string verbatim in events.jsonl. pr:owner/repo#N merged is observed against GitHub. So the board-pinned criterion was a self-attestation about a command that did not exist, while the PR list was the only kind anyone could verify — and both live acceptance lists drifted to PR refs. The harness was paying for the wrong thing.

exit finisher dock-claimed-items-merged
0 every row merged or shipped every claimed row merged or shipped
1 the board is SHORT (how many, by group) some claimed rows are not done
3 no live claims: absent, empty, or all RELEASED
4 the dock could not be fetched, or parsed to zero rows same
5 a claimed slug is not on the dock at all (renamed)

3, 4 and 5 are deliberately neither 0 nor 1. "I could not measure" must not read as "done", and must not read as "the board is red" either — a broken gate reported as a red board sends somebody to repair rows that are fine. An empty claim list exits 3 rather than passing vacuously, which is the failure this whole roster keeps meeting: an absent input rendering as a positive verdict. Pinned by tests/finisher-absence-is-not-done.sh, six cases, nearly all negative.

It reads the published dock over the API at a ref, never the working tree. A finish line the runner can edit is not a finish line.

Appendix — everything linked from this entry

updater (the closing ref, so status can derive)

What it doesWrites a merged PR's closing `prs` ref onto the board row it closes, in the form propflow-docs/bin/refresh-tracker parses, so the row's status DERIVES. It writes the REF and never a status. Full-vs-partial is COMPUTED from the evidence kind — every check OBSERVED (pr/run/slack) and every pr: verified MERGED writes a bare string (full); any ATTESTED check (file/exit), any observed check that did not verify, or any kind outside the five writes {closes: partial, reason} and prints the `blocked raise` line. An unrecognised kind fails CLOSED. The two sets are a MIRROR of the canonical ones in agent-smith `src/agent_smith/operator_task.py:520` (read by `check_is_observable`), copied rather than imported because this runs from PATH in another repo — so that copy is the known divergence risk. The sub-agent's own `--closes partial` is never promoted by evidence: nothing-remains is a claim about the complement, which no check kind can observe, so upward movement stays `completed_by`'s job.
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/updater
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → updater (the closing ref, so status can derive)

Liveness signals

SignalHow it is read
run recentlylog-match-age · ~/.claude/jobs/specialists.log · matches updater · stale after 1440 min

What it exists to end. A row's status is derived from its own prs field, so merging moves nothing: a merged PR that no row names reads open forever. Measured 2026-09-12 — the portfolio-architecture dock held at 141/291 across fourteen consecutive refresher commits while real work merged in propflowai. The hop between "merged" and "the board knows" is one ref, and until now no specialist owned it.

Housing: ACTIVITY, and it changed hands the same day. Astra housed it as an AGENT because full-vs-partial closure was a judgment call. Gera removed the judgment — "the updater should be just focused on passing information into the docs… it should be self-determined" — so under Astra's own test (bounded contract → activity; investigative ownership → agent) it is an activity. ADR-0025 decision 8: the test survived, the answer changed.

⚠️ THE WRITE PATH HAS A SECOND WRITER AND THIS ONE IS BUILT FOR IT. propflow-docs/bin/refresh-trackers rewrites the same artifacts from a launchd tick every 300s in a checkout every session shares, and on 2026-09-12 that race destroyed the content of a merged PR — ten rows, a clean fast-forward, every channel reporting success. So updater never touches a working tree: it reads the artifact through the GitHub contents API, keeps the blob sha, and PUTs pinned to it, so GitHub itself answers 409 if anything landed in between. It re-reads and re-applies rather than forcing, and then verifies by reading origin/main again — a write that reported success is not evidence it survived. Pinned by tests/updater-a-moved-base-never-loses-a-row.py, ten controls; the sharpest sabotage pins the PUT to a freshly-read sha instead of the derived-from blob and reproduces the incident exactly (exit 0, no conflict, the co-tenant's row gone).

Appendix — everything linked from this entry

slacker (announce a closed row, once)

What it doesPosts to Slack when an action item is observably DONE — per CLOSED ROW, never per PR ('per PR is noise; per closed row is a thing worth reading'). It sweeps the PUBLISHED dock over the API and announces a row that MOVED into merged/shipped since the last observation, carrying the row's own title, its own prose and its closing PR links — never a paraphrase. It posts as the AGENT SMITH BOT, the automated-surface path in post-updates/SKILL.md, and never as Gera: that skill's MCP identity covers its own broadcasts and nothing else. Idempotency is a receipt CLAIMED with O_CREAT|O_EXCL BEFORE the post, keyed on the ROW and the STATE IT MOVED TO, so a retried sweep, a second copy on the box, or a run killed between the post and the state write all find the claim and post nothing; an unsettled claim is never re-taken automatically (exit 6, --reclaim). It never posts on a guess: a transition is required, the row must carry an OBSERVED closing ref (a bare string or closes:full — partial-only, which is what updater writes for attested file:/exit: evidence, is skipped), an absent prior observation is a baseline rather than a closure, and an unreadable dock or a zero-row parse is exit 4 with the state untouched.
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/slacker
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → slacker (announce a closed row, once)

Liveness signals

SignalHow it is read
run recentlylog-match-age · ~/.claude/jobs/specialists.log · matches slacker · stale after 1440 min

Which poster, and the line that decides it. post-updates/SKILL.md splits Slack by SURFACE: its own broadcasts go out as Gera through the MCP, with "no bot-token fallback for update posts. Ever."; the automated surfaces — "heartbeats, escalations, task-event mirrors, #agent-smith alerts/ops, digests" — go through the Agent Smith bot. A specialist reporting machine work is the second kind, because "a machine reporting machine work does not wear a person's name". So slacker shells out to the one bot poster binary, through the same SMITH_SLACK_POST env seam task-event:93 uses so a test can never post for real, and contains no MCP call, no bot token and no third poster.

⚠️ IT IS TRIGGERED BY THE UPDATER'S SIGNAL AND READS THE BOARD ANYWAY. the-specialists.md says to trigger it off the updater, "because the updater holds the only reliable signal: a row flipping to observably-done". That is true about the SIGNAL and not about the CLOCK. updater writes a prs ref; the row's status DERIVES from that ref when propflow-docs/bin/refresh-tracker runs from launchd, up to 300s later. So at the instant updater exits 0 the row still reads open, and a slacker wired to that exit would announce nothing, ever. It sweeps the published dock instead and compares it to what it last saw — no hook in updater was needed and none was added, and it does not re-derive "is this really done", which is where the roster says things drift.

⚠️ ZERO AUTOMATED CALLERS as of 2026-09-12 — the same state as cleaner and optimizer, and stated rather than implied. It runs when somebody runs it. The natural caller is a refresher tick, not updater's exit, for the reason above.

What the receipt is for, precisely. Re-running the same sweep is already a no-op without it, because the state file has advanced — so the receipt is the guard for the cases where the state has NOT: a run killed between the post and the end-of-run state write, two copies started a second apart, a state file restored from a backup. tests/slacker-the-same-closure-is-announced-once.py makes that the sharpest case by rolling the state file back after a successful post and asserting the second run is stopped by the RECEIPT. Six sabotage controls, each run and each proven to redden exactly the case it names; a seventh is recorded as reddening NOTHING, with the reason — the baseline early-return and the was is None guard hold the same property, and a reader who tries it deserves to know which one does the work.

Appendix — everything linked from this entry

red-main

What it doesWhen propflowai's push:main CI goes red, holds ONE atomic claim naming the single fixing lane — minted by the supervisor or adopted from a human's /driver lane (the door formerly typed /operator) — so every other fixer (Smith's alert lane, a second session, a person) can read who owns it and stand down
Kindreflex
Repohttps://github.com/PropFlow-Technologies/agentflow
Deployed atsupervisor/src/redmain.ts (inside com.propflow.agentflow-supervisor)
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → red-main

Liveness signals

SignalHow it is read
sweep tickingfile-age · ~/.claude/jobs/red-main/status.json · stale after 5 min
supervisor loadedlaunchd · com.propflow.agentflow-supervisor

Detail document: ~/.claude/jobs/red-main/status.json (the Systems tab opens this row into it).

What it is. One phase of the supervisor sweep (superviseRedMain, every 60s). It reads the Alert on red main job of the latest push:main run; on red it opens an incident (~/.claude/jobs/red-main/incident.json, one per stretch of main being broken, closed only by main going green) and takes the claim (claim.json, a flock-guarded compare-and-swap in bin/redmain-claim.py) naming the single owning task, then mints that task via start-operator — or, since 2026-09-10, adopts a live red-main-* task a human minted via /driver (typed /operator before the rename) after the incident opened. Every tick the claim is held it logs red-main owned by <task> since <ISO> to that task's supervisor.log and to the daemon log. Released when main is green or the owning task is done/abandoned; orphaned claims are reaped by TTL.

parked does NOT release the claim (CLAIM_RELEASING_TASK_STATES, since 2026-09-12). A fixer that raises a block and parks is a fixer that is still mid-fix — with a branch, and very possibly an open PR — so taking its red away would mint a rival beside a fix in flight, the exact ADR-0015 race. The claim is HELD and heartbeaten, a red-main-owner-parked alarm goes to a person, and a parked red-main-* lane is still ADOPTABLE for the same reason — unless it carries no operatorSid, which is how intake.ts marks a task whose session was torn down: adopting that corpse would hold the claim forever behind nobody. ⚠️ Anything still saying otherwise is stale, including ~/.local/bin/canon/fixer-SKILL.md §4.4, which tells every appointed fixer never to park because parking "hands your red to a rival mint".

The fixer MERGES its own fix (since 2026-09-12; Gera, "it could merge a PR and everything… just to make sure that everything is clean"). The goal file used to say "Open a PR. Never merge — that is a human decision" while the criterion made the fixer the owner until push:main was green — a job plus a prohibition on the only step that reaches it — and --allow-push had been passed at the mint the whole time, so only the sentence was in the way. Both strings now grant the merge and scope it: this red's PR, not the feature work that caused the red, and no refactors on the way past. A hold-for-review label is still a stop sign.

A door refusal is not a consumed owner. start-operator exit 75 is session-cap refusing the start because the box is at its ceiling — nothing was launched. It used to end the claim as released, which the incident's MAX_MINT_ATTEMPTS counter counted, so two refusals ninety seconds apart spent the whole budget and a human was told two owners had failed at a red nobody had been assigned to. Refusals now end the claim as refused, are excluded from the cap, back off (MINT_REFUSAL_BACKOFF_MS, 5m) instead of knocking every sweep, and raise their own alarm — red-main-mint-refused, the machine is full, free a slot — while the phase keeps trying for as long as main is red.

It announces, as a SIGNAL. One write to one shared surface when a fixer is confirmed to own the red (announced on the operator BIND, never on the bare claim — ~30% of claims never produced a session) and one when main goes green. Rendered from claim.json + incident.json, which are already the record; it adds no new source of truth and messages nobody (ADR-0003: signals, never calls). The surface is #agent-smith via agent_smith.slack_post on stdin — Smith's existing poster, shelled out to like start-operator, no Slack code in this repo. Off if that interpreter is absent; the claim is still the record.

What the signals read, and why not claim.json. status.json is rewritten after every sweep, red or green, claimed or not — {at, red, sha, incident, ownedBy, ownedSince, operatorSid, heartbeatAt} — so its age proves the phase ran and its body says who owns the current red. claim.json cannot be the liveness signal: it is absent for the whole of a green day, and absent must never read as dead. Five missed sweeps (300s) and the row goes Stale; the signal and the detail block name the same file and the same 300s on purpose and must move together (the ccswitch rule). The launchd signal is the daemon itself: the phase cannot tick if its host is not loaded. The daemon log is ISO-stamped, which the log-match-age parser does not read — that is why this is a file, not a log match.

Who reads the claim. Agent Smith's channel-alert lane reads claim.json

  • incident.json before it opens a smith-fix/*-push-main-red-* branch and stands down in the alert thread when a live owner exists (agent-smith red_main_owner.py). supervise --red-main prints statusLine(). The 2026-09-10 incident this exists for: both supervisor mints were refused at the door (session cap), the refusals spent the mint budget, Gera minted the fixing lane himself, and — with no claim on disk — Smith and a human each opened a rival PR within two minutes of the owner's.

Appendix

nudge (idle watchers)

What it doesReflex — wakes a parked session after N seconds of stillness while it has work or an open block; self-compacts at 80% with a derived keep-list; 24h TTL with a say-something-new gate
Kindreflex
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/nudge
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → nudge (idle watchers)

Liveness signals

SignalHow it is read
watchers tickingfile-age · ~/.claude/jobs/nudge/nudge.log · stale after 30 min
a wake delivered recentlylog-match-age · ~/.claude/jobs/nudge/nudge.log · matches NUDGED · stale after 360 min

Rung 2 of the ladder. One --_watch process per armed session, registered by pid file under ~/.claude/jobs/nudge/. It is the only thing that can resume a stopped session (a Stop hook cannot), so its death is silent by construction — which is why watcher-doctor below exists. Since local-bin#37 a watcher whose block state has not moved speaks once, escalates at 1h and 6h with different text, and retires at 24h; a decision link rides every blocked wake (#39). The NUDGED signal is deliberately long (6h): a quiet fleet is not a dead one, and the file-age signal catches a dead log first.

Appendix — everything linked from this entry

watcher-doctor

What it doesReflex — every 180s, finds parked Drivers whose nudge watcher died and re-arms them from the task contract; the outside eye a watcher cannot be for itself
Kindreflex
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/watcher-doctor
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → watcher-doctor

Liveness signals

SignalHow it is read
job loadedlaunchd · co.propflow.watcher-doctor
sweep completedlog-match-age · ~/.claude/logs/watcher-doctor.log · matches expected lane(s) · stale after 15 min

Asks the PROCESS TABLE, never the corpse: a watcher only ever records the death it chose. Since local-bin#38 a dark lane with no stored arm record is re-armed from start-operator's own wake text derived from task.json — never invented — and the fleet-wide killswitch (~/.claude/nudge-rearm.OFF) prints its age and contents in every refusal, because an empty marker held 27 lanes dark for eleven days with nobody able to tell whether it was still deliberate.

Appendix — everything linked from this entry

fleet-bake (week-long bake)

What it doesPipeline — every hour, measures the fleet's credential + wake machinery (panes, lanes, every catalog entry's state, ccswitch accounts, wakes, rotations, the last e2e PASS, the last controls run) and renders the week to a bot-owned Slack canvas bookmarked in #alerts
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/fleet-bake
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → fleet-bake (week-long bake)

Liveness signals

SignalHow it is read
job loadedlaunchd · co.propflow.fleet-bake
tick appendedfile-age · ~/.claude/logs/fleet-bake.jsonl · stale after 120 min

The instrument that watches the week AFTER a fix is declared. Every prior "fixed" on the credential machinery was declared from a moment; Gera asked on 2026-08-31 for something behind the scenes that, a week later, says what worked and what regressed. fleet-bake tick (launchd co.propflow.fleet-bake, StartInterval 3600) collects one record — all derived, nothing remembered — appends it as one line to ~/.claude/logs/fleet-bake.jsonl, and re-renders the canvas from the whole log. Read-only against the fleet: every unreachable source prints as a regression, never as a blank that reads green.

The tick appended signal is 7200s — two ticks — so one slow hour on a busy box is not a red row, and a job that has stopped appending is visible within the hour after that. The systems section of every tick is this catalog's own /api/systems answer, so the bake and the Systems tab can never disagree about a row without one of them being provably wrong.

Appendix — everything linked from this entry

fleet-e2e (PITFALLS §0 as a command)

What it doesPipeline — the acceptance test for any credential change: spawns a FRESH session through `ccswitch exec`, requires a real turn (assistant message + tool call, nonce round-tripped) AND a session registration, then tears it down; writes the PASS marker the bake reads
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/fleet-e2e
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → fleet-e2e (PITFALLS §0 as a command)

Liveness signals

SignalHow it is read
PASS within 7 daysfile-age · ~/.claude/logs/fleet-e2e.last · stale after 10080 min

PITFALLS §0: "a cap fix counts as fixed only when a previously-stranded live session is observed completing a real turn on the healthy account. Not when the symlink moved. Not when ccswitch list looked clean." Every one of the ~30 "fixed" declarations before 2026-08-31 would have failed that test, and none of them ran it, because it was a paragraph and not a command. A turn is a new assistant message PLUS a tool call — a 0s completion is a 429 rejection, a resume banner is not a turn, a live pid is not a turn — so the command asks the session to echo a nonce through a shell tool and waits for the nonce to come back through the tool result, the one shape a rejected turn cannot fake. On PASS it writes ~/.claude/logs/fleet-e2e.last, a JSON {at, email, cwd}: when, which account the gate bound, and where.

Run by hand, never on a timer, and the signal is deliberately long. A run costs a real session start (~130K tokens of context on whichever account the spawn gate binds), so it runs after a credential change — a rotation, a pool edit, a ccswitch deploy — and not on a schedule. staleAfterS is therefore 604800 (7 days): the same rule the bake applies ("no fleet-e2e PASS in 7 days" is one of its regression rules), and a week without a PASS means a week in which no credential change was accepted the way §0 demands, which is worth a stale row. A shorter window would make the catalog nag for a session start nobody needs. pipeline is the nearest kind the vocabulary has for a check that is run, not a thing that runs — it is not a reflex (nothing triggers it), not orchestration, not an actor.

Appendix — everything linked from this entry

fleet-controls (weekly control run)

What it doesPipeline — every Sunday 04:00, runs every local-bin control script and every ccswitch suite under a timeout and appends one record per run — passed / failed / could-not-grade / not-run / flaky-passed, with the failing names — for the bake to read
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/fleet-controls
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → fleet-controls (weekly control run)

Liveness signals

SignalHow it is read
job loadedlaunchd · co.propflow.fleet-controls
a run recorded within 8 daysfile-age · ~/.claude/logs/fleet-controls.jsonl · stale after 11520 min

Until 2026-09-01 the controls (~/.local/bin/tests/*.sh, 56 scripts, and ~/code/ccswitch/tests/*.py, 36 suites) ran only when a person ran them from memory: no CI, no cron, no hook. A control that runs only when someone remembers stops running the week after it was written, and a spec nobody runs is a document. launchd co.propflow.fleet-controls runs the whole set weekly (StartCalendarInterval Sunday 04:00, when the fleet is quietest — control-35 arms real watchers and the set takes 10–15 minutes; RunAtLoad false so a bootstrap never starts a 15-minute run on a busy box) and appends one line to ~/.claude/logs/fleet-controls.jsonl. The exit code reports the instrument, not the finding — 0 means the run completed and was recorded — so launchd's LastExitStatus says whether the JOB ran and the verdict lives in the jsonl, which is why the launchd signal here cannot turn a failed suite into a red job, on purpose. The bake reads the last record into its "Right now" table and its two regression rules, "controls failed" and "no controls run in 8 days"; staleAfterS 691200 (8 days) is that same rule — one weekly slot plus a day of slack for a run that starts late.

First run, 2026-09-01: 73 passed / 21 failed / 6 not run across 100 suites (763s). The 21 are pre-existing reds on the deployed tools — they were red the day before, and the day before that, invisibly — now visible weekly instead of never. Their names are in the record; fixing them is local-bin work, and this row does not go green by hiding them. Two ccswitch suites (keychain-and-panes, wedge-heal) fail in a batch and pass alone; a failure there is rerun once, alone, and counted as flaky-passed if green — never as a silent pass and never as a fail that was really the batch.

Appendix — everything linked from this entry

relay-deploy (the live branch, advanced safely)

What it doesPipeline — every 15 minutes, fast-forwards the relay's `live` branch to origin/main when every incoming file is one the running process reads at request time, and HOLDS the moment main carries code that would need a build
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/relay-deploy
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → relay-deploy (the live branch, advanced safely)

Liveness signals

SignalHow it is read
sweeper scheduledlaunchd · co.propflow.relay-deploy
sweep succeedinglog-match-age · ~/.claude/jobs/relay-deploy/relay-deploy.log · matches SWEEP OK · stale after 45 min
reading freshfile-age · ~/.claude/jobs/relay-deploy/snapshot.json · stale after 45 min

Detail document: ~/.claude/jobs/relay-deploy/snapshot.json (the Systems tab opens this row into it).

Merging a PR to main does not deploy this relay. It runs from the live branch of ~/code/PropFlow/agentflow, and until 2026-09-02 nothing advanced it — the Systems tab was found serving a catalog two commits behind, with #96 merged and simply never live. Nobody knew, because a stale catalog looks exactly like a current one.

⚠️ This is deliberately not git merge --ff-only on a timer. relay/dist and supervisor/dist are gitignored, and launchd execs the absolute dist path, so the two halves of this repo deploy on completely different rules:

how it reaches the running process what a fast-forward does
SYSTEMS.md, docs/ parsed at request time deploys it instantly
.ts source compiled into gitignored dist/ nothing — inert until a rebuild

A blind timer therefore does something worse than nothing: it walks the source ahead of the running binary, so the next person to run npm run build in that checkout ships every accumulated commit at a moment they did not choose and never reviewed. That is the inverted hazard — git operations are inert, BUILD operations are destructive — and putting it on a schedule loads the gun automatically.

So the job ships only what the running process reads at request time (default-deny allowlist: SYSTEMS.md, README.md, docs/) and holds the moment main carries anything else, naming what it held in the snapshot. One code file holds the whole batch, deliberately — a catalog edit riding behind a source change is not a safe deploy just because the catalog half is. A human sequences those, because they need a build and a restart.

Fail-safe by construction: it refuses a dirty tree, refuses a checkout that is not on live (that is somebody's in-flight work, not an invitation), refuses a diverged branch, and only ever runs git merge --ff-only. It never builds, never restarts, never force-anythings.

A held deploy is information, not a fault, so it does not colour the row red — the log line opens SWEEP OK whenever the check itself ran, SWEEP FAIL only when it could not run at all, and what is waiting on a human shows in the held block of the detail panel.

Appendix

fleet-deploy (seat versions, and the seats nobody can read)

What it doesReads the boot record every agent SEAT emits at worker start, compares each one to main, and reports which seat is running which commit — naming any seat this host cannot read as UNKNOWN rather than letting its absence read as up to date
Kindpipeline
Repohttps://github.com/PropFlow-Technologies/agent-smith
Deployed atsmith-fleet-deploy (agent_smith/fleet_deploy.py)
Statusproposed
Live on the Systems tabpropflowai.co/agents → Systems → fleet-deploy (seat versions, and the seats nobody can read)

Liveness signals

SignalHow it is read
reader completing a passlog-match-age · ~/.claude/jobs/fleet-deploy/fleet-deploy.log · matches FLEET-DEPLOY · stale after 65 min
roll-up freshfile-age · ~/.claude/jobs/fleet-deploy/snapshot.json · stale after 65 min

Detail document: ~/.claude/jobs/fleet-deploy/snapshot.json (the Systems tab opens this row into it).

relay-deploy above says a merge to main does not deploy the relay. This row is the same sentence about the AGENT SEATS, and it cost more to learn. agent-smith PR #376 fixed the false-amnesia banner ("I don't have this thread's earlier turns for this reply"), verified at zero occurrences from Smith's own checkout. On 2026-09-07 between 02:39 and 02:44Z the clara seat — presenting as Trinity — emitted that same banner three times: same codebase, different AGENT_SLUG (ADR-0001), another machine, another checkout, and the fix had never reached it.

Nothing on any surface could say so, and neither instrument was broken. Smith's outbound ledger read 0 because it records posts made by the agent_smith package on this box (the 63% blind spot agent-smith#399 documented) — a seat on another host is not under-counted there, it is absent, and the absence rendered as a zero. And the per-seat fact existed but had no cross-seat reader: deploy_state.record_boot has every worker declare its commit at boot into its own state dir, while the only consumer asked this_boot() — this process, this host.

The rule this row enforces, and the one thing it will never say. A seat whose boot record this host cannot read is UNKNOWN. Never live, and the roll-up has no path to aligned while one exists — the coverage clause rides the clean line as loudly as the loud one. Run on the mini, that reads:

FLEET-DEPLOY BEHIND main=d01d8d87 smith=7a52b553(-1) · clara=unknown
  — seats read: smith of 2; NOT covered: clara

  smith (Agent Smith): BEHIND — 7a52b5538 (1 commit behind main), booted 2m ago
  clara (Trinity): UNKNOWN — no boot record at ~/.claude/clara-state/worker-boot.json

⚠️ Trinity will keep reading UNKNOWN from this host, and that is the honest answer rather than a defect. Its record is written on its own machine and no transport carries it here. The reader deliberately does not reach out to ask — ADR-0003, rungs integrate by signals never by calls — so the supported closes are to run smith-fleet-deploy on that host, or to point CLARA_SMITH_STATE_DIR at a synced copy of its worker-boot.json. Updating a seat on another machine is a human action; nothing in this system deploys anything. ADR-0011 records why a general cross-host transport is a separate decision.

Why the signals prove the READER ran and not that the fleet is aligned. A fleet being briefly behind is the normal state after every merge — seats pick up new code when their workers restart, not when a PR lands. Keying this row on ALIGNED would paint it red for every ordinary merge and train everyone to ignore it, which is ADR-0009's "green means the thermometer works" failing in the other direction. So FLEET-DEPLOY opens every completed pass, aligned and behind and unknown alike, and the verdict lives in the detail panel — the same split relay-deploy makes for a held deploy. A pattern that only appeared on the unhappy path would make this row go stale exactly when everything was fine.

⚠️ The pattern is the literal FLEET-DEPLOY because the evaluator matches SUBSTRINGS, not regexes (the HOST (OK|PRESSURE) lesson two rows up), and the log stamp is YYYY-MM-DD HH:MM:SS in LOCAL time because evalLogMatchAge matches ^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}). An ISO stamp renders this row unknown: matched line has no timestamp while every test of the writer still passes, so agent-smith's test_log_line_stamp_is_what_the_relay_can_parse asserts against the evaluator's own regex rather than the writer's format string.

proposed, not active, and the signals above are already the real ones. agent-smith#415 is open and nothing schedules the reader yet, so both signals render cannot read …unknown, which is the truthful answer to "is this alive": it is not. That is the session-pulse pattern below, deliberately — the same declaration becomes the live canary with no edit, so there is no second step anyone can forget and no window in which this entry claims liveness the catalog cannot see. It flips to active when the PR lands and an hourly run exists; staleAfterS: 3900 is the promise that cadence owes.

Appendix — everything linked from this entry

agentflow (Driver / Supervisor / relay)

What it doesOrchestration — takes the DOCK it is assigned at setup, assigns its action items to sub-agents (parallel where the optimizer proves independence, serial where it does not), judges progress, and surfaces only the ~5% of forks a human must settle
Kindorchestration
Repohttps://github.com/PropFlow-Technologies/agentflow
Deployed at
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → agentflow (Driver / Supervisor / relay)

Liveness signals

SignalHow it is read
relay servingself
supervisor loadedlaunchd · com.propflow.agentflow-supervisor

Rung 3 of the ladder: respawns sessions against a criterion, escalates to the human (rung 4) via decision pages. Opt-in per task — a session with no stated goal is deliberately outside its reach, which is exactly why the reflex layer below it must not depend on it. Integration between rungs is signals (files), never calls.

Appendix

Session pulse (state classifier)

What it doesOne answer to "what is this session doing" — six states or null, with the wake path that decides between waiting and needs-kickoff
Kindorchestration
Repohttps://github.com/PropFlow-Technologies/agentflow
Deployed atrelay/src/pulse/
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → Session pulse (state classifier)

Liveness signals

SignalHow it is read
pulse snapshot freshfile-age · ~/.claude/jobs/pulse/snapshot.json · stale after 15 min

active since the collector shipped — and the signal above is byte-for-byte the one the entry carried while it was proposed. That was the whole design. It sat as a file-age on a path nothing wrote, rendering unknown (cannot read …), which was the truthful answer to "is this alive": it was not. relay/src/pulse/collector.ts now writes that exact path every 120s, so the same declaration became the real freshness canary with no edit — there was no second step anyone could forget, and there is no window in which the entry claimed liveness the catalog could not see.

⚠️ THE 120s CADENCE IS A PROMISE TO THE staleAfterS: 900 ABOVE, AND IT IS TESTED, NOT ASSERTED. relay/tests/pulseCollector.test.ts parses this file, finds this entry, and checks the collector's interval leaves a wide margin inside the declared bound (seven consecutive missed cycles before the signal goes stale — an outage, not a slow sweep). Its first assertion is that it FOUND the entry, because a parser that matches nothing agrees with every number. Change either side and that test is where you will hear about it.

⚠️ A self signal was the obvious alternative and is wrong here. self evaluates to ok unconditionally (relay/src/systems/catalog.ts) — it is the relay reporting that the relay is serving. On the relay's own entry that is exactly right; on a module that merely SHIPS inside the relay it is an unconditional green over code that may never be called, which is the inert-mechanism failure ADR-0004's liveness clause exists to prevent. It would also have gone green on the day this system was dead code, which is precisely the day it needed to report otherwise.

What the collector does, in one paragraph. Every 120s it takes the roster the relay already polls, captures each session's VISIBLE pane region (no -S — the region discipline is the defence, not an optimisation), reads the DONE markers, the block ledger, the task ledger and the phase arbiter ONCE for the whole fleet, runs classify() per session, and stage-and-renames one document to the path above. It writes that one file and nothing else; the only tmux command on its path is capture-pane. A failed cycle deliberately writes NOTHING, so the file ages and this signal says so — a degraded write would refresh the mtime and report health that was not measured.

Rung 3 of the ladder, beside the Supervisor: it decides, it never acts. Classification and actuation are separate layers on purpose — every "nudged a session that shouldn't have been nudged" incident this replaces came from one elif chain that did both. See ADR-0006 for the six states, the wake-path rule, and the inventory of the five tools it absorbs.

Appendix — everything linked from this entry

Reviewer token pipeline

What it doesKeeps CI code-review authenticated across all repos — probes the bank, ranks accounts by worst-axis headroom, writes GitHub secrets every 2h
Kindpipeline
Repohttps://github.com/gera-propflow/dotclaude
Deployed at~/.claude/scripts/rotate-reviewer-tokens.sh
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → Reviewer token pipeline

Liveness signals

SignalHow it is read
cycle completed recentlylog-match-age · ~/.claude/logs/reviewer-token-rotate.log · matches cycle done · stale after 180 min
launchd job loadedlaunchd · com.propflow.reviewer-token-rotate

The cycle done signal is the freshness canary this pipeline earned on 2026-08-29: the job died silently every 2 hours for ~13 hours (launchd ran it under macOS /bin/bash 3.2, which lacks declare -A) and its only voice was itself — dead things don't speak. This signal is the external witness.

Appendix

cerebrus (Clara's quality gate)

What it doesPipeline — replays a recorded agent turn (or whole thread) through a deterministic three-layer checker and returns pass | repair(span) | hold | block; blocks a propflowai PR when a fixture's verdict regresses against the merge-base
Kindpipeline
Repohttps://github.com/PropFlow-Technologies/cerebrus
Deployed atvendored into propflowai/quality-gate-poc/, pinned by CEREBRUS_VERSION
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → cerebrus (Clara's quality gate)

Liveness signals

SignalHow it is read
checker scheduledlaunchd · co.propflow.cerebrus-gate
gate greenlog-match-age · ~/.claude/jobs/cerebrus/cerebrus-gate.log · matches GATE OK · stale after 120 min
reading freshfile-age · ~/.claude/jobs/cerebrus/snapshot.json · stale after 120 min

Detail document: ~/.claude/jobs/cerebrus/snapshot.json (the Systems tab opens this row into it).

Three layers, run in order, higher severity from an earlier layer winning and never downgrading: L1 diffs the turn against a serialized ground-truth ledger, L2 checks the tool loop actually backs what was claimed, L3 checks the outbound draft. The harness makes zero LLM callsllmCallsUsed is hardcoded 0, and the prototype's "LLM last-resort vote" was deliberately stripped on port. The one judge lane (judge/invented-policy-lane.ts, model pinned) is advisory over 5 cases and can never move a verdict.

It never imports application code, and it is vendored into propflowai rather than depended on — the pin carries a tag, a commit sha, and a vendored-tree checksum, with check-vendor-sync.sh refusing drift.

The blocking gate is the paired base-vs-head verdict diff, not the corpus match. The full-corpus run is log-only on purpose: case-11/12/13 and d8-0e01f3be are disclosed intentional misses, and a gate that failed on them would be measuring the answer key rather than a regression. The diff fails on three classes only — a verdict flipping correct→incorrect against expected.json, a crash that was clean at base, and a case that disappeared.

Because the gate runs entirely on GitHub runners, nothing it does touches this machinecerebrus/ledger/ holds a .gitkeep and nothing else, since the run ledger is written on the runner and dies with it. So liveness here is supplied by cerebrus-gate (launchd co.propflow.cerebrus-gate, hourly), which asks GitHub what the gate actually did and writes the answer where the relay can stat it. GATE OK requires the consumer gate — propflowai's quality-gate-poc.yml, the one that blocks PRs — to have concluded success within 14 days, with the engine's own CI not failing. GATE FAIL, GATE STALE and GATE UNKNOWN each name their reason, and an unreachable GitHub is UNKNOWN rather than a silent pass.

The staleness window is deliberately loose. The gate is PR-triggered, not scheduled, so a quiet week is normal and must not colour the row; two weeks with no run at all means the workflow was removed, disabled or renamed, which is the silent death worth catching.

⚠️ The signal that looks right and is a trap: CEREBRUS_VERSION's file age. It is the obvious local artifact and it is a lie — that file's mtime is when the repo was cloned, not when the gate last ran, so a fresh checkout would render green on a gate that had been dead for a month. Rendering missing evidence as health is the exact disease this catalog exists to treat.

Note for searchers: the docs page is titled "Cerberus Burn-In" (with the r), while the repo, the package and this id are cerebrus. The id here is the machine key; the docs title is prose.

Appendix — everything linked from this entry

merge-evidence gate

What it doesReflex — refuses to arm GitHub auto-merge on a propflowai PR unless every required context has concluded AND produced evidence about the SHA being armed, so a green that measured nothing cannot merge unattended
Kindreflex
Repohttps://github.com/PropFlow-Technologies/propflowai
Deployed at.github/workflows/auto-merge-all.yml rule 6 :: scripts/ci/decide-merge-evidence.sh
Statusproposed
Live on the Systems tabpropflowai.co/agents → Systems → merge-evidence gate

Liveness signals

SignalHow it is read
gate decidinglog-match-age · ~/.claude/jobs/merge-evidence/merge-evidence-gate.log · matches EVIDENCE-GATE OK · stale after 20160 min

Detail document: ~/.claude/jobs/merge-evidence/snapshot.json (the Systems tab opens this row into it).

The hazard it closes. Auto-merge merges on green, and several of propflowai's greens are statements about a SELECTION or a PATH FILTER rather than about anything that ran: Unit Tests = success with the affected selection skipped for exceeding AFFECTED_CAP; ci-docs.yml's unconditional stub — it forges FOUR required names in one loop (Type Check, Unit Tests, Build, Lint), titled "docs-only change — skipped" — landing on a diff that is not docs-only; a required context that only ever skipped (protection passes those); and a PR into a feature-branch base, where "all checks passed" is true over an EMPTY required set because auto-merge-all.yml has no base filter and ci.yml only runs for branches: [main].

It is a gate, not a brake, and the distinction is the design. The rejected version wrote a hold-for-review label from a periodic observer that notices a vacuous green — a label applied by a poll can land AFTER the merge has already happened. Eligibility is instead evaluated at ARMING time against the SHA being armed, and arm() hands that SHA to --match-head-commit, which GitHub validates at merge time.

⚠️ THE STUB AND THE REAL LANE SHARE A REQUIRED NAME, AND NOTHING AT READ TIME TELLS THEM APART. 2026-09-11, dated and costed: a reviewer read Lint: success titled "docs-only change — skipped" on a mixed docs+code PR, concluded the rename ratchet had never run, and held a merge that was unblocking the whole repository. The real Lint HAD run, for 552s; the 17-second stub posts under the same name and arrives first. An hour lost — and note the direction: that was a FALSE HOLD by a human whose only available discriminator was DURATION. The gate answers it by asking WHICH PRODUCER posted, which is the same question in every direction: stub+real → eligible, stub alone on a code diff → held, stub alone on a docs-only diff → eligible.

⚠️ AND BRANCH PROTECTION REPORTS THE STUB AS THE ANSWER. Observed twice on this gate's own pull request, on consecutive commits: all six required contexts reading pass while ci.yml's real lane had not reported — the second time over a lane that was actually FAILING. Any instrument that asks gh pr checks --required inherits this; ask the producing lane (external_id == ci-yml-unit-tests) instead.

⚠️ IT NEVER READS A DURATION, and the catalog should not let anyone "fix" that. Zero duration alone is not proof of invalid testing; MISSING REQUIRED EVIDENCE is the reason to hold. propflowai's own data is anti-correlated: on #7583 the vacuous Unit Tests green ran 3m45s while a genuine review context ran 0s (GitHub rounds to whole seconds). A duration gate blocks honest work, gets routed around, and leaves the repo worse than before. Tests scan the shipped source and fail if any timestamp field is ever read.

⚠️ THE SIGNAL THAT LOOKS RIGHT AND IS A TRAP: "the workflow exists", or "the auto-merge workflow ran." Both stay true of a workflow whose evidence step has been deleted, and of one whose script dies exit 127 before deciding — which is exactly how a chokepoint in this repo failed once before. So merge-evidence-gate (~/.local/bin/merge-evidence-gate) reads the DECISION ITSELF: it walks recent successful auto-merge-all.yml runs and greps their logs for the decide-merge-evidence: state=… line, which carries sub-second timestamps. Most runs exit early ("No open PR for this event"), so it scans up to 25 rather than only the newest — reading one run would render a working gate as gone.

⚠️ IT DOES NOT GRADE THE VERDICT, deliberately. state=missing is the gate WORKING: it held a PR that had no evidence. A checker that went red on missing would be standing pressure to loosen the gate — the ratchet this whole effort exists to resist. What is measured is that the gate DECIDED; the ok/pending/missing mix is reported as data on every sample.

The window is 14 days and is deliberately loose. The gate is PR-triggered, not scheduled, so a quiet day is normal and must not colour the row. Two weeks with no decision at all means it was removed, disabled or renamed.

Both controls are observed, which is the point. Its first samples (2026-09-10 23:39 and 23:49, before the rule reached main) read EVIDENCE-GATE GONE; at 23:50, once a real guard run had logged decide-merge-evidence: state=missing, it read EVIDENCE-GATE OK decided@0.0d mix=missing=1. A signal that cannot say NO is not a signal, and one that can only say NO is not one either — this one has said both, each for the right reason.

⚠️ THE WINDOW IS MEASURED IN RUNS, AND THE RUN RATE IS ENORMOUS — the second GONE above was a FALSE one. auto-merge-all.yml fires on every PR event in a very busy repo: ~30 successful runs in seven minutes, measured. The checker's first draft scanned 25 and reported GONE against a gate that had decided correctly eight minutes earlier, because that run was already 31 back. A window measured in RUNS inherits the watched workflow's run rate; at this one's rate, 25 runs is fifteen minutes of history and the signal becomes a generator that mostly says dead. It scans 120 now (about an hour of traffic) and stops at the first decision, so the healthy path stays cheap and only the genuinely-broken path pays the full scan. Any future signal over a high-traffic workflow inherits this trap.

⚠️ NOT SCHEDULED YET, and that is a deliberate stop. The plist is written (co.propflow.merge-evidence-gate, hourly, mirroring co.propflow.cerebrus-gate) but NOT loaded: installing a cron schedule is inside the founder's risky-change carve-out. Until someone runs launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/co.propflow.merge-evidence-gate.plist, this row ages out to UNKNOWN after 14 days rather than rendering healthy — which is the correct failure direction and is why the gap is written here instead of papered over.

Appendix

decision blocks

What it doesCarries a question from a blocked session to Gera and the answer back, then tracks whether the answer was APPLIED — `blocked raise` files it and publishes a decisions-page link, one durable Temporal driver per drivable row polls the single answer store and wakes the lane that owns the work (the raiser, or whoever holds its task now), the 600s sweep drives OPEN rows AND answered-but-unapplied ones and publishes the unapplied backlog as counts, and `blocked outstanding` names every decided row with no terminal application — including the ones blocked on a capability no agent has
Kindpipeline
Repohttps://github.com/PropFlow-Technologies/agent-smith
Deployed at~/.local/bin/blocked (raise · check · resolve · apply · executed · verified · outstanding); agent_smith.decision_block_cli sweep (launchd, 600s); agent_smith workflows/decision_block.py (Temporal)
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → decision blocks

Liveness signals

SignalHow it is read
sweep scheduledlaunchd · co.propflow.decision-block-sweep
sweep completingfile-age · ~/.claude/jobs/decision-drivers.json · stale after 30 min

Detail document: ~/.claude/jobs/decision-drivers.json (the Systems tab opens this row into it).

Why this entry exists at all (2026-09-07). It did not, and the omission was load-bearing rather than tidy: this is the path every answer Gera gives travels, it has a launchd job and a durable workflow per question, and nothing in the catalog watched it. On 2026-09-07 six decisions he had answered on the decisions page — each row carrying his verbatim choice — sat between one and six days with their raisers' tasks still awaiting-human, the state whose whole promise (supervisor/src/tasks.ts) is that "when the answer lands there is still somebody whose job is to execute it". Nobody was, and no signal anywhere said so. A system absent from this file is a system nobody is watching; that is the rule this file opens with, and this is what it costs.

Why the liveness signal is a FILE AGE and not a log match. A completed sweep prints one of two terminal lines — sweep: N drivable, M skipped, or no open blocks in the ledger — nothing to drive — and they share no substring. The relay matches log patterns as SUBSTRINGS, not regexes (the HOST (OK|PRESSURE) lesson in the host entry above, which rendered down for a sampler writing every five minutes), so ANY pattern keyed on one of those two lines reads down in a healthy state: an empty ledger is the good outcome and would have been the one reported as broken. decision-drivers.json is written on every completed non-dry path — the empty ledger, the all-undrivable case, and the normal one — deliberately, so that "nothing is being driven" is stated freshly rather than left to the previous sweep's file. That makes its mtime the honest proof the sweep ran, whatever it found.

⚠️ THIS ENTRY SAID "IT READS OPEN BLOCKS ONLY, CORRECTLY" AND THAT WAS FALSE FROM THE DAY ADR-0013 SHIPPED. The prose above and the job string both described an OPEN-only sweep; the code has swept answered-but-unexecuted rows since ADR-0013 (open_block_records(..., unexecuted=True)). Corrected 2026-09-10 along with ADR-0022. Recorded rather than quietly fixed because it is the same defect class this file exists to catch — a description that reports green on behaviour that has moved underneath it — and catalog prose rots faster than anything else here: the claim was contradicted by a merge in the same repo, eleven days after it was written, and nothing failed.

What this system does NOT do, stated so nobody assumes it. Closing a block is not executing its answer, and tracking an answer is not applying one. The sweep drives an answered row only while the lane that owns it is alive (ADR-0013's liveness bound, ADR-0022's task-keyed successor), so an answer whose lane is dead is recorded, durable, correct and unapplied — named by blocked outstanding for a person, never executed by anything here. It executes nothing itself: it polls, it wakes, and it records what somebody else says happened.

Three of those non-facts are worth naming individually, because each one used to be invisible:

  • A row can be DECIDED and its application BLOCKED on a capability — a Slack kickoff to Smith, where every agent route is structurally dead. The sweep will not drive it (no wake fixes a missing capability) and does not hide it: it is skipped by name and counted in the manifest under unexecuted.needs. It is a queue for a person's hands, and it is not an unanswered question.
  • An APPLIED row is not a VERIFIED one. A merged PR stays merged forever while the behaviour it added is reverted underneath it, so an application record whose evidence names no revision reads "verified against no named revision" — a weaker claim, not a passing one.
  • Historical rows carry no task stamp and are not retro-fitted, so the ~176 answered-unapplied rows on this box became countable and triageable the day ADR-0022 landed, but only rows raised after it are automatically inherited by a successor.

Why the DETAIL file now carries the backlog, and why it is omitted rather than zeroed. decision-drivers.json grew an unexecuted block — total, counts by application state, the capabilities decided work is stuck behind, and the age of the oldest row. That last field is the one that matters: a total can sit flat while every row in it ages, so "how long has the oldest answer been waiting" is the number that says a backlog is growing. The counts are taken WITHOUT the liveness narrowing the drive list uses, because a row whose lane is gone is the most outstanding row there is — narrow the count the same way and it reads zero exactly when things are worst. And a sweep that could not count OMITS the field instead of writing 0: a zero nobody measured is this file's own frozen-job-reports-green failure, one field down.

Appendix — everything linked from this entry

decision ladder

What it doesAnswers a fork a working session cannot settle, without waking anybody — per ADR-0019 Fable and Astra are asked INDEPENDENTLY on one evidence snapshot and agreement is the decision, a disagreement gives Fable one reconsideration whose actionable verdict wins; every call is `fable-decide`, so every call is two options-reversed passes carrying the money/commitment gate, and within a call Fable still falls through to Astra when Fable cannot be REACHED (never when Fable was reached and declined); the answering model is stamped on the receipt, and a 300s canary writes `ladder OK` only while both model rungs are reachable
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/fable-decide (rung 1 `claude -p --model claude-fable-5`, rung 2 `codex exec -m gpt-6-astra`); receipts ~/.claude/jobs/fable/<id>.json; ~/.local/bin/decision-ladder-canary (launchd co.propflow.decision-ladder-canary, 300s)
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → decision ladder

Liveness signals

SignalHow it is read
canary scheduledlaunchd · co.propflow.decision-ladder-canary
both model rungs reachablelog-match-age · ~/.claude/logs/decision-ladder-canary.log · matches ladder OK · stale after 15 min

Detail document: ~/.claude/jobs/decision-ladder.json (the Systems tab opens this row into it).

Why this entry exists (2026-09-10). It did not, and this is the rung between a stuck session and Gera's decisions page — the one that decides whether he is woken at all. On 2026-09-10 it lost its only model rung for ninety minutes and nothing said so: six of seven seats weekly-walled, the seventh session-capped until 20:40Z, five questions on PR #7618 reported "pending Fable" while codex exec -m gpt-6-astra answered in 7 seconds when finally asked. local-bin#107 made the cap a wall (exit 4, stop retrying); local-bin#109 added the Astra rung, so the ladder degrades Fable → Astra → human instead of vanishing; local-bin#110 is the canary that lets this row exist. decision-blocks (above) catalogs the path an answer travels after a human is asked; this is the path that decides whether to ask one.

Why the signal is a log-match on the literal ladder OK, and not a file age. fable-decide is on demand — a quiet day writes no receipts — so a file-age over jobs/fable/ would render a healthy quiet day as degraded, the exact trap the decision-blocks entry documents from the other direction. The canary is the thing that CAN fail: it runs on a clock, reads ccswitch's snapshot (classified by fable-decide's own pick_seat(), so it cannot disagree with the tool it watches) and the codex access token's exp claim, calls no model, and writes one line every run. DEGRADED and DOWN lines are written too, so a reader sees what is wrong — but they do not match ladder OK, so this row goes stale precisely when a rung is missing (900s = three missed ticks). The launchd signal is the other fact: the canary exits 0 whatever it finds, so "the canary crashed" and "the ladder is down" are different colours, and they need different people.

The one thing that must never be "simplified" here. Could not be reached and was reached and declined must not share an exit path. A capped seat and a genuine resolvable: false are opposite facts, and a fall-through keyed on "non-zero exit" would hand a question a model said only a human may settle to a second model, which could return it settled. The discriminator is one pure function — did any pass return a parseable verdict object? — and tests/control-20-fable-decide.sh §G asserts every refusal path with a tripwire Astra stub that WOULD resolve, so a swallowed refusal cannot pass as green.

Appendix — everything linked from this entry

puller — the Driver's feed, and the decider

What it doesReads the Driver's PRs, main's CI and the decision queue every 300s and writes ONE delta digest, so the Driver judges instead of polling — and DECIDES un-ruled blocks by running the ADR-0019 ladder, then per ADR-0020 hands the ruling BACK to the blocked raiser in one message naming the choice, the receipt and the model, without ever resolving one itself
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/puller (launchd co.propflow.puller); ledger ~/.claude/jobs/puller/routed.json; spend ~/.claude/jobs/puller/spend.jsonl
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → puller — the Driver's feed, and the decider

Liveness signals

SignalHow it is read
scheduledlaunchd · co.propflow.puller
reading the fleetlog-match-age · ~/.claude/logs/puller.log · matches puller OK · stale after 15 min
digest freshfile-age · ~/.claude/jobs/puller/digest.md · stale after 15 min
rulings reaching raiserslog-match-age · ~/.claude/logs/puller.log · matches puller DELIVERED · stale after 4320 min

Why it exists. Gera, 2026-09-10: "we have to have something feeding you." The Driver swept PR states, check conclusions, review verdicts and the decision queue by hand on every wake, mostly to learn nothing had changed. In one evening that cost a PR merged out from under it, a regenerating alarm it lost one withdrawal at a time, and two problems Gera saw before the Driver did. driver/SKILL.md §1 already said "watching is a dumb process's job"; no such process existed. ADR-0017 is the rule; local-bin#113 specifies the rung and #114 builds it.

It reads, decides, and returns. It never acts on a ruling — no merge, no resolve, no approve, no label, no draft toggle, no production write, no registry write, and it never answers a decision page or writes state on a block. One writer per artifact (ADR-0003); the only artifacts it writes are its own. It is not a nudge watcher (launchd, no loop, no GATE, never in the nudge roster — §0b would be right to kill a second looper).

The one thing it sends is a single message to a single blocked raiser, carrying the ruling this rung obtained for that raiser's own question (ADR-0020). That is the decisions wake the blocked contract always promised, restored on a path that works — an answer clicked on the /agents Decisions tab wakes the raiser zero times. It is not a delta ping, it never reaches the Driver, and §1's ~30-firings cap is untouched.

Why four signals, and why they are not redundant. They answer four different questions and the row takes the worst of them:

signal the fact it carries what its absence means
launchd the job is loaded and not crash-looping nobody bootstrapped it, or it is failing to start
digest fresh a tick RAN and produced a file the process is dead or wedged
puller OK a tick ran and could read every source it is running blind — GitHub refused, or the decision queue is gone
puller DELIVERED a ruling actually reached a blocked raiser the return hop is broken, or nothing has needed deciding

⚠️ puller DELIVERED CARRIES A THREE-DAY WINDOW, NOT FIFTEEN MINUTES, AND THAT IS THE HONEST SHAPE. Deciding is demand-driven — a quiet week raises no blocks and delivers nothing, and a signal that went red for a quiet week would train everyone to ignore it (ADR-0009 cuts both ways: a signal that fails when the system is fine is as useless as one that cannot fail). Three days is long enough that silence is unusual and short enough that a return hop broken on Monday is visible by Thursday. It is the only signal that can show the last hop failing: the puller can read the whole fleet perfectly, ask the ladder, get a ruling, and hand it to nobody — and puller OK stays green through all of it. That was the state of this rung before the decider shipped, and a catalog row that could not show it would have called the gap healthy.

⚠️ THE SIGNAL IS THE puller OK LINE, NOT "A LINE WAS WRITTEN". The puller writes puller PARTIAL when any source is UNREAD and puller DOWN when none could be read, so a reader can see what is wrong — but neither matches the pattern, so the row goes stale exactly when the puller stops being able to read the fleet. Pairing it with digest fresh is deliberate: a job that dies and a job that runs blind are different failures needing different fixes, and a single signal that covered both would name neither. A signal matching any line would be file: exists in another costume (ADR-0009).

⚠️ EXIT 0 MEANS THE PULLER RAN, NOT THAT THE BOARD IS FINE. launchd's LastExitStatus says whether the job completes; the log word says what it found. Non-zero happens only when the digest itself could not be written. A puller that exited non-zero on a red PR would make "the puller crashed" and "CI is red" the same colour, and those need different people.

Ladder routing — it routes, it never resolves. An open block with no ruling goes to fable-decide — ⚠️ asked on BOTH model rungs concurrently, with agreement driving and a disagreement settled by one Fable reconsideration (ADR-0019). This paragraph used to read "Fable → Astra → a human"; that ordering is ADR-0016's and is superseded — it survives only inside a single call, as the fall-through when Fable cannot be reached. Capped at 2 per tick and ledgered by block id plus a hash of the question. A RESOLVED receipt is reported in the digest as apply it with drive --task, addressed to the Driver. ⚠️ Reporting a ruling in the digest and DELIVERING it to the session parked on the question are two different acts — the delivery message goes to the raiser and never to the Driver (ADR-0020); this sentence is about the digest only, and a reader who collapses the two will think a ruling reached the blocked lane because the Driver saw it. A question the ladder was reached on and declined is terminal and never re-asked — ADR-0016's rule, extended to the thing that feeds the ladder, because a scheduled job that re-asks a refusal every five minutes launders it by attrition.

⚠️ THE ROW IS NOT GREEN UNTIL THE PLIST IS BOOTSTRAPPED, AND THAT IS THE SIGNAL WORKING. One hand-run tick seeded ~/.claude/logs/puller.log and the digest at 2026-09-10 18:39 local, so the Driver has something to read tonight — but nothing is scheduled to write the next one, so puller OK ages past its 900s window and the row goes stale, while launchd reads down for a job that was never loaded. Both are true statements about a tool that is built and not yet installed.

That is the property being bought, demonstrated on itself: the signal decays when the thing stops running. A catalog row that stayed green on a system nobody installed — or on one whose last tick was hours ago — is the exact lie ADR-0009 exists to prevent. Bootstrap it (the command is in ~/.local/bin/README.md) and the row earns its colour on the next tick.

Appendix — everything linked from this entry

astra-relaunch

What it doesLaunches the next queued Astra brief when the lane goes to zero — a 300s launchd tick that derives liveness from the process table, claims one brief atomically, spawns a detached run under a per-tick, per-day and per-run cap, and exits; it never invents work and never loops
Kindpipeline
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/astra-relaunch (launchd co.propflow.astra-relaunch, 300s); queue ~/.claude/jobs/astra/queue/; receipts ~/.claude/jobs/astra/receipts/<runid>.json; run output /tmp/astra/<name>.log
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → astra-relaunch

Liveness signals

SignalHow it is read
launcher scheduledlaunchd · co.propflow.astra-relaunch
the tick completed and read every sourcelog-match-age · ~/.claude/logs/astra-relaunch.log · matches astra-relaunch OK · stale after 20 min

Detail document: ~/.claude/jobs/astra/run.json (the Systems tab opens this row into it).

This exists because the lane's supervisor was a Claude session's memory. Until 2026-09-10 an Astra job ran only because some session hand-typed nohup codex exec -m gpt-6-astra …. There was no launchd job, no cron and no loop, so a one-shot run exiting left the lane at zero until somebody remembered. It sat at zero for 11.5 hours that day: the 15:00Z run finished normally, artifact delivered, 215k tokens, and nothing launched the next brief. If a fact has to be remembered to stay true, it will be false.

The astra-relaunch OK pattern, not any line. UNREAD and BREAKER lines are written too, so a reader can see what is wrong — but they do not match, so this row goes stale exactly when the rung stops being able to do its job. A signal matching any line would be file: exists in a costume: green while documenting failure. staleAfterS is 1200 — four ticks — so one slow tick is not an outage and a stopped job is caught inside twenty minutes.

⚠️ THE LIVENESS SIGNAL IS THE LAUNCHER, NOT THE LANE, AND THAT IS DELIBERATE. A green row here means the rung is deciding every five minutes, not an Astra run is in progress. An empty queue is a correct, healthy IDLE — this rung launches what is queued and never invents work, so keying the signal on "a run is live" would render a legitimately quiet lane as an outage, and would go red for the one reason nobody should be paged about. Whether the queue has anything in it is the enqueueing session's problem; astra-relaunch --status prints it.

⚠️ detail CARRIES staleAfterS: 0 ON PURPOSE. run.json is an index into the process table, not a fact — the rung reads a pid and a start time from it and believes the lane is occupied only when ps agrees the pid exists, carries that run's --wrap <runid> sentinel, and started when the record says. An absent run.json means the lane is free; a stale one is reaped on the next tick and logged as reaped. Nothing keys health off its age.

Appendix — everything linked from this entry

Agent Smith

What it doesThe fleet's workplace actor — Slack presence, PR reviews, ticket intake
Kindactor
Repohttps://github.com/PropFlow-Technologies/agent-smith
Deployed at
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → Agent Smith

Liveness signals

SignalHow it is read
worker loadedlaunchd · co.propflow.smith-worker
slack socket loadedlaunchd · co.propflow.smith-slack-socket

An actor, not infrastructure: it uses the fleet's rails (credentials from the pipeline above, sessions like any other) rather than providing rails.

Appendix

row fence

What it doesStops a superseded or mistaken lane emitting an external effect on an action item it does not hold, by making every fenced gateway name its item and validating that name against the set the lane actually holds
Kindreflex
Repohttps://github.com/PropFlow-Technologies/agent-smith
Deployed atin-process :: agent_smith.row_fence, consulted by smith-pr-drive and smith-task
Statusproposed
Live on the Systems tabpropflowai.co/agents → Systems → row fence

Liveness signals

No liveness signals declared — the catalog renders this system Unknown until one is added. Unknown is not healthy.

Vocabulary first, because two mechanisms here are one careless sentence away from sharing a name (ADR-0023 decision 4). A Driver is assigned an action item — since 2026-09-12 setup hands the Driver a dock and the Driver assigns its items to sub-agents; nothing self-claims (ADR-0025 decisions 4 and 9). The verb was claim until then, which is the word ADR-0023 decision 4 records and the word the descriptor on disk still uses. Claim was also the product's word, and DriverWorkflow.assign (renamed from DriverTaskWorkflow.claim) is the door. Underneath, the thing that hands out the monotonic fencing generation is the fenced row lease (SmithRowLeaseWorkflow — the Smith prefix is dropped fleet-wide per ADR-0025 §9, and this type's final name is the one OPEN item on that list, pending Fable, because Astra argues a RETRY is a second writer even with one driver), which keeps the word lease as a named lag and is not SingleRunnerWorkflow (renamed from SmithWorkClaimWorkflow, ADR-0025 §9): they have opposite lifetimes, which is the whole reason the generation cannot live in the latter. This entry says assignment for what a lane takes and fenced row lease in full for what grants it.

What it is. The lane's descriptor (row-fence.json in its task dir) carries a set — the board plus every action item this session holds right now, the union of the arming-time row and any mid-life assignments. It answers one question: what may this caller touch? The gateway then takes --item, because it genuinely cannot infer one — nothing in a gh pr create says which of a board's items the PR is for — and validates that --item against the set. The validation is the mechanism; the flag alone is not. An unvalidated --item would fence something, just not the thing being worked, and come back granted: a lease with no fencing generation wearing a different costume.

Four cells, and they are the contract:

descriptor --item result
present in the set fenced at that item's generation
present not in the set exit 3 item_not_held
present absent exit 3 item_unnamed
absent either UNFENCED, announced on stderr

Exit 3 is the fence said no; exit 1 is the fence could not be read. Both stop the effect, and they are kept apart because they demand opposite reactions — a couldn't read reported as a said no sends a lane to bed when it should retry.

The board id is validated against KNOWN_BOARDS at both the read and the write, never free-form. This is not defensiveness: an unvalidated board fails in the direction that looks like success. portfolio-architecure — one transposition — resolves to a fenced row lease nobody else will ever ask about, so every guard is granted, every gateway prints allowed, and the lane writes with no fence at all. A no-op fence and a working fence are indistinguishable from the outside. Checking at the write too means the failure names the publisher rather than the gateway, which is the component that did nothing wrong.

Liveness: why signals is EMPTY, and what would fill it

An empty list renders UNKNOWN, and UNKNOWN is the correct reading today — not a gap somebody should close by adding a signal that goes green.

Nothing has been assigned an action item in production yet, so the fence has no steady-state emission at all. A signal that cannot tell working from never invoked is worse than none, and this catalog exists to stop exactly that. Three candidates were measured and rejected on 2026-09-11:

  • ~/Library/Logs/smith-row-sweep.log — real, and correctly formatted for log-match-age, but its only line is row-sweep rollout-x: 3 rows from a test board. It proves the board-owner sweep ran once in a test, and it would go green while the fence was entirely broken: it is a different system.
  • The worker log's row-lease: published … line — the publish genuinely logs. But smith-worker.err.log lines open with ANSI escape bytes and an ISO-8601 stamp (\x1b[2m2026-09-11T07:33:40.016278Z), and log-match-age requires the line to start with YYYY-MM-DD HH:MM:SS local. It would render unknown ("matched line has no timestamp") forever — a signal that never resolves is a row that teaches people to ignore the page.
  • The descriptor file itself~/.claude/jobs/tasks/<slug>/row-fence.json is a glob, and file-age does a single statSync on an exact path. Its absence is also the normal state for a lane holding nothing.

⚠️ gate printing allowed is NOT evidence the fence is live. This is the defect that was actually shipped and fixed here: resolve_path returns a path whether or not a descriptor exists there, so a lane with $SMITH_TASK_DIR set and nothing published printed row-fence: allowed and read as fenced while nothing had been asked. An unfenced effect announcing itself as allowed is the one thing this module must never print. It now says UNFENCED — no descriptor at <path> for that case, so the audit trail distinguishes granted from there was no fence to ask. Anyone reading a transcript for proof the fence worked must look for allowed — … on item <id> with an item named; allowed with an empty item is the old bug's signature.

What a dead fence looks like: silence. Every failure mode here is a write that simply was not guarded, and nothing emits when a guard does not run. That is why the liveness question is answered by whether a descriptor exists for a lane that holds something — and why the honest signal arrives only once something is assigned. When a Driver genuinely holds assigned items, the fillable signal is a file-age on that lane's descriptor, refreshed every heartbeat tick; it needs a fixed path, which needs the arming path below to be closed first.

Residuals, named — a catalog that hides these is worse than none

  • item_unnamed will refuse the first Driver that is genuinely assigned an item. prompts.py instructs agents to run smith-pr-drive add and smith-task verdict with no --item, and there is no shell caller of either verb anywhere in the repo — they are instructions to an agent. The moment a lane holds a descriptor those instructions produce exit 3. This fails closed and loud (the refusal ends "Pass --item.") and is unreachable while nothing is assigned, so it is recorded as a decision rather than guessed at.
  • operator-here in ~/.local/bin still passes no --board/--row. That is a live shared checkout where any edit is an unreviewed fleet-wide deploy, so the out-of-repo half of the arming path is deliberately untouched. operator-workflow claim is the door that works today (→ driver-workflow assign per ADR-0025 §9; the binary is not renamed yet, and naming a command that does not exist on PATH is the exact defect the finisher entry records).
  • ⚠️ A non-zero exit from an acquire means STATE UNKNOWN, never "nothing happened". Observed 2026-09-11: smith-row-lease acquire connected, took the row, published the descriptor, printed both lines — and then died of SIGSEGV in interpreter finalization, so the caller read 139. The work was complete; the process was not. The general form is worth holding beyond this system: an exit code describes the process, not the work, and the window is widest exactly where the work is most side-effectful, because the world has already changed by the time finalization runs. cli() now flushes and os._exits rather than finalizing, but a caller must still treat a non-zero acquire as go and look, not as it did not happen.
  • Raw gh pr create from an interactive session is still unfenced. smith-pr-drive add runs after the PR exists. hooks/command_gate.py is the only surface that could close it and it is wired inline for Smith-brain sessions only, so a human-started session does not run it.

status is proposed, not active, and that is the same honesty as the empty signal list: the mechanism is built, reviewed and tested, and it has not yet fenced a single production write. It becomes active when a Driver holds an ASSIGNED action item and an action item and its descriptor is on disk.

Shipped by agent-smith #452, on top of the fenced row lease (#449) and the Driver assignment door (#453, shipped as the "claim door").

Appendix

Smith daily self-review

What it doesEvery morning Smith reviews everything it said and did since the last checkpoint — sweeps the fail tags, writes every finding as a tracker action item, fans the fixes out as PRs, drives them to merge, self-certifies each completion naming its PR (prod proof in #agent-smith is the encouraged stronger grade), and records the checkpoint
Kindpipeline
Repohttps://github.com/PropFlow-Technologies/agent-smith
Deployed atTemporal tools-prod :: MorningQueueWorkflow step `smith-review` → SmithTaskWorkflow `smith-goal-<key>` on propflow-smith
Statusproposed
Live on the Systems tabpropflowai.co/agents → Systems → Smith daily self-review

Liveness signals

SignalHow it is read
dispatched this morninglog-match-age · ~/.claude/smith-state/self-review/dispatch.log · matches smith-review dispatched · stale after 1800 min
checkpoint recordedfile-age · ~/.claude/smith-state/review-checkpoints.db · stale after 1800 min

The smith-review skill's own preamble calls itself "the inventory that a future autonomous self-repair engine will run." Until 2026-09-07 every one of its runs was a human typing /smith-review. Gera: "it should run daily in the morning queue, and also drive itself to merge." This is that engine (ADR-0012 — whose "a merge refuses under a person" title clause and Can count Rung 5 prod-evidence clause are both superseded by ADR-0025; the ADR itself is not rewritten): the morning queue's last step runs agent_smith.self_review run, which decides the window from the checkpoint store, refuses a second run in one day, posts the brief as a #agent-smith thread, and starts a durable SmithTaskWorkflow carrying it on MODEL_HIGH. The review — sweep, items, PRs, merge, self-certification naming the PR (prod proof where reachable), checkpoint — is the brain's, inside that container. Read the morning's answer on the phase doc: /a/smith-fail-markers, N/M per group; the receipt is the thread the brief opened.

proposed, not active, on purpose. agent-smith #426 is open; nothing has dispatched yet and neither signal path exists on the machine. It flips to active after the first morning whose thread carries a checkpoint — receipts before claims, the same rule phase-snapshot below follows.

What the two signals prove, and what they do not (ADR-0009). dispatched this morning matches the one line self_review writes ONLY when it started a container — a skipped morning writes smith-review skipped … and a failed launch dispatch FAILED, neither of which matches, so a queue that stopped dispatching goes red within 30h rather than reading as quiet. checkpoint recorded is the mtime of the store smith-review-checkpoint record writes at Rung 6 — the review reaching its END, whatever it found. A green here does not mean a clean day; the N/M on the page is where that lives, and record itself refuses a completion claim over an incomplete sweep. The two can disagree legitimately: a morning where a human already ran the review is skipped (signal 1 red-after-30h) with the checkpoint fresh (signal 2 green) — "the review ran, not by the queue".

Neither signal proves a fix was PROVEN IN PROD — which, since 2026-09-12, is the STRONGER grade and no longer the bar. The bar is a PR that addresses the point plus the sub-agent's own completion, which must NAME ITS PR (ADR-0025 decision 6). That fact lives on the tracker action item: bin/refresh-tracker (propflow-docs #47, live) derives shipped only from a merged PR AND a prod_evidence permalink — the STRONGER grade. ⚠️ It is no longer the done bar. That rule is why the board reads 27 shipped against 114 merged-but-never-exercised; under the 2026-09-12 standard those 114 are done, and prod_evidence separates a merged fix from a prod-proven one rather than refusing to count the first. The distinction is superseded, not deleted — it is kept as the record of why it was drawn, and it earned its keep on the day it was superseded: a prod preflight read FIVE address collisions where PR #7713's body had recorded THREE the day before. A PR body is a snapshot of a moment.

Appendix — everything linked from this entry

The Oracle

What it doesWhen a Clara turn grades below 8, explains why from the persisted record in the turn's own thread — one plain-English reply naming the cause (prompt gap / missing tool / tool result ignored / policy / grader gap / could not determine) with quoted evidence and no resident detail — and, when the cause has a fix, opens ONE held PR per cause on propflowai; every morning, one line per live property from the persisted grade rows
Kindpipeline
Repohttps://github.com/PropFlow-Technologies/agent-smith
Deployed atTemporal tools-prod :: OracleTurnWorkflow on propflow-smith (started by propflowai's grading seam) + MorningQueueWorkflow step oracle-nightly
Statusproposed
Live on the Systems tabpropflowai.co/agents → Systems → The Oracle

Liveness signals

SignalHow it is read
a low turn was explainedlog-match-age · ~/.claude/smith-state/oracle/dispatch.log · matches oracle-turn · stale after 20160 min
morning line postedlog-match-age · ~/.claude/smith-state/oracle/dispatch.log · matches oracle-nightly posted · stale after 1800 min

Not ADR-0086's oracle guard (the human-approval bit on a generated eval scenario). The Oracle is Gera's name for the Agent Smith workflow that explains a low-graded Clara turn — fleet ADR-0014, product propflowai ADR-0132.

Grading (propflowai's postScorecardIfNew, the one seam every detector reports through) reaches Smith by ONE door: an onLowTurn hook fired after a turn line lands, when the computed grade is below ORACLE_THRESHOLD (8), starting OracleTurnWorkflow by type name with id oracle-<conversationId>-<turnKey> and FAIL on a duplicate. No polling, no cron, no Slack-side listener. The workflow shells scripts/oracle-evidence.ts from a worktree pinned to origin/main — the ONLY thing it may quote — and the bucket is decided there, once, by a table over the record; Smith renders a template (no LLM in the finding path) and validates it with the live instrument's own predicates before the send is scheduled. A fix-bearing cause takes a fleet-scoped claim and starts one SmithReplyWorkflow fix-drive in #agent-smith, which opens one PR per cause on propflowai, read back. ⚠️ hold-for-review + auto-merge off was the old shape and is retired (2026-09-12): that PR now merges itself on green CI plus a cloud-reviewer 🟢, like every other PR in every repo. The Oracle still does not merge by hand; the PR driver does. The nightly step rides the morning queue (07:00 CT) immediately before smith-review.

proposed, not active, and the signals are already the real ones. Both match lines agent_smith.activities.oracle_turn.write_dispatch_line writes with a local YYYY-MM-DD HH:MM:SS stamp: a low turn was explained matches oracle-turn <outcome> … — written ONLY when a workflow ran to an outcome (a refused start in the propflowai seam writes a propflowai log line, never this one), so it is a per-run pulse and its window is long (14 days: low turns are sparse). morning line posted matches oracle-nightly posted — written ONLY when the step posted its lines, so a dead step reads red within 30 hours. That second signal is the true liveness. Flips to active with no signal edit once the writer logs (agent-smith PR 2/3 + propflowai #7338 deployed and the ECS worker provisioned with Smith's Temporal key).

What the Oracle cannot see, said plainly (ADR-0009). A stale injected price with no lookup behind it grades 10 and never reaches it; a pre-field voice call has no persisted briefing, so an unbacked figure there is "could not determine"; a turn the grader never scored is invisible to the morning line, whose footer says so.

Appendix — everything linked from this entry

Driver heartbeat (was: Operator heartbeat)

What it doesEvery 120s, re-derives each live task's phase rows and republishes phases.json plus the thread's pinned one-line /agents pointer — the only ticker keeping the Phases card fresh on a Driver-only task
Kindpipeline
Repohttps://github.com/gera-propflow/dotclaude
Deployed at~/.claude/scripts/propflow-status/operator-heartbeat.py
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → Driver heartbeat (was: Operator heartbeat)

Liveness signals

SignalHow it is read
ticker loadedlaunchd · co.propflow.operator-heartbeat
sweepinglog-match-age · ~/.claude/scripts/propflow-status/state/operator-heartbeat.log · matches sweep scanned= · stale after 30 min

The LaunchAgent that keeps a running task's PHASE SURFACE current. Every 120s it walks ~/.claude/jobs/tasks/<slug>/, and for each task with a Slack binding and a non-terminal state it makes exactly ONE call into agent-smith — thread_phase_activities.refresh_phases(channel, thread_ts) — which re-derives the rows once, rewrites phases.json (the file phase-snapshot below transports), and pushes the thread's one-line /agents pointer at its pinned anchor.

⚠️ WHY THIS ENTRY EXISTS AT ALL — it is the ADR-0004 case, measured. This system was absent from this catalog from 2026-08-18 until 2026-09-11, and that absence is the whole reason the /agents cutover missed it. agent-smith #371 replaced the thread's eleven-row phase table with one line and a link on 2026-09-04; this script, in a different repo, went on posting the full table into the same threads. Ledger receipt, 2026-09-07→11: 18 table posts across 7 distinct threads as of 20:27Z on 2026-09-11, the last tick before the cutover — re-run the query today and it reads 22, because a still-old live copy of the script re-posted four tables at 20:30–20:33Z, after the first reclaim and before the final deploy, and the reclaim deleted them again at 20:39:23Z. That is the number to stamp, not a number to quietly update; ADR-0024 records the window and why the second reclaim could fire at all. And on the 2026-09-11 kickoff thread the pointer landed at 20:05:08 and the table 46 seconds later. Nobody checked it because, by this file's own first sentence, it did not exist. ADR-0024 retires the Slack table and amends ADR-0007.

⚠️ THE SIGNAL IS THE LOG, NOT THE LEDGER, AND THAT IS DELIBERATE (ADR-0009). The obvious liveness check for this system used to be "did it post to Slack?" — select count(*) from posts where source='operator-heartbeat' in ~/.claude/smith-state/outbound-ledger.db. After ADR-0024 that query returns zero forever, by design, so it can no longer tell a working retirement from a dead LaunchAgent: a signal that cannot fail when the system fails is not a signal. The tick therefore prints one line per task per tick — 2026-09-11 15:39:35 [operator-heartbeat] <slug> state=<state> refresh=<posted|edited|reminted|unchanged|skipped|failed|declined-…> for each task it TOUCHED, plus — unconditionally, once per sweep — 2026-09-11 15:59:02 [operator-heartbeat] sweep scanned=3168 bound=11 published=2 declined=0 skipped=0 failed=0 errors=0 unreclaimable=7.

⚠️ The catalog keys on sweep scanned=, NOT on the per-task line and NOT on the [operator-heartbeat] prefix, and both alternatives are wrong for reasons worth keeping. A per-task line fires only when a bound, non-dormant task exists, so a healthy ticker whose live tasks have all finished prints nothing and this signal reads down. ⚠️ This is the one home for that count, because it is a LIVE number and not a constant — as of 2026-09-11 20:40Z, exactly two tasks were bound and non-terminal (out of 11 bound and ~3,169 scanned), i.e. one finished task away. It is a dated snapshot, not a standing fact: bind a third task and it changes. ADR-0024 makes the same argument and deliberately does NOT restate the figure — it points here, the same way it points here for staleAfterS. And the tick-failure line also carries the [operator-heartbeat] prefix, so a prefix-keyed pattern would go green against a log containing nothing but errors — the inversion catalog.ts's own header warns about. The sweep line is written even when nothing needed doing, which is the only shape that separates quiet because idle from silent because dead, and it carries the counts that say which. staleAfterS is 1800s — fifteen missed 120s sweeps (StartInterval 120 in co.propflow.operator-heartbeat.plist; 1800/120 = 15, not two). It is set wide rather than tight on purpose: this line, unlike the per-task one, has no legitimate reason to be absent, so the only thing 1800s buys is room for a slow sweep — each one walks ~3,200 task dirs and imports agent-smith — without a single long tick flipping the catalog to down. The launchd signal beside it is the fast catch for a job that stopped being LOADED; this one catches a loaded job that stopped SWEEPING. ⚠️ The leading local-time YYYY-MM-DD HH:MM:SS stamp is REQUIRED, not cosmetic: evalLogMatchAge parses a matched line's age with the anchored regex /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/, so an ISO …T…Z stamp — or a stamp anywhere but the start of the line — renders this signal unknown forever. This script's first draft had exactly that bug (fixed in dotclaude#45 before deploy, with a both-directions control), and it is the THIRD sighting in this file: fleet-deploy (ISO instead of local) and row-fence (ANSI-prefixed lines) are the other two.

What it deliberately does NOT do. It derives no phase and owns no Slack message. Both would be a second answer to a question phase_table.py already answers once — the failure phase-snapshot below was built to end. It also declines rather than publishes when a thread is bound to a different task's slug (thread_phase.claim_task_slug, compare-and-write), because filing one task's rows under another task's pointer is the one thing a single anchor must never do.

Appendix — everything linked from this entry

Phase snapshot transport

What it doesCarries Agent Smith's already-derived phase table to the /agents board as a versioned per-task JSON sidecar — one resolver in Python, two surfaces
Kindpipeline
Repohttps://github.com/PropFlow-Technologies/agent-smith
Deployed at~/.claude/jobs/tasks/<slug>/phases.json
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → Phase snapshot transport

Liveness signals

SignalHow it is read
writer loadedlaunchd · co.propflow.smith-worker
phase evidence recordingfile-age · ~/.claude/smith-state/thread_phase.json · stale after 1440 min

Two systems, one document. Agent Smith (phase_snapshot.py) derives the table once — in the process that owns the facts — and writes it beside task.json; this relay (digest/phases.ts) transports it as the additive phases / phasesUnreadable fields on task rows the feed already serves, and propflowai renders it on /agents/[sessionId]. The board never computes a row. ADR-0007 is why the transport was forced rather than chosen: grep -rn SMITH_STATE_DIR relay/src supervisor/src returns nothing, so half the phase inputs live in a store nothing on this side can reach.

active since 2026-09-11, and this entry carried the stale claim that made the flip necessary. It read "nothing is deployed, and no phases.json exists on the machine yet" — written while agent-smith #348 and agentflow-relay #83 were open — and stayed that way after both merged. Receipts, measured while writing ADR-0024: ~/.claude/jobs/tasks/arch-design-stress-test/phases.json and …/phase-pointer-title/phases.json both exist and were rewritten at 2026-09-11T15:39:22 and 15:39:35 local. The flip is late because a proposed entry that has silently gone live reads as nobody has built this, which is how a second session builds it again — the disease this file's own header names. Found by this repo's reviewer on the PR that added the operator-heartbeat entry above, which measures the very file this entry said did not exist.

What the signals prove, and what they do not. writer loaded is the process that does the writing. phase evidence recording watches thread_phase.json — Smith's evidence store, the very one under SMITH_STATE_DIR that the board cannot reach — whose mtime ticks on every phase record; a Smith that has stopped seeing threads for a day stops looking healthy.

Neither proves a snapshot file landed, and that gap is a limit of the catalog's vocabulary, not an oversight. file-age takes a fixed path and this document's path is per-slug (~/.claude/jobs/tasks/<slug>/phases.json), so the per-task file cannot be declared. The end-to-end proof is a task row on /api/feed carrying a phases object; a signal type that can watch a newest-match across a directory is the follow-up that would let this entry assert it.

⚠️ A log-match-age on ~/Library/Logs/smith-worker.err.log was the first draft and is wrong, measured rather than guessed: that log grows ~6 KB/min, evalLogMatchAge tails only the last 64 KB, so the window it can see is ~11 minutes regardless of the staleAfterS declared. The signal read ok at 14:29 and down at 15:00 with nothing about the system having changed — it would have been reporting log volume while claiming to report liveness. The whole point of a canary is that it fails differently from the thing it watches.

Appendix — everything linked from this entry

Phase dock rotation

What it doesKeeps docs.propflowai.co/a/portfolio-architecture-phases true without anyone editing it — re-derives every tracker action item's status from live PR, decision and prod-evidence state (a merged PR naming the item is done; prod evidence grades it stronger — ADR-0025 decision 6), rewrites the dock's live block (running lanes, Astra, decisions taken) and its top 'Waiting on you' queue from the decisions ledger, and ships by pushing when and only when something moved
Kindpipeline
Repohttps://github.com/PropFlow-Technologies/propflow-docs
Deployed atpropflow-docs/bin/refresh-trackers (launchd co.propflow.tracker-refresh, StartInterval 300) → bin/refresh-tracker · bin/derive-live-lanes · bin/sync-dock-phase00 · bin/dock-ping
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → Phase dock rotation

Liveness signals

SignalHow it is read
rotation scheduledlaunchd · co.propflow.tracker-refresh
rotation tickingfile-age · ~/Library/Logs/tracker-refresh.out.log · stale after 15 min
live deriver runningfile-age · ~/Library/Caches/propflow-docs/live-lanes-state.json · stale after 15 min
dock freshfile-age · ~/Library/Caches/propflow-docs/dock-fresh.marker · stale after 30 min

Why this entry exists (2026-09-10). It did not, and by this file's own opening rule that meant it did not exist — while it had quietly become the surface Gera reads most. His words that day: "If something's waiting for me, it needs to be a clear decision link on that portfolio architecture phase doc. At the very top... And I will go and answer those as they come in because I'm looking at that phase doc all the time." The dock now carries a Waiting on you block whose membership is the decisions ledger itself and whose answeredness is re-read each tick from the canonical answer store, so this rotation stopping no longer just freezes a status table — it strands the queue of questions a human is waiting to answer, on the page where he expects to find them. Nothing was watching the watcher.

Why the liveness signals are FILE AGES and not a log match. The terminal line this rotation prints is 2026-09-10T20:06:07Z all trackers current — nothing to publish, and its stamp is ISO 8601. log-match-age needs YYYY-MM-DD HH:MM:SS local; handed an ISO stamp it renders UNKNOWN while every test stays green — a signal that reports nothing while looking installed. So liveness is taken from mtimes instead, and deliberately from two different layers so they fail apart: tracker-refresh.out.log proves the launchd wrapper woke and ran, live-lanes-state.json proves derive-live-lanes itself completed, because that cache is written on every derive whether or not the page changed. Both are set to three intervals (900s), the same "three intervals with nothing looking" threshold the page renders to its reader.

What these signals do NOT prove, stated so nobody assumes it. They do not prove the dock is fresh, only that the rotation is running. The published page's generatedAt is the last tick that CHANGED something — refresh-trackers neutralises it before diffing so an unchanged page is not re-committed or re-deployed — so an old stamp on a quiet afternoon is correct, not a fault. Nor do they prove a deploy: shipping is the push, and Cloudflare Pages can queue behind other sessions' pushes for many minutes. The end-to-end proof is bin/docs-fetch /a/portfolio-architecture-phases returning the change.

⚠️ dock fresh is the only one of these four that answers the question a reader actually has — added 2026-09-11, and the three above it are kept precisely because they fail differently. On 2026-09-11 a docs push landed inside this job's four-minute pull→push window, its own push was rejected, the checkout was left ahead 1, behind 1, and every later tick's git pull --ff-only refused to advance a diverged branch: 10 rejected pushes, 32 frozen ticks, worst case 45 minutes of a stale dock. All three signals above stayed GREEN for the whole of it, correctly — the rotation was running, and was faithfully writing pull failed — not touching the site into that log every five minutes. Green meant the thermometer worked. That is ADR-0009 stated as an incident.

So bin/refresh-trackers touches dock-fresh.marker on one condition and no other: the dock was derived on that tick (not skipped for being dirty, not frozen behind a failed pull) and the commit it was derived against is origin/main. Nothing else touches it — not a tick that ran, not a tick that logged, not a tick that failed politely. So its mtime is not "the job ran"; it is the last moment the published page was what the rotation would have produced, and one signal now covers a dead job, a wedged repo and a dock left dirty by a co-tenant. The sibling dock-fresh.json carries the reason in prose and is deliberately not the signal: it is rewritten every tick, fresh or not, so its own mtime lies. staleAfterS is 1800 — six intervals, half the worst observed freeze and three times the "nothing has looked" threshold the page renders to its reader, which is ADR-0009's corollary about picking a threshold that cannot flap.

⚠️ AND A RED SIGNAL HERE REACHES NOBODY ON ITS OWN. relay/src/systems/catalog.ts is read-only by construction and the only importer is relay/src/index.ts, which serves the /agents Systems tab; nothing under supervisor/src/ reads this file at all. For a failure whose entire symptom is the page you are looking at has stopped telling the truth, a signal you must already be looking at is circular. So the marker is consumed by a second rung — supervisor/src/dockfresh.ts, which stats the same file each sweep and raises a dock-frozen alarm through escalate() (delivered to the architect, or an open row on the decisions ledger; never a log line with no reader). Signal, not callADR-0003: that phase never runs bin/docs, never takes the docs publish lock, and only ever stats. The two instruments split the failure modes on purpose: the alarm covers the marker going stale, and the catalog covers it being absent (a missing path renders unknown, and unknown never renders healthy), which the alarm deliberately will not raise on.

⚠️ The per-file dirty guard is what makes this survivable in a shared checkout. propflow-docs is live and many sessions commit into it at once. The rotation computes the set of files it would write, intersects that with the dirty set, and skips precisely those by name rather than bailing on a dirty tree — because it used to be git status --porcelain → exit, and on 2026-09-10 one foreign uncommitted file stopped all twelve trackers for hours, once every fifteen minutes, saying so in a line nobody read. A session that leaves the dock dirty across a tick boundary still silently un-watches that one page, so: commit what you touch, and leave the tree clean.


Appendix — everything linked from this entry

doctrine pull

What it doesReflex — fast-forwards ~/.local/bin, the live shared checkout carrying canon/operator-SKILL.md, so a merged doctrine change reaches every session without a human pulling it
Kindreflex
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/doctrine-pull :: launchd/co.propflow.doctrine-pull.plist
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → doctrine pull

Liveness signals

SignalHow it is read
job loadedlaunchd · co.propflow.doctrine-pull
tick completinglog-match-age · ~/.claude/jobs/doctrine-pull/doctrine-pull.log · matches health=ok · stale after 30 min

Detail document: ~/.claude/jobs/doctrine-pull/last.json (the Systems tab opens this row into it).

The hazard it closes. ~/.local/bin/canon/operator-SKILL.md is the Driver manual, and agentflow-relay ADR-0021 §4 makes it a live shared checkout on PATH for every session, deliberately: merging a doctrine change is meant to be an instant fleet-wide deploy. That design has exactly one moving part — something has to pull — and until 2026-09-11 nothing did. Every advance in that repo's reflog was a hand-run git pull, and local-bin's own README said so (main here "only moves forward by git pull after a PR merges") without anyone reading it as a gap. It cost the largest miss of 2026-09-10: the claim commands (operator-workflow claim/claims/release) existed for hours before any manual in the checkout named them, and the merge documenting them had to be waited on by a human before a Driver could read its own arms.

⚠️ co.propflow.puller is not this, and the name is why the gap survived. That job reads PR states, check conclusions, review verdicts and the decision queue. It contains no git pull and never touches ~/.local/bin. It was loaded, healthy and ticking the whole time the doctrine checkout was going unfed — a green light for a different question.

⚠️ THE SIGNAL THAT LOOKS RIGHT AND IS A TRAP: "the checkout is current." It was current the night this was investigated — 0 commits behind, both #127 and #128 present — because a human had pulled it 3 hours earlier. Currency is an outcome someone may have produced by hand; it says nothing about whether anything will produce it next time. That is why the launchd signal sits beside the log signal: one proves something is scheduled, the other proves ticks complete. doctrine-pull --status makes the same distinction for a human, and reports JOB_DEAD over a perfectly current checkout when nothing has ticked.

⚠️ AND THE READ-OUT EVERY SESSION ACTUALLY SEES USED TO HIDE THIS. loop-doctor --version printed a bare short sha of this checkout — which reads as a version and proves nothing, because the question that matters here is not "which commit" but "how far behind". Measured on 2026-09-12: the checkout sat 1–2 commits behind for 25 minutes (17:57–18:22, untracked astra-relaunch* copies blocking the fast-forward, BEHIND_REFUSED health=bad logged six times) and --version printed a sha and looked healthy for every one of those minutes. It was resolved by a hand merge — the reflog reads merge origin/main: Fast-forward at 18:26, not doctrine-pull:.

Since local-bin#168 the count is welded to the same line as the sha, so neither can be quoted without the other:

loop-doctor a2214a1  ·  6/6 parts linked …  ·  current with origin/main (fetched 79s ago)
loop-doctor a2214a1  ·  6/6 parts linked …  ·  2 BEHIND origin/main (fetched 61s ago)

It does not fetch, and that is why the count can read UNKNOWN. This job is the only fetcher; a second one would re-inherit the keychain hang #131 fixed, and --version must stay side-effect-free. So --version reads a LOCAL origin/main — which is only as fresh as this job — and pairs the count with last.json's tick age, downgrading to FRESHNESS UNKNOWN past 1800s or with no tick on disk. If doctrine-pull dies, 0 behind would otherwise be a lie told in the same confident voice as the truth. That makes --version a third reader of this job's liveness, beside the two signals above.

⚠️ A WEDGE IS WORSE THAN A FAILURE, and this job found that out in production on its first tick. It ran ~90s without exiting: state = running, runs = 1, no status file, no log line, ps showing git-remote-https. launchd does not start the next tick while one is running, so one wedge is permanent silence — and it leaves the previous success sitting in last.json looking like health. The cause was never discriminated and this entry does not pretend otherwise: the repo's credential.helper = osxkeychain cannot prompt from a background LaunchAgent, and the machine was at load average ~100 with the job at Background priority — the ticks that later SUCCEEDED took 62s and 105s, so ~90s may simply have been an unfinished slow fetch. No git-credential-osxkeychain process was ever observed. Both are closed: GIT_TERMINAL_PROMPT=0, a per-invocation -c credential.helper='!gh auth git-credential' (gh's token is a plain file, needing no keychain and no GUI session), a 240s timeout sized against those measured 62s/105s ticks, and no ProcessTypeBackground asks launchd to defer under exactly the load a fan-out night produces, which would starve the doctrine deploy precisely when the fleet is busiest. The tick also writes RUNNING before the fetch, so a wedge renders as HUNG rather than as the last good tick.

Why the log signal matches health=ok. log-match-age matches with String.includes — a substring, not a regex (relay/src/systems/catalog.ts, l.includes(sig.pattern)) — so it cannot say "CURRENT or ADVANCED". A signal on state=CURRENT alone goes stale after a run of consecutive ADVANCED ticks, which is exactly what a busy merge night produces: local-bin merged 7 PRs in one hour on 2026-09-11, and the catalog would have called a perfectly working job dead. Matching any line is worse — a wedged tick writes RUNNING forever. So the job stamps health=ok on precisely the two healthy terminal states.

It is deliberately timid, because the directory is on PATH live. Never reset, checkout, stash or clean; merge --ff-only refuses rather than clobbers, which matters because ~/.local/bin carries untracked tools nobody has committed yet. It refuses to act at all off main: a pull --ff-only on a parked feature branch fails silently, which is how the 2026-06-13 false spine-health alarm happened.

How you can tell a person did not do it. The job sets GIT_REFLOG_ACTION=doctrine-pull, so an advance it made reads doctrine-pull: Fast-forward in the reflog where every hand-run one reads pull --ff-only: Fast-forward. "Mechanism or person?" is answerable from disk rather than from a report.

Appendix

slug owner

What it doesAnswers whose task slug a register belongs to, so two live incarnations of one Driver cannot both write the claim register and the acceptance gate while each reads the other's lines as its own — and refuses the arm that would create the second writer
Kindreflex
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/slug-owner (the judge) :: ~/.claude/scripts/operator-here (the refusal), symlinked onto PATH as ~/.local/bin/operator-here
Statusproposed
Live on the Systems tabpropflowai.co/agents → Systems → slug owner

Liveness signals

No liveness signals declared — the catalog renders this system Unknown until one is added. Unknown is not healthy.

The hazard it closes. A Driver's task slug (sess-<name>) names a DIRECTORY, and that directory holds the claim register (claimed-items.txt), the acceptance gate (acceptance.json) and the row fence (row-fence.json). The slug is derived from the roster NAME, so a respawned pane lands on the same slug — which is the design: a dying pane must not lose the work. The cost is that nothing in the path could tell two incarnations apart.

2026-09-12: a Driver pane respawned, the pre-respawn session did not die — lanes caught it writing files 6 and 9 minutes later — and four action-item claims plus three acceptance items appeared under the successor's slug that it never authored. It asked twice who wrote them before the tmux pane address settled it: driver-v2, propflow-b4 and propflow-1f all resolved to ONE pane.

⚠️ Nothing was at risk in that instance, and the record must keep saying so. Both writes were APPENDS, and appending only makes "done" harder to reach. The direction was luck, not design — the same seam lets one incarnation release a row the other's lane is still working, which the row fence cannot catch because both are the same holder.

What it is, in two halves. slug-owner <slug> is a judge that writes nothing: it reads the operatorSid that already binds a session to a slug and exits 0 MINE, 9 NOT MINE (naming the owner and whether it reads LIVE / DEAD / UNKNOWN) or 5 CANNOT TELL. operator-here is the refusal: before binding a session to a slug it consults the judge, and a different owner that reads LIVE — or whose liveness could not be MEASURED — refuses the arm with exit 9 and writes nothing.

⚠️ RESPAWN IS NORMAL AND THIS DOES NOT FORBID IT. A predecessor that reads DEAD is taken over with no flag; --supersede is the deliberate door through a live one. Either way the takeover is recorded (priorOperatorSid, operatorEpoch, a bounded handovers list), because a register that names only a slug cannot answer "who wrote this".

⚠️ 5 IS NOT 9, AND unknown IS NOT dead. Both refusals are non-zero and demand opposite repairs — 9 is stop writing, 5 is arm it. Liveness comes from a process list and a transcript mtime across both projects roots (~/.claude/projects and every ccswitch/session-cfg/*/projects), deduped by realpath and filtered by isdir, the same shape find-pane uses. That filter is load-bearing: an absent root globs empty exactly like a present empty one, so without it "I could not look" renders as "I looked and it was gone" — and dead is a successor's licence to take a live slug.

What it does NOT close, named rather than hidden:

  • "Who wrote THIS LINE" is still unanswerable. The registers carry no per-line authorship, so after a legitimate takeover a predecessor's lines are indistinguishable from the successor's. What this closes is two writers live at once.
  • claimed-items.txt has no writer tool — it is hand-appended — so it is made askable by the judge, not gated by it.
  • The release half (a sibling freeing a row a live lane is still working) is agent-smith #511, separate and open.
  • An absent judge is not a refusal. operator-here proceeds and prints ⚠ fence: NOT RUN … do not read this arming as fenced — a box without local-bin must still be able to arm, and an unrun fence that read as a pass would be the defect one layer up.

Liveness: why signals is EMPTY, and what would fill it

An empty list renders UNKNOWN, which is the correct reading: at the time of writing both PRs are OPEN and the refusal has never fired in production. What would fill it is the refusal's own exit-9 line — operator-here writing a task-event when it refuses an arm, and the handovers entries accumulating in task.json, both of which are files a sweep can count. Neither exists yet, and inventing a signal that nothing emits is how a catalog starts lying.

status is proposed, not active. It becomes active the first time an arm is refused, or the first handovers entry is written by a real respawn.

Appendix

lane scratch namespace

What it doesGives every lane of a Driver its OWN subdirectory of the one shared session scratchpad, and refuses a write to the shared ROOT while lanes are live — so two lanes picking the same natural filename cannot silently overwrite each other's work
Kindreflex
Repohttps://github.com/gera-propflow/local-bin
Deployed at~/.local/bin/lane-scratch :: ~/.local/bin/canon/guard-lane-scratch.py, linked to ~/.claude/hooks/ and registered under PreToolUse by loop-doctor
Statusproposed
Live on the Systems tabpropflowai.co/agents → Systems → lane scratch namespace

Liveness signals

No liveness signals declared — the catalog renders this system Unknown until one is added. Unknown is not healthy.

The hazard it closes. A Driver's lanes are sub-agents of ONE session (operator-SKILL §0d), so they do not get their own scratchpad — they all get the Driver's, and nothing namespaced it. 2026-09-12: the #7708 lane wrote pr-body.md at the root; the #7744 lane picked the same obvious name and pushed #7708's content over #7744's PR description. pr-body.md, v1.md, v2.md, v3.md and v4.md all sat unprefixed at that root with eight lanes live.

⚠️ Worse than an ordinary race, because the collision is SILENT and the wreckage is PLAUSIBLE — a PR description full of real prose about a real PR, just the wrong one. Nothing in the write path could tell my file from a file with my name on it, and the natural filename is exactly the one every lane picks. It scales with parallelism: the more lanes, the likelier two reach for pr-body.md in the same minute.

What it is. lane-scratch <item-id> mints and prints <scratchpad>/lanes/<item-id>/, idempotently, with an owner stamp; the Driver runs it in the same breath as the assignment and names the path in the kickoff. canon/guard-lane-scratch.py is the mechanical half: a PreToolUse guard (Write|Edit|NotebookEdit and Bash) that refuses to CREATE a file at a scratchpad root while a lanes/ directory exists, and refuses to overwrite a root file a DIFFERENT writer already wrote, naming the other writer. Lanes are told apart by their own sub-agent transcript; the session id is the same for all of them. Every permitted write is recorded in <scratchpad>/.writers.jsonl.

Liveness: why signals is EMPTY, and what would fill it

status is proposed because nothing has run it yet, not because nothing installs it. The first draft of this entry said the guard was inert pending a hand-added line — that was true of the first draft of the PR and a fresh-context review called it the blocker: ~/.claude/settings.json is in no repo, so a hook whose installation is a remembered step is a hook that never runs, while the two SKILL.md files (which ARE symlinked live) tell every Driver and Worker it will. loop-doctor now owns both halves — its canon map links ~/.claude/hooks/guard-lane-scratch.py and its check 3 registers the command under PreToolUse with matcher Write|Edit|NotebookEdit|Bash, re-registering it after a reset --hard of ~/.claude. It goes active on its first run in a new session (hook config is snapshotted at session start).

The liveness question here is not "did it tick" — it is "is it wired", and the catalog cannot ask that today. evaluateSignal supports exactly four types — file-age, log-match-age, launchd, self (the SystemSignal.type union in relay/src/systems/catalog.ts) — and none of them reads a pattern out of a config file. Declaring a fifth spelling would render unknown signal type, which is a worse entry than an honest empty one. What would fill it: a file-match type (path + pattern, ok when the pattern is present), at which point the signal is ~/.claude/settings.json matching guard-lane-scratch and this entry goes active with no other edit. Until then lane-scratch --check answers it for a human, and loop-doctor --check answers it for the fleet.

⚠️ A per-write liveness signal would be a trap, which is why none is proposed. The natural candidate — age of the last blocked collision — reads STALE exactly when the fleet is healthiest, and the ledger it would read lives at a session-specific path that no fixed signal can name. An unwired guard and a quiet one are indistinguishable from the inside; that is the same reason lane-scratch --check exits 4 (could not measure) on an absent ledger and never 0: nothing was recorded is not nothing collided.


Appendix

review webhook + subscriptions

What it doesReceives GitHub review/merge events over a Cloudflare tunnel, debounces them per-PR, then for every subscribed session writes a signal file and types a wake into that session's pane — so a session waiting on a review never polls for it
Kindreflex
Repohttps://github.com/PropFlow-Technologies/propflowai
Deployed at~/.claude/services/review-webhook/listener.js, subscriptions in ~/.claude/review-subs/, armed by ~/.local/bin/review-sub
Statusactive
Live on the Systems tabpropflowai.co/agents → Systems → review webhook + subscriptions

Liveness signals

SignalHow it is read
listener runninglaunchd · com.propflow.review-webhook
tunnel runninglaunchd · com.propflow.cloudflared-claude
events arrivinglog-match-age · ~/.claude/services/review-webhook/listener.log · matches event.in · stale after 360 min

Why this was not in the catalog until 2026-09-12. It predates the catalog and nobody added it, which is exactly the failure the registry exists to catch: review-turns forbids polling and points every session at this system, so it has been load-bearing for the whole fleet while being, by this file's own rule, a system that does not exist. It is listed now because review-sub was built against it.

The liveness signal is event.in, not a wake. Deliberate. Wakes are rare — they fire only when a subscribed session's PR gets a verdict — so keying liveness on one would read down through every quiet hour and up only by luck. event.in is logged for every delivery GitHub makes, including the ones the listener then skips, which is the question this row actually asks: is the tunnel still terminating and is the process still reading it. The 6h window is generous on purpose; a fleet that opens no PRs overnight is not a broken tunnel.

⚠️ A GREEN ROW HERE DOES NOT MEAN A WAITING SESSION WILL BE WOKEN, and that gap is the reason review-sub exists. The listener can be perfectly healthy while every subscription on disk is undeliverable — measured on 2026-09-12 across 98 live subscriptions: 56 with no wake target at all, 42 with a wake target and no record of who armed them, none routable. The cause was that a Driver's lane is an Agent-tool sub-agent with no pane of its own, so the arming block resolved its parent's pane and the wake landed on the Driver while the lane waited for a signal it could not receive.

The catalog cannot ask that question today, and an entry that implied it could would be worse than one that says so. evaluateSignal supports file-age, log-match-age, launchd and self — none reads a JSON field out of a directory of files. What would fill it: the same file-match type the lane-scratch entry above asks for, or a dir-predicate signal, at which point this row could assert every live subscription names an armer. Until then the answer is a command, not a row:

review-sub list      # every subscription, each diagnosed OK / UNATTRIBUTED / DEAF

DEAF is a subscription with no wake target: nothing types anywhere, and only a session still polling for the passive signal file would ever notice. DEAF and OK are told apart on purpose rather than both counted as "subscribed", for the same reason lane-scratch --check exits 4 rather than 0 on an absent ledger — nothing will be delivered must not render the same as nothing is wrong.

Appendix

replay-authored (stranded-tracker rescue)

What it doesReplays a STRANDED tracker snapshot onto the live board AUTHORED FIELDS ONLY — refs, keys and rows the board has lost — leaves every `status` for the next tick to re-derive, holds and names every conflict, reports by default and writes only under `--apply` through the same compare-and-swap the dock writers use
Kindpipeline
Repohttps://github.com/PropFlow-Technologies/propflow-docs
Deployed atpropflow-docs/bin/replay-authored → bin/tracker_rows.authored_replay + cas_write; run BY HAND, nothing schedules it
Statusproposed
Live on the Systems tabpropflowai.co/agents → Systems → replay-authored (stranded-tracker rescue)

Liveness signals

No liveness signals declared — the catalog renders this system Unknown until one is added. Unknown is not healthy.

What it is and who runs it. When bin/refresh-trackers (reclaim_stale_skips, the phase-dock-rotation entry above) finds a tracker wedged, it archives the dirty bytes and restores from HEAD; putting that archive back is a HUMAN step whose only fence was a log sentence, and a wholesale replay (cp, git apply) is a revert wearing a rescue's diff — propflow-docs 2e01f24d flipped p3-registry to merged with prs absent. A human or Driver runs this instead, after a re-derive, and reads the field-level list it prints (DERIVED-NOT-REPLAYED, HELD, REPLAYED) before --apply. pipeline per the fleet-e2e rule: run, not scheduled.

MANUAL, and proposed means the catalog cannot see it — not that it is unbuilt. Measured 2026-09-17 on origin/main: nothing invokes it (a git grep outside the tool and its test hits only a comment and the dock's own row text) and no launchd plist names it. No fixed-path record is written on --apply, and any age-based signal would read STALE exactly when no tracker has wedged — the trap the lane-scratch entry names. So signals is empty, the row renders UNKNOWN, and active is unreachable until a signal exists (relay/tests/systems.test.ts requires one). Nothing green here proves anything, which is the honest render (ADR-0009).

Contract, and the defect found in review. It tests key ABSENCE (key not in now), not truthiness: a differing PRESENT value is HELD whatever it is — "", false, 0, [] are amendments, not gaps. The first commit (9f005ae6) tested truthiness and wrote stale text over a deliberately cleared context; fixed in da9ee28d, with a falsifier that reverts that one branch. prs and canceled (LEDGER_KEYS) are never applied onto a row the board already has; an authored EDIT is held, not applied — resolving it needs a base two files lack.

Appendix