Skip to content

Fix #41: Stop hook — a builder cannot end its turn mid-phase - #46

Open
pseudoseed wants to merge 2 commits into
mainfrom
fix/41-phase-stop-guard
Open

Fix #41: Stop hook — a builder cannot end its turn mid-phase#46
pseudoseed wants to merge 2 commits into
mainfrom
fix/41-phase-stop-guard

Conversation

@pseudoseed

Copy link
Copy Markdown
Owner

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:

  • Phase 1 committed as e62d1c0; build and tests green
  • Moving to phase 2, the seam measurement harness, which is the one that can be host-blocked
  • No action needed from you

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

buildWorktreeGuardFiles already writes .claude/hooks/worktree-write-guard.cjs and a PreToolUse entry into every Claude builder worktree at spawn (cluesmith#1018). It now also writes phase-stop-guard.cjs and a Stop entry into the same settings.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.sh since 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, not porch status output. The bash version's gate check grepped for awaiting (approval|review)|gate-requested|ready for approval|approval required. porch prints WAITING FOR HUMAN APPROVAL, Do not proceed until gate is approved., and GATE REQUIRED: — none of which match. As written 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 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/jq and a porch on PATH. Adopter machines are guaranteed neither.

It also adds verified to the terminal-phase list. That is porch's own terminal phase, so every completed project was earning one pointless nudge.

Why detectProjectIdFromCwd moved

Importing commands/porch/state.js reaches its module-load promisify(execFile). Adding that import edge from agent-farm/utils/ failed 24 tests in doctor.test.ts — a file this change never touches — because its partial node:child_process mock has no execFile:

Error: [vitest] No "execFile" export is defined on the "node:child_process" mock.

The pure path-to-id rule now lives in commands/porch/project-id.ts, which imports only node:path. state.ts imports and re-exports it, so no existing importer changes. (Import and re-export: export { x } from does not bind x in the re-exporting module's scope, and resolveProjectId calls it locally — 6 state.test.ts failures 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_active set · each of the six terminal phases · any gate pending · unreadable status file · id matching no project dir · missing env vars · malformed stdin · empty stdin · no phase key · 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 at afx send architect as the non-turn-ending alternative, and say it will not block twice.

Also pinned: project 77 must not match 7713 (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 of src/commands/porch; 3286 passed / 34 skipped across src/agent-farm.

Rollout

@cluesmith/codev is a global npm symlink into this tree, and none of the other repos (entriq, negeq, pseudoapps, pseudofi-v2, verbalytics) has a local install — so one pnpm build && pnpm local-install here 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

pseudoseed and others added 2 commits August 22, 2026 13:57
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No stall detection: an idle builder mid-phase is invisible until a human opens the pane

1 participant