Every claim here is from a run that happened, not a practice we intend to adopt 2026-08-01
Goals, compacting, subagents, the loop.
What we learned running a fleet to completion — and the small number of rules that actually did the work.
19 REPORTS · 19 VERIFIED · 0 OVERCLAIMS
docs.propflowai.coOPERATING MANUAL
02 /PROBLEM
The failure that started all of it
A session can be sincerely wrong about being finished.
Not lying, not lazy — sincere. It evaluated its own goal, concluded it was met, and stopped with work still open. Three sessions did this today and only caught it by voluntarily re-checking.
Why it happens
The judge is the defendant
"Am I done?" is answered by the same model that decided what "done" means. There is no adversary in that loop. It will pass its own exam every time.
The fix
Two gates, not one
A soft gate the model reasons with, and a hard gate it cannot reason past. Neither alone is enough — and we tried both alone first.
The thing to internalise: "it said it was done" and "it is done" are two different measurements. Everything in this deck follows from keeping them apart.
3 sessions · self-caught, not gate-caught02
03 /GOALS
Gate 1 — soft
/goal is real, native, and weaker than it sounds.
What it is
A built-in command — "set a goal Claude checks before stopping." Type /goal finish X and post the PR and it registers a session hook.
What it does well
Keeps the objective in front of the model across a long turn. It genuinely stops drift.
⚠️ What it does not do
It does not survive the turn. It is a condition the model evaluates about itself — the exact measurement slide 2 says not to trust alone.
Use it anyway. A soft gate that catches the honest 80% is cheap and costs one line. Just never treat "it had a goal set" as evidence the goal was met.
/goal — native, per-turn03
04 /GATES
Gate 2 — hard
A Stop hook is a shell check the model cannot argue with.
A Stop hook returning {"decision":"block","reason":…} refuses the stop and hands the reason back as the next instruction. It lives in settings.json, so unlike /goal it persists across turns.
model says "done"
→
Stop hook runs
→
counts real files on disk
→
block + reason
The design rule
Measure a fact, not an opinion
Ours counts queue/*.txt — files that exist or don't. A gate that asks the model anything has re-entered the loop it was built to break.
The guard
stop_hook_active
Set on re-entry. Without checking it you can build an infinite stop→block→stop loop. Ours also caps at 6 consecutive blocks.
~/.claude/hooks/drive-gate.py04
05 /KILL SWITCH
Asked directly: "should we have a kill switch so we stop if we are stuck?"
Yes — and a gate that only blocks is a trap, not a gate.
01 · MANUAL
rm ~/.claude/jobs/DRIVE-GATE
Always works, no restart, no permission. If the only escape needs a working model, there is no escape.
02 · NO PROGRESS
Queue unchanged for 25 minutes
Stuck is not "slow" — stuck is not moving. Measure the delta, never the duration.
03 · DEAD WATCHER
Nothing is delivering
If the thing that drains the queue is dead, blocking cannot help. Blocking on an unreachable condition is just a hang with better manners.
04 · DEADLINE
180 minutes, unconditional
A ceiling a bug cannot argue past.
05 · NOT ACTIONABLE
Every target is mid-turn
Added last, after watching it hold a session whose only available action was wait. Block to force work, never to force patience.
any one release opens it · all print to stderr05
06 /KILL SWITCH
Two rules that outrank every other line in the hook
Fail open. And never let a release look like a finish.
Rule 1 — fail open
Any exception → exit 0
A blocking Stop hook is machine-wide. A typo in it does not break one session — it wedges every session on the machine, including the one you would use to fix it. The whole body is wrapped in a bare except: sys.exit(0), deliberately.
Rule 2 — be loud
Releases print to stderr
A silent auto-release is indistinguishable from clean completion — which would make the gate a machine for manufacturing false "done" reports. Every release writes drive-gate RELEASED: <why> into the transcript.
Scope it to one session. The marker file holds the session id it gates; every other session reads the marker, sees a different id, and exits 0 untouched. A global gate is a global outage.
fail open > fail safe, for anything machine-wide06
07 /COMPACT
The single highest-leverage command in a long run
A compact is a handoff — so write it like one.
Do
Name what must survive
/compact keep: the two-gate design, address by FULL UUID, Gera's open decisions #5140 + #5232
Specific nouns and numbers survive. "keep the important stuff" does not — it has no referent.
Don't
Wait until you are wedged
Past ~85% there may not be room to do the compact well. You cannot ask it to condense earlier than it decides to. Compact at a natural seam — a verified milestone — not at the ceiling.
Carry the ledger in a file, not in context. Everything we needed after this compact — 19 verified reports, the queue, the open decisions — was on disk. The compact only had to carry pointers. That is what made it survivable.
context is a cache; the file is the record07
08 /COMPACT
"If you can send messages to others, how could you not to yourself?"
Yes. It took three wrong conclusions to get there.
MISTAKE 1
Sent it mid-turn
It queued in the input box instead of running. A session cannot act on a slash command while it is the one executing. Send from a detached watcher that waits for idle.
MISTAKE 2
Concluded "self-compact is impossible"
One failure, one general law. The failure was timing; the law was invented.
MISTAKE 3
Sent it to a claude attach viewer
The pane named after the session held 0K/1M context. The real conversation sat in a pane named after nothing — claude-fresh-1785544698, at 842K/1M. "Not enough messages to compact" was true of the pane I picked.
The transferable lesson: a tmux session named after your session is not necessarily where your session lives. The human-friendly identifier is the unreliable one — every single time.
viewer ≠ process · check the token count first08
09 /COMMS
26 consecutive delivery failures, one root cause
Names are convenient. Only the full UUID is unambiguous.
'god' MATCHED
729
transcripts, newest-first
'cc4ad587' MATCHED
89
an 8-char prefix is not an id
FULL UUID
1
the only safe form
FAILURES
26
before we stopped guessing
The trap inside the trap
An all-digit sid8
04253809 was read as a picker row number, not an id. Fixed upstream to only treat <4-digit values as rows.
Why matching lies
It reads the first message only
The matcher searches the first user message of each transcript and returns the newest hit. Two live sessions can share a name — two sales-v2 did.
msg <full-uuid> · never a name, never a prefix09
10 /COMMS
The pane is the mailbox
tmux send-keys reaches a pane whether or not anyone is watching.
That one property is the whole architecture. Panes are mailboxes; clients are windows. Nobody has to be attached for a message to land, so the Agent view never has to be open for the fleet to coordinate.
write queue/<uuid>.txt
→
watcher polls 45s
→
target idle?
→
msg → pane
→
rm the file
Refuse, don't force
msgrejects a mid-turn target on purpose. Typing into a busy session lands text at a moment it did not choose — or silently answers a permission prompt.
One watcher, enforced
Two watchers both see idle and both send before either deletes the file → duplicate delivery. An mkdir lock caught a second watcher starting for real.
The queue is the state
Files on disk, not a list in context. It is what the hard gate counts, and what survives a compact.
queue + watcher + lock10
11 /SUBAGENTS
The line is not about difficulty — it is about reversibility
Delegate the reading. Never delegate the deciding.
Delegate freely
Anything you would undo with git checkout
Sweeping many files for one answer · reading a subsystem you only need the conclusion from · independent verification of a claim · drafting a fix.
The main thread pays almost nothing for this — you keep the conclusion, not the file dumps.
These go to Gera, by name, parked and labelled. A subagent has no standing to make them and no context to weigh them — and its confidence reads identically either way.
Two are parked right now and neither is delegable: #5140 bulk market-rent repricing, and #5232 the enforcement flip that arms real sends to residents. Both sit at 409 NOT_WIRED until a human says go.
reversibility is the test, not difficulty11
12 /HYGIENE
What 37 sessions carrying a dozen goals actually looks like
Duplicate sessions do not double the work. They halve it.
3×
delinquency-v5
Three live sessions on one goal, each unaware of the others' commits.
2×
saga-consolidate-v2
One wrote an 18KB test file into the other's worktree — uncommitted, on nobody's branch, invisible to both.
2×
check-coverage
Both twins analysed the same issue and reached contradictory conclusions. A third PR exists only to reconcile them.
The real cost is not a merge conflict — it is two coherent answers to one question. Twin A shipped analysis #5164; twin B shipped #5166; they disagreed; #5230 exists solely to merge them, and its title says so. Neither twin was wrong. Neither touched the other's files. Git had nothing to complain about — and the waste was visible only from outside both sessions.
The rule: one session per goal, its own worktree, an explicit do-not list. They report into one thread; that thread reports to you. You never open twelve sessions yourself.
a name collision is a merge conflict you cannot see12
13 /THE LENS
Seven independent sightings in one day — filed as reviewer lens #5227
The most expensive bug is a check that passes for the wrong reason.
01 · CI
Green on a lane that ran zero tests
A git fetch --depth=1 grafted the repo, killing the merge base --changed needs. Selecting nothing exits 0. #5159 merged, #5105 closed.
02 · CLI
"No answers saved yet"
Byte-identical output for failed to read and genuinely empty.
03 · SWEEP
"0 missing" from a scan that sees 5 of 7 doors
A correct count of an incomplete set. An upper bound wearing a total's clothes.
04 · GUARD
Three vacuous guards
Synthetic controls exercised the predicate; the bug was upstream in extraction. The guard was never reached.
05 · GIT
Squash-merge defeats --is-ancestor
And git diff main <branch> against a moved main. Both give confident false negatives on merged work.
06 · WAIT
A CI wait that returned instantly
Run just after a force-push, the check list is still empty — and zero pending checks is indistinguishable from all checks passed. Any predicate over a collection needs "the collection is populated" as a separate condition; all() over an empty set is vacuously true. Same defect as an empty grep, and as the zero-test CI lane above.
A second session hit six more of these independently — transcripts read as evidence of authorship, a broken grep read as a clean result, "no human turns" read as "did nothing". Written up separately as verification-learnings. Two sessions finding the same class on the same day is the argument for the lens.
also: stale `git status` · a handoff doc for deleted work13
14 /VERIFY
19 completion reports, checked one at a time against gh
Nineteen verified. Zero overclaims. That is not luck — it is the gate.
Ask a different instrument
The session says merged; gh pr view says MERGED. Never verify a claim with the tool that produced it.
Positive control
Break it on purpose. The break that stays green is the one that teaches you — it means a vacuous test, or dead code dressed as policy.
Live case today: an enforcement guard stayed green when its author unwired a door. It now derives door coverage instead of listing it.
Reward the self-correction
Six sessions retracted their own claims mid-report. One walked back a fabricated why inside a single message. That is the behaviour you want, not a fault.
Print the matches. Never trust the count. Three of us hit this independently today: a grep returning 0 is not evidence of absence, it is evidence about the grep — a backtick in the pattern, a forward-only window, a line wrap, a binary file. One session logged four confidently wrong verification greps in a row, each caught only by printing what actually matched.
Careful with negatives. 43 merged branches are still alive on origin — auto-delete is simply off. Reading a live branch as "the PR did not land" would be sighting #8: branch-exists answers was cleanup run, not did this merge.
tmp/VERIFIED.md · one row per report14
15 /RULES
The whole deck, on one slide
Ten rules that did the actual work.
Do
✓ Set /goaland a Stop hook — soft plus hard
✓ Make the hard gate count files, never opinions
✓ Give every gate a manual release that needs no model
✓ Print every auto-release to stderr
✓ Address sessions by full UUID
✓ Keep the ledger on disk so a compact only carries pointers
✓ Compact at a verified seam, not at the ceiling
✓ Verify with a different instrument than the claim came from
✓ Print what a search matched — never trust its count
✓ One goal, one session, one worktree
✓ Park money / customer / destructive calls with a human, by name
Don't
✗ Trust "I'm done" as a measurement
✗ Build a gate with no release — that is a hang
✗ Block a session whose only action is wait
✗ Let a Stop hook throw — it wedges the whole machine
✗ Address by name or 8-char prefix
✗ Force a message into a mid-turn session
✗ Run two watchers on one queue
✗ Draw a general law from one failure
✗ Read 0 results as "it isn't there"
✗ Assume a pane named after a session is that session
✗ Let a subagent decide anything you cannot undo
every line above cost us something today15
16 /THE LOOP
What "loop ourselves so we don't stop till we're done" actually became
Six steps, and a human at exactly one of them.
01
Queue the ask
One file per target, named with its full UUID. The queue is the state.
02
Watcher delivers on idle
Single watcher, lock-enforced. Refuses mid-turn targets rather than forcing them.
03
Session works, reports back by name
Report format: what shipped, what is open, what was deliberately not done and why.
04
Verify against gh, log the row
Different instrument, every time. Unverified is logged as unverified — never as done.
05
Stop hook refuses the stop while the queue is non-empty
…and releases itself the moment "still working" becomes "stuck".
06 · HUMAN
Capability-arming decisions go to Gera
The loop is designed to run unattended up to this line, and to stop dead at it.
autonomous to the arming decision, never through it16
17 /THE WAKE-UP
Two silent stalls, four hours, and neither had an error in it
The wake-up predicate is “is there work” — not “is the gate holding”.
01 · ASYMMETRY
A Stop hook can refuse to stop. It can never restart.
Once the turn genuinely ends there is nothing holding the loop open. The gate was working perfectly and was entirely irrelevant — the only thing that resumes a stopped session is an external send into its pane.
02 · COMPOSITION
The release that silenced the loop
A new gate release was correct in isolation. But the watcher only fired while the gate said HOLDING, so the moment it released, the loop went quiet: 34 consecutive declines over 1h47m, each logged “gate reports no registered work” while four items sat registered. Five of six releases are conditional — which is exactly when a wake-up matters most.
03 · SHARED STATE
A finishing child disarmed its coordinator
nudge --stop killed every watcher on the machine, and the child skill tells children to run it when they finish. The happy path was the bug. The log reads cancelled 3 — one child ending three sessions’ loops. No error anywhere; just a coordinator that never wakes up again.
04 · THE GAP
A compact is a stop
/compact is delivered when the session goes idle, and when it finishes the session is idle again with no turn behind it. Every watcher armed beforehand is gone, and the thing that would re-arm them has no turn to run in. Closed with a SessionStart hook — the only one that runs on the far side of the gap.
Every new guard needs its interaction with the wake-up checked, not just its own logic. Both stalls were composition failures between two individually-correct pieces. And both presented as “the loop finished”, never as “the loop is stuck”.
a stall has no symptom — that is the whole problem17
18 /GUARD POLARITY
Three of these in one day, two of them inside the fix for the first
A guard keyed on the thing that goes missing is not a guard.
Gated on the symptom
The publisher’s checks ran only if grep -q data-pf-state. A page that had LOST its bindings therefore skipped every check — including the one whose job was to report exactly that. 32 radios, 9 note boxes, zero bindings, and publishing printed a clean tick.
Asserting its own list
expect(SITES).toHaveLength(4) can only fail when someone edits the list it is checking. A fifth dial site already existed. Inverted to a tree-wide scan: naming this endpoint anywhere is a decision.
Deferred ≠ nothing failed
A lane that defers its selection still runs its repo-wide guards. Exiting 0 on “deferred” swallowed a red guard — and because the guards don’t re-run on the later verdict event, the approval would have patched the required check green over it.
Re-key on what makes the thing what it is, never on the attribute that disappears. The publisher now keys on <section class="q"> — what makes a page a pick-one page — instead of on the binding that goes missing.
The ratchet is the honest shape when the tree is already dirty. A repo-wide link guard found 29 pre-existing dead links. Failing the build would have reddened main for a mess it didn’t create; a clean gate was never going to land. Budget at the measured count, may only shrink. ⚠️ Then watch the ordering: a second PR carrying 5 new dead links would have taken the count past the budget and reddened main on merge, because the baseline was measured before those links existed.
ask: what would have to be true for this to pass while the claim is false?18
19 /LYING INSTRUMENTS
Every one of these was acted on before it was caught
Three readings that were confidently, precisely backwards.
01 · CONTEXT
“Three sessions haven’t compacted” — all three had
A compact leaves no fresh usage block, so the meter read the pre-compact number and reported 96 / 90 / 81%. The gate acts on that, so it kept ordering compacts from sessions that had just done one. Fixed by detecting a compact-summary newer than the last usage block.
02 · ROSTER
busy is not evidence of working
It means the process said it was in a turn. One session read busy for 63 hours with a completed handoff at the end of its transcript. Another read busy for 43 minutes having never started — its transcript held exactly one entry, the kickoff prompt.
03 · CI
Green having executed none of the PR’s tests
Affected selection: 695 files against a cap of 200 → the single-threaded run is skipped. The required check went green while two tests that contradicted the diff sat unrun. They would have failed on main.
The check on a check is a different instrument, not a closer reading of the same one. A stalled session and a working one are byte-identical in the roster; they differ in the transcript. A compacted session and a full one are identical in the last usage block; they differ in what comes after it.
read the transcript, not the status field19
20 /BLOCKING
The gate deadlocked against its own instruction
Block only on an action that can happen inside a turn.
The deadlock
The gate refused to stop while demanding a /compact — which is delivered by typing into the pane once the session goes idle. It was preventing the very thing it demanded, and would have spun until the worker timed out an hour later.
The tell
The visible symptom would have been “the loop kept going”, not “the loop is stuck”. A deadlock that looks like progress is worse than one that looks like a hang.
The rule
If the next action requires the turn to end — a compact, a human decision, an external event — the gate must release and the wake-up must carry it. Blocking is for work you can do right now.
The corollary, learned the same day: five of six releases are conditional (“let the turn end so something can be delivered”). A release is not a finish, and a watcher that treats it as one goes silent at exactly the moment the loop is most fragile.
a gate that only blocks is a trap — see slide 0420