Fix #41: Stop hook — a builder cannot end its turn mid-phase - #46
Open
pseudoseed wants to merge 2 commits into
Open
Fix #41: Stop hook — a builder cannot end its turn mid-phase#46pseudoseed wants to merge 2 commits into
pseudoseed wants to merge 2 commits into
Conversation
A builder ends its turn mid-phase with nothing blocking it and nobody
notices for hours. Two incidents a week apart. The second one's last message
named the next phase it was about to start — and then did nothing:
- Phase 1 committed as e62d1c0; build and tests green
- Moving to phase 2, the seam measurement harness
- No action needed from you
It was not confused. It intended to continue. Its turn ended BECAUSE it
wrote that paragraph: the loop runs while the model emits tool calls and
terminates at its first response that is only prose. "Report, then keep
working" cannot happen in that order.
#40 rewrote porch's handoff box and the role doc to say this out loud, which
makes the mistake harder. It cannot make it impossible — documentation loads
into context and can be read past. This is enforcement: the harness runs the
hook whether or not the model remembers.
The guard rides the seam that already exists. buildWorktreeGuardFiles emits
worktree-write-guard.cjs plus a PreToolUse entry into every Claude builder
worktree at spawn; it now also emits phase-stop-guard.cjs and a Stop entry
into the SAME settings file, because two writers of one file is how one of
them silently loses.
Three departures from a hand-written version of this that has been sitting
unwired in a home directory since 2026-08-15:
It reads status.yaml rather than grepping `porch status` output. That
version matched on four gate phrases porch does not print, so it would have
nudged a builder parked at dev-approval to "do the next unit of work now" —
pushing it past a decision only a human may make. Display strings are a
contract nobody enforces; the state file's shape is fixed by porch's writer.
The project id is baked in at spawn from porch's OWN detectProjectIdFromCwd,
not a second copy of the rule. A guard that disagrees with porch about which
project it watches nudges about the wrong one.
Node, not bash: the old one needed /usr/bin/jq and a porch on PATH, and
adopter machines are not guaranteed either.
detectProjectIdFromCwd moves to a leaf module. Importing porch/state.js
reaches its module-load `promisify(execFile)`, and that new import edge
failed 24 doctor tests whose partial node:child_process mock has no
execFile — a failure in a file this change never touched.
24 new tests, ten allow-paths to one block-path. That ratio is the design: a
guard that fails to block costs one idle builder, and a guard that blocks
wrongly can trap a session or shove a builder through a human gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review found this by running the emitted script against a real committed
status.yaml instead of reading the diff, and it is total: the hook allowed
every stop in production.
createInitialState (state.ts:228-231) pre-seeds EVERY gate in the protocol as
{ status: 'pending' } at project creation, before any has been reached. The
guard treated any `pending` gate as "a human is waiting", so from the moment
a project exists there was always a pending gate and it always allowed.
BUGFIX has exactly one gate, making this a no-op for that entire protocol.
For SPIR, `pr` and `verify-approval` sit pending through the whole of
`implement` -- which is where both reported incidents happened. The second
incident, the one that motivated the issue, would not have been caught.
A gate now counts as waiting only with BOTH `status: pending` AND
`requested_at`. That is porch's own predicate, used verbatim at index.ts:383
and :1080 to decide "WAITING FOR HUMAN APPROVAL", and requested_at is set
only by requestGate. Matching it rather than inventing a second definition of
"waiting" is the point. The scanner closes each gate block so a `pending` on
one gate cannot pair with a `requested_at` on another.
Why 24 green tests missed it: MID_PHASE was hand-typed and carried a single
approved gate, a shape porch never writes. The fixture is now produced by
createInitialState run through porch's own YAML writer, and two tests copy
REAL committed status.yaml files out of this repo -- the check the reviewer
performed, now in the suite. Both would have failed before this commit.
Removed buildPhaseStopGuardFiles, exported and called by nothing; the
`void worktreePath;` on an unused parameter was the tell.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #41.
The failure
A builder ends its turn mid-phase with nothing blocking it. Nobody notices until a human opens the pane — routinely hours. Two incidents, a week apart. The second one's final message:
Then nothing. It was not confused about what to do next; it named the next phase.
Its turn ended because it wrote that paragraph. The agentic loop runs while the model emits tool calls and terminates at the first response that is only prose. So "report, then keep working" cannot happen in that order — reporting is the stop. The builder does not experience those as one act.
#40 rewrote porch's handoff box and the builder role doc to say this out loud. That makes the mistake harder; it cannot make it impossible, because documentation loads into context and can be read past. This is the enforcement half: the harness runs the hook whether or not the model remembers.
Where it plugs in
buildWorktreeGuardFilesalready writes.claude/hooks/worktree-write-guard.cjsand aPreToolUseentry into every Claude builder worktree at spawn (cluesmith#1018). It now also writesphase-stop-guard.cjsand aStopentry into the samesettings.local.json. One function owns that file — two writers of one file is how one of them silently loses.Three departures from the version that was already on disk
A hand-written bash version of this guard has been sitting at
~/.claude/hooks/porch-phase-guard.shsince 2026-08-15, never wired into any settings file. Its header explains the reasoning well and it was right about the mechanism. Three things had to change to ship it:1. It reads
status.yaml, notporch statusoutput. The bash version's gate check grepped forawaiting (approval|review)|gate-requested|ready for approval|approval required. porch printsWAITING FOR HUMAN APPROVAL,Do not proceed until gate is approved., andGATE REQUIRED:— none of which match. As written it would have nudged a builder parked atdev-approvalto "do the next unit of work now", pushing it past a decision only a human may make. Display strings are a contract between two programs that nothing enforces; the state file's shape is fixed by porch's own writer.2. The project id is baked in at spawn, from porch's own
detectProjectIdFromCwd. Not a second copy of the regex. A guard that disagrees with porch about which project it is watching nudges about a phase that is not open.3. Node, not bash. The old one needed
/usr/bin/jqand aporchonPATH. Adopter machines are guaranteed neither.It also adds
verifiedto the terminal-phase list. That is porch's own terminal phase, so every completed project was earning one pointless nudge.Why
detectProjectIdFromCwdmovedImporting
commands/porch/state.jsreaches its module-loadpromisify(execFile). Adding that import edge fromagent-farm/utils/failed 24 tests indoctor.test.ts— a file this change never touches — because its partialnode:child_processmock has noexecFile:The pure path-to-id rule now lives in
commands/porch/project-id.ts, which imports onlynode:path.state.tsimports and re-exports it, so no existing importer changes. (Import and re-export:export { x } fromdoes not bindxin the re-exporting module's scope, andresolveProjectIdcalls it locally — 6state.test.tsfailures said so.)Tests
24 new, and the ratio is deliberate: ten allow-paths to one block-path. A guard that fails to block costs one idle builder. A guard that blocks wrongly can trap a session in a loop it cannot talk its way out of, or shove a builder through a human approval gate.
Allow:
stop_hook_activeset · each of the six terminal phases · any gatepending· unreadable status file · id matching no project dir · missing env vars · malformed stdin · empty stdin · nophasekey · exit 0 on every one of them.Block: mid-phase, no gate pending. And the nudge is checked for content, not just presence — it must name the phase, explain the mechanism (
IS the act of ending the turn), point atafx send architectas the non-turn-ending alternative, and say it will not block twice.Also pinned: project
77must not match7713(the zero-strip collision from #9), and an unrecognized worktree path installs no Stop hook while still installing the write-guard.Full runs: 575 passed across
doctor+ both guards + all ofsrc/commands/porch; 3286 passed / 34 skipped acrosssrc/agent-farm.Rollout
@cluesmith/codevis a global npm symlink into this tree, and none of the other repos (entriq, negeq, pseudoapps, pseudofi-v2, verbalytics) has a local install — so onepnpm build && pnpm local-installhere reaches all of them on their next builder spawn. No per-repo work.Already-running builders are unaffected; the hook is written at spawn.
Scope
This catches a builder that ends its turn. It cannot see a builder whose process died, or one wedged inside a turn that never ends. Those still want an idle watchdog, which #41 now describes as the backstop rather than the primary mechanism.
🤖 Generated with Claude Code