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:
id— stable slug (the machine key; never rename casually)job— one sentence: what it does for the fleetkind—reflex(acts locally, no dependencies upward) |orchestration(drives goals, opt-in) |actor(an agent identity that uses the fleet) |pipeline(scheduled automation, or a check invoked by hand against a stated cadence — fleet-e2e is the member that is run, not scheduled)status—active|superseded|proposed. A superseded system stays listed withsupersededByuntil its removal ADR completes — legacy is a catalog state, not a vibe.signals— how the relay proves it's alive. Types:file-age(path + staleAfterS),log-match-age(last line matchingpatternmust be recent),launchd(label must be loaded and not crash-looping),self(the relay reporting on itself). An empty list renders as UNKNOWN, never as healthy — not proving liveness must never look like proving it.
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 does | Runs the maintenance, turnover and morpheus benches against the prod bench property every night, serialised so they cannot race |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/PropFlow-Technologies/propflowai |
| Deployed at | Temporal tools-prod :: NightlyQueueWorkflow |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → nightly eval queue |
Liveness signals
| Signal | How it is read |
|---|---|
| turnover scope-sync green | log-match-age · ~/Library/Logs/turnover-eval-scope-sync.out.log · matches VERDICT: ALL GREEN · stale after 1800 min |
| turnover multi-trade green | log-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
- https://github.com/PropFlow-Technologies/propflowai#readme — the README (no docs declared in SYSTEMS.md yet; add some)
host headroom + session cap
| What it does | Samples 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/host-headroom, ~/.local/bin/session-cap, ~/.local/bin/session-exit |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → host headroom + session cap |
Liveness signals
| Signal | How it is read |
|---|---|
| sampler scheduled | launchd · co.propflow.host-headroom |
| expiry scheduled | launchd · co.propflow.session-expire |
| sampler reporting | log-match-age · ~/.claude/jobs/host/host-headroom.log · matches HOST · stale after 30 min |
| snapshot fresh | file-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 afterreleases.ts, asks once, requests at most three times, then raisessession-will-not-exitfor 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 stopfor background,/exitin 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
- https://github.com/gera-propflow/local-bin#readme — the README (no docs declared in SYSTEMS.md yet; add some)
Fleet stability check
| What it does | Every 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 |
|---|---|
| Kind | reflex |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/fleet-stability |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → Fleet stability check |
Liveness signals
| Signal | How it is read |
|---|---|
| check scheduled | launchd · co.propflow.fleet-stability |
| check reporting | log-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
- https://github.com/gera-propflow/local-bin#readme — the README (no docs declared in SYSTEMS.md yet; add some)
ccswitch
| What it does | Credential 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 |
|---|---|
| Kind | reflex |
| Repo | https://github.com/PropFlow-Technologies/ccswitch |
| Deployed at | ~/.local/bin/ccswitch |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → ccswitch |
Liveness signals
| Signal | How it is read |
|---|---|
| pool cache fresh | file-age · ~/.claude/ccswitch/cache.json · stale after 60 min |
| session registrations flowing | file-age · ~/.claude/ccswitch/reg · stale after 60 min |
| rotation telemetry | file-age · ~/.claude/ccswitch/rotation.log · stale after 2880 min |
| account snapshot fresh | file-age · ~/.claude/ccswitch/snapshot.json · stale after 5 min |
| swappable sessions being born | file-age · ~/.claude/ccswitch/session-cfg · stale after 1440 min |
| subagent-hold guard consulted | file-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 execpicks 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 noCLAUDE_CODE_OAUTH_TOKENin its environment reads$CLAUDE_CONFIG_DIR/.credentials.jsonand 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.jsonin it.swap_target()answers the question fromps ewwof 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 byswap_bar: it refuses a destination thatpreflightgrades 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_sessionsis 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 inrotation.logwhen 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 inrotation.logsince the cutover — e.g. session01ad649fat 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 holddoes not hold a swap. It is read at exactly one call site, insidehard_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— ansdk-clisession born twelve seconds earlier whose first prompt wasls— crossed a switch line and was respawned, and in that same second the fleet's Operator9e9f7a53and all eleven of its subagent lanes wrote their last line and stopped forever. Claude Code exportsTMUX_PANEinto 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, andrespawn-pane -kkills that pane's whole process group. The hold did not fail; it was asked about the wrong session, andsubagents: 0on that line is a true answer about the probe. The@claude_session_idstamp 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 earlierclaude-archincident 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 athard_restart_nowand atwedge_heal, which respawns with-kwithout 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
switchline (#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 thanhard_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-drillis 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
unnamedand its sessions never register (#58, #60). A pane's SESSION id comes from the claude process's CHILDREN's environment (CLAUDE_CODE_SESSION_IDon 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
retiredmakes it stick (#58). The one remedy every label names isccswitch mint <email>— never/login, which rotates the refresh token under every session on the account. ⚠️mintitself was broken four ways until #107 (2026-09-12), and each one turns a correct paste into an unexplained failure:tty.setrawmeans 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 theunnamedstate 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 so — cannot 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 kind — swap_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
ccswitchand 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 — bareccswitchranks the BANK and puts its arrow on~/.zprofile-token's account;ccswitch leaseswas 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 runningccswitch— i.e. by landing on the fleet default. The34%and101%it reported weregera@propflowai.co's 5h and Fable rows, verbatim; the session was onpropflow.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 time —session_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, andlist/doctor/leases/preflightrelabelled 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 rotatehad not rotated, the answer was that it had been evaluated correctly and was blocked further down (the restart cap). And a session not launched throughccswitch exec— an interactiveclaudeon 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 —
sessionNamessay who claims an account,pssays 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/mainin BOTH directions. On 2026-09-03 it was 142 lines AHEAD (a hand-deployed feature in no commit) and onlydiffagainst 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 rawcpleft the 60s timer pointing at a path that does not exist (2026-09-07). ⚠️ AndCURRENTis a narrower claim than it reads as — it means the copy equals THIS machine's checkout. See the sunset gate above, where the Mac wasCURRENTon a checkout 6 commits behindorigin/mainand 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 does | The 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. |
|---|---|
| Kind | orchestration |
| Repo | https://github.com/PropFlow-Technologies/agentflow-relay |
| Deployed at | — |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → agent specialists |
Liveness signals
| Signal | How it is read |
|---|---|
| roster snapshot fresh | file-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 does | The 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. |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/driver-setup |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → driver-setup (the table a /driver run starts through) |
Liveness signals
| Signal | How it is read |
|---|---|
| run recently | log-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 does | Archives 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. |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/cleaner |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → cleaner (retire what finished) |
Liveness signals
| Signal | How it is read |
|---|---|
| run recently | log-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 does | Reads 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. |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/optimizer |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → optimizer (provable parallelism) |
Liveness signals
| Signal | How it is read |
|---|---|
| run recently | log-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 does | Recounts 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. |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/finisher |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → finisher (the finish line) |
Liveness signals
| Signal | How it is read |
|---|---|
| run recently | log-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 does | Writes 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. |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/updater |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → updater (the closing ref, so status can derive) |
Liveness signals
| Signal | How it is read |
|---|---|
| run recently | log-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 does | Posts 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. |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/slacker |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → slacker (announce a closed row, once) |
Liveness signals
| Signal | How it is read |
|---|---|
| run recently | log-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 does | When 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 |
|---|---|
| Kind | reflex |
| Repo | https://github.com/PropFlow-Technologies/agentflow |
| Deployed at | supervisor/src/redmain.ts (inside com.propflow.agentflow-supervisor) |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → red-main |
Liveness signals
| Signal | How it is read |
|---|---|
| sweep ticking | file-age · ~/.claude/jobs/red-main/status.json · stale after 5 min |
| supervisor loaded | launchd · 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.jsonbefore it opens asmith-fix/*-push-main-red-*branch and stands down in the alert thread when a live owner exists (agent-smithred_main_owner.py).supervise --red-mainprintsstatusLine(). 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
- https://github.com/PropFlow-Technologies/agentflow#readme — the README (no docs declared in SYSTEMS.md yet; add some)
nudge (idle watchers)
| What it does | Reflex — 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 |
|---|---|
| Kind | reflex |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/nudge |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → nudge (idle watchers) |
Liveness signals
| Signal | How it is read |
|---|---|
| watchers ticking | file-age · ~/.claude/jobs/nudge/nudge.log · stale after 30 min |
| a wake delivered recently | log-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 does | Reflex — 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 |
|---|---|
| Kind | reflex |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/watcher-doctor |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → watcher-doctor |
Liveness signals
| Signal | How it is read |
|---|---|
| job loaded | launchd · co.propflow.watcher-doctor |
| sweep completed | log-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 does | Pipeline — 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/fleet-bake |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → fleet-bake (week-long bake) |
Liveness signals
| Signal | How it is read |
|---|---|
| job loaded | launchd · co.propflow.fleet-bake |
| tick appended | file-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 does | Pipeline — 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/fleet-e2e |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → fleet-e2e (PITFALLS §0 as a command) |
Liveness signals
| Signal | How it is read |
|---|---|
| PASS within 7 days | file-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 does | Pipeline — 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/fleet-controls |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → fleet-controls (weekly control run) |
Liveness signals
| Signal | How it is read |
|---|---|
| job loaded | launchd · co.propflow.fleet-controls |
| a run recorded within 8 days | file-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 does | Pipeline — 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/relay-deploy |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → relay-deploy (the live branch, advanced safely) |
Liveness signals
| Signal | How it is read |
|---|---|
| sweeper scheduled | launchd · co.propflow.relay-deploy |
| sweep succeeding | log-match-age · ~/.claude/jobs/relay-deploy/relay-deploy.log · matches SWEEP OK · stale after 45 min |
| reading fresh | file-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
- https://github.com/gera-propflow/local-bin#readme — the README (no docs declared in SYSTEMS.md yet; add some)
fleet-deploy (seat versions, and the seats nobody can read)
| What it does | Reads 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/PropFlow-Technologies/agent-smith |
| Deployed at | smith-fleet-deploy (agent_smith/fleet_deploy.py) |
| Status | proposed |
| Live on the Systems tab | propflowai.co/agents → Systems → fleet-deploy (seat versions, and the seats nobody can read) |
Liveness signals
| Signal | How it is read |
|---|---|
| reader completing a pass | log-match-age · ~/.claude/jobs/fleet-deploy/fleet-deploy.log · matches FLEET-DEPLOY · stale after 65 min |
| roll-up fresh | file-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 does | Orchestration — 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 |
|---|---|
| Kind | orchestration |
| Repo | https://github.com/PropFlow-Technologies/agentflow |
| Deployed at | — |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → agentflow (Driver / Supervisor / relay) |
Liveness signals
| Signal | How it is read |
|---|---|
| relay serving | self |
| supervisor loaded | launchd · 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
- https://github.com/PropFlow-Technologies/agentflow#readme — the README (no docs declared in SYSTEMS.md yet; add some)
Session pulse (state classifier)
| What it does | One answer to "what is this session doing" — six states or null, with the wake path that decides between waiting and needs-kickoff |
|---|---|
| Kind | orchestration |
| Repo | https://github.com/PropFlow-Technologies/agentflow |
| Deployed at | relay/src/pulse/ |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → Session pulse (state classifier) |
Liveness signals
| Signal | How it is read |
|---|---|
| pulse snapshot fresh | file-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 does | Keeps CI code-review authenticated across all repos — probes the bank, ranks accounts by worst-axis headroom, writes GitHub secrets every 2h |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/dotclaude |
| Deployed at | ~/.claude/scripts/rotate-reviewer-tokens.sh |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → Reviewer token pipeline |
Liveness signals
| Signal | How it is read |
|---|---|
| cycle completed recently | log-match-age · ~/.claude/logs/reviewer-token-rotate.log · matches cycle done · stale after 180 min |
| launchd job loaded | launchd · 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
- https://github.com/gera-propflow/dotclaude#readme — the README (no docs declared in SYSTEMS.md yet; add some)
cerebrus (Clara's quality gate)
| What it does | Pipeline — 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/PropFlow-Technologies/cerebrus |
| Deployed at | vendored into propflowai/quality-gate-poc/, pinned by CEREBRUS_VERSION |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → cerebrus (Clara's quality gate) |
Liveness signals
| Signal | How it is read |
|---|---|
| checker scheduled | launchd · co.propflow.cerebrus-gate |
| gate green | log-match-age · ~/.claude/jobs/cerebrus/cerebrus-gate.log · matches GATE OK · stale after 120 min |
| reading fresh | file-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 calls —
llmCallsUsed 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 machine — cerebrus/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 does | Reflex — 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 |
|---|---|
| Kind | reflex |
| Repo | https://github.com/PropFlow-Technologies/propflowai |
| Deployed at | .github/workflows/auto-merge-all.yml rule 6 :: scripts/ci/decide-merge-evidence.sh |
| Status | proposed |
| Live on the Systems tab | propflowai.co/agents → Systems → merge-evidence gate |
Liveness signals
| Signal | How it is read |
|---|---|
| gate deciding | log-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
- https://github.com/PropFlow-Technologies/propflowai#readme — the README (no docs declared in SYSTEMS.md yet; add some)
decision blocks
| What it does | Carries 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 |
|---|---|
| Kind | pipeline |
| Repo | https://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) |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → decision blocks |
Liveness signals
| Signal | How it is read |
|---|---|
| sweep scheduled | launchd · co.propflow.decision-block-sweep |
| sweep completing | file-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
taskstamp 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 does | Answers 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 |
|---|---|
| Kind | pipeline |
| Repo | https://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) |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → decision ladder |
Liveness signals
| Signal | How it is read |
|---|---|
| canary scheduled | launchd · co.propflow.decision-ladder-canary |
| both model rungs reachable | log-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 does | Reads 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 |
|---|---|
| Kind | pipeline |
| Repo | https://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 |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → puller — the Driver's feed, and the decider |
Liveness signals
| Signal | How it is read |
|---|---|
| scheduled | launchd · co.propflow.puller |
| reading the fleet | log-match-age · ~/.claude/logs/puller.log · matches puller OK · stale after 15 min |
| digest fresh | file-age · ~/.claude/jobs/puller/digest.md · stale after 15 min |
| rulings reaching raisers | log-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 does | Launches 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 |
|---|---|
| Kind | pipeline |
| Repo | https://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 |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → astra-relaunch |
Liveness signals
| Signal | How it is read |
|---|---|
| launcher scheduled | launchd · co.propflow.astra-relaunch |
| the tick completed and read every source | log-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 does | The fleet's workplace actor — Slack presence, PR reviews, ticket intake |
|---|---|
| Kind | actor |
| Repo | https://github.com/PropFlow-Technologies/agent-smith |
| Deployed at | — |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → Agent Smith |
Liveness signals
| Signal | How it is read |
|---|---|
| worker loaded | launchd · co.propflow.smith-worker |
| slack socket loaded | launchd · 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
- https://github.com/PropFlow-Technologies/agent-smith#readme — the README (no docs declared in SYSTEMS.md yet; add some)
row fence
| What it does | Stops 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 |
|---|---|
| Kind | reflex |
| Repo | https://github.com/PropFlow-Technologies/agent-smith |
| Deployed at | in-process :: agent_smith.row_fence, consulted by smith-pr-drive and smith-task |
| Status | proposed |
| Live on the Systems tab | propflowai.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 forlog-match-age, but its only line isrow-sweep rollout-x: 3 rowsfrom 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. Butsmith-worker.err.loglines open with ANSI escape bytes and an ISO-8601 stamp (\x1b[2m2026-09-11T07:33:40.016278Z), andlog-match-agerequires the line to start withYYYY-MM-DD HH:MM:SSlocal. It would renderunknown("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.jsonis a glob, andfile-agedoes a singlestatSyncon 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_unnamedwill refuse the first Driver that is genuinely assigned an item.prompts.pyinstructs agents to runsmith-pr-drive addandsmith-task verdictwith 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-herein~/.local/binstill 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 claimis the door that works today (→driver-workflow assignper ADR-0025 §9; the binary is not renamed yet, and naming a command that does not exist on PATH is the exact defect thefinisherentry records).- ⚠️ A non-zero exit from an acquire means STATE UNKNOWN, never "nothing
happened". Observed 2026-09-11:
smith-row-lease acquireconnected, 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 andos._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 createfrom an interactive session is still unfenced.smith-pr-drive addruns after the PR exists.hooks/command_gate.pyis 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
- https://github.com/PropFlow-Technologies/agent-smith#readme — the README (no docs declared in SYSTEMS.md yet; add some)
Smith daily self-review
| What it does | Every 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/PropFlow-Technologies/agent-smith |
| Deployed at | Temporal tools-prod :: MorningQueueWorkflow step `smith-review` → SmithTaskWorkflow `smith-goal-<key>` on propflow-smith |
| Status | proposed |
| Live on the Systems tab | propflowai.co/agents → Systems → Smith daily self-review |
Liveness signals
| Signal | How it is read |
|---|---|
| dispatched this morning | log-match-age · ~/.claude/smith-state/self-review/dispatch.log · matches smith-review dispatched · stale after 1800 min |
| checkpoint recorded | file-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 does | When 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/PropFlow-Technologies/agent-smith |
| Deployed at | Temporal tools-prod :: OracleTurnWorkflow on propflow-smith (started by propflowai's grading seam) + MorningQueueWorkflow step oracle-nightly |
| Status | proposed |
| Live on the Systems tab | propflowai.co/agents → Systems → The Oracle |
Liveness signals
| Signal | How it is read |
|---|---|
| a low turn was explained | log-match-age · ~/.claude/smith-state/oracle/dispatch.log · matches oracle-turn · stale after 20160 min |
| morning line posted | log-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 does | Every 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/gera-propflow/dotclaude |
| Deployed at | ~/.claude/scripts/propflow-status/operator-heartbeat.py |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → Driver heartbeat (was: Operator heartbeat) |
Liveness signals
| Signal | How it is read |
|---|---|
| ticker loaded | launchd · co.propflow.operator-heartbeat |
| sweeping | log-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 does | Carries Agent Smith's already-derived phase table to the /agents board as a versioned per-task JSON sidecar — one resolver in Python, two surfaces |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/PropFlow-Technologies/agent-smith |
| Deployed at | ~/.claude/jobs/tasks/<slug>/phases.json |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → Phase snapshot transport |
Liveness signals
| Signal | How it is read |
|---|---|
| writer loaded | launchd · co.propflow.smith-worker |
| phase evidence recording | file-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 does | Keeps 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 |
|---|---|
| Kind | pipeline |
| Repo | https://github.com/PropFlow-Technologies/propflow-docs |
| Deployed at | propflow-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 |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → Phase dock rotation |
Liveness signals
| Signal | How it is read |
|---|---|
| rotation scheduled | launchd · co.propflow.tracker-refresh |
| rotation ticking | file-age · ~/Library/Logs/tracker-refresh.out.log · stale after 15 min |
| live deriver running | file-age · ~/Library/Caches/propflow-docs/live-lanes-state.json · stale after 15 min |
| dock fresh | file-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 call —
ADR-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 does | Reflex — 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 |
|---|---|
| Kind | reflex |
| Repo | https://github.com/gera-propflow/local-bin |
| Deployed at | ~/.local/bin/doctrine-pull :: launchd/co.propflow.doctrine-pull.plist |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → doctrine pull |
Liveness signals
| Signal | How it is read |
|---|---|
| job loaded | launchd · co.propflow.doctrine-pull |
| tick completing | log-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 ProcessType — Background 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
- https://github.com/gera-propflow/local-bin#readme — the README (no docs declared in SYSTEMS.md yet; add some)
slug owner
| What it does | Answers 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 |
|---|---|
| Kind | reflex |
| Repo | https://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 |
| Status | proposed |
| Live on the Systems tab | propflowai.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.txthas 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-hereproceeds 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
- https://github.com/gera-propflow/local-bin#readme — the README (no docs declared in SYSTEMS.md yet; add some)
lane scratch namespace
| What it does | Gives 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 |
|---|---|
| Kind | reflex |
| Repo | https://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 |
| Status | proposed |
| Live on the Systems tab | propflowai.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
- https://github.com/gera-propflow/local-bin#readme — the README (no docs declared in SYSTEMS.md yet; add some)
review webhook + subscriptions
| What it does | Receives 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 |
|---|---|
| Kind | reflex |
| Repo | https://github.com/PropFlow-Technologies/propflowai |
| Deployed at | ~/.claude/services/review-webhook/listener.js, subscriptions in ~/.claude/review-subs/, armed by ~/.local/bin/review-sub |
| Status | active |
| Live on the Systems tab | propflowai.co/agents → Systems → review webhook + subscriptions |
Liveness signals
| Signal | How it is read |
|---|---|
| listener running | launchd · com.propflow.review-webhook |
| tunnel running | launchd · com.propflow.cloudflared-claude |
| events arriving | log-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
- https://github.com/PropFlow-Technologies/propflowai#readme — the README (no docs declared in SYSTEMS.md yet; add some)