Repository navigation
fix(#5182): watch a GSD-owned return receipt instead of the host transcript in the plan-phase stall watch - #5192
Conversation
… not argv, so Windows MSYS cannot strip the quote Windows conformance shard 1/3 on PR open-gsd#5192 failed the gsd_receipt_path unsafe-phase-dir row: the "ph'ase" argument reached bash as "phase" because Git Bash's MSYS layer re-splits and unescapes argv. The helper was right to accept "phase". Read the value from a file inside the script instead, the same transport hazard extractStallHelpersBash() documents.
trek-e
left a comment
There was a problem hiding this comment.
Review — fix(#5182): watch a GSD-owned return receipt instead of the host transcript in the plan-phase stall watch
Head reviewed: 47b8993 (fresh gh pr view this turn: mergeable=CONFLICTING, mergeStateStatus=DIRTY, reviewDecision=REVIEW_REQUIRED, no reviews/comments).
Summary
Replaces the host {outputFile} grep in gsd_stall_watch with a per-dispatch GSD-owned receipt (gsd_receipt_path, gsd_return_marker), wires all five watch sites, passes revision markers, and adds agent receipt rules. Substance is sound; the branch conflicts with origin/next and the new parser helper has no property test.
Classification & gate compliance
- Issue #5182: OPEN, labels
bug,area: workflow,confirmed-bug-> Bug-Fix track. Body hasFixes #5182; not draft; branchfix/5182-stall-watch-return-receipt; titlefix(#5182):. - Changeset
.changeset/lively-sloths-forage.md: type Fixed, pr: 5192, body format OK. No docs/ requirement (Fixed). - PR label is only
area: workflow(issue carries the gate label; fine). - No competing open PRs for #5182.
- Acceptance criteria (verbatim from trek-e brief): AC1 markers only in prompt/definition/transcript -> waiting (test row AC1); AC2 checker issues -> revision (row AC2); AC3 finished revision -> success within one poll (row AC3, revision site now passes
## REVISION COMPLETE/## REVISION_CONFLICT, plan-phase.md ~L1193); AC4 never-signals -> stalled (row AC4); AC5 identical on Claude Code vs no-output host (row AC5; receipt is host-independent, but see Finding 3 for the read-only checker host); AC6 fail-first (PR body reports 20 failing rows on untouched next; not executed here per brief).
Functional checklist / root-cause verdict
Root cause (one sentence): gsd_stall_watch grepped the whole host transcript, which already contains the prompt/definition quoting the markers, and the revision site passed no markers. Fix addresses the input to the watch (single owner gsd_return_marker used by watch and step 11), not the symptom. gsd_stall_should_recover unchanged. Verified in stall-detection-helpers.md (helpers ~L152-L195) and plan-phase.md steps 8/10/11/12.
Blast radius + risk tier
Shipped-prose + bash-in-markdown change; consumers are the five plan-phase watch sites, two agent definitions, agent-contracts registry. Tier: medium (runtime-loaded workflow/agent text, size-capped files). compact-content-benchmark-baseline.json refreshed for plan-phase growth.
Co-change completeness
No known co-change gap beyond the conflicting baseline fixture. agent-contracts.md updated for ## REVISION COMPLETE. CONTEXT.md has no gsd_stall_watch entry (grep: 0 hits), so no glossary gap is implied.
Memtrace Evidence
Memtrace is over quota (quota_exceeded) per orchestrator brief; get_impact, get_symbol_context, recall_decision, find_code_review_issues were NOT run (no result obtained; this is not a clean result). Evidence came from git diff/git merge-tree and direct reads of the PR head in the worktree. Co-change (get_cochange_context) not run. Changed symbols are bash helpers inside markdown (not graph-indexed), so graph impact would be limited regardless.
Findings
Blocker
- [judgment] Merge conflict with
origin/next(behind by 13):git merge-tree --write-tree --name-onlyexit 1, conflicting pathtests/fixtures/compact-content-benchmark-baseline.json(plan-phase offTokens/onTokens/aggregate at lines ~28-45).nextmovedplan-phase.mdafter the PR's measurement (e.g. 84b4dfe #5171, 7586af3 #5044). Fix:git fetch origin && git rebase origin/next, regenerate the baseline against the rebasedplan-phase.md, re-verify.
Major
2. [judgment] tests/plan-phase-stall-detection.test.cjs (new describe at ~L940-1200): gsd_return_marker is a new parser (line-start literal, word boundary, CR/BOM tolerance) and gsd_receipt_path a new sanitizer; the test file imports fast-check (L49) but the new rows use only example cases. RULESET.TESTS requires at least one discriminating fast-check property for parsers/bijective contracts. Add a property: for arbitrary lines/prefixes, a marker matches iff the line starts with it and the next char is not [A-Za-z0-9_]; and gsd_receipt_path output never escapes .gsd-returns/ for arbitrary spawn labels.
3. [judgment] AC5 / acceptance criterion partially unmet for the checker on read-only hosts: gsd-plan-checker writes its receipt via Bash printf; on a host that runs it read-only (Codex derives read-only from tools:) the write is refused and the checker watch ends stalled (agents/gsd-plan-checker.md ~L957-962; stall-detection-helpers.md "Plan-checker on a read-only host"). The PR discloses this and defers the Group B decision to the maintainer; it is a maintainer decision (must be raised in chat), but until decided the criterion "identical on every host" is not met for the checker.
Minor
4. [judgment] Boundary coverage: AC4 row tests elapsed 9 min and 11 min against a 10 min threshold but not the limit itself (10 min) — add limit case (limit-1/limit/limit+1). Rows derive times from Date.now() (nowSec() helper) rather than a clock seam; offsets are coarse so not flaky, but it is wall-clock-derived input.
Nit
5. [judgment] Several new rows assert prose via assert.match on workflow/agent markdown (e.g. "never counted as 0 issues", rm -f "{receipt}"); these are prose-contract checks, acceptable but brittle to rewording.
Verdict
Changes requested (blocker: merge conflict; major: no property test; maintainer decision pending on checker read-only host). CI: all 36 check-runs for the head are success/skipped, pending approval runs=0 — scoped to the CI result only; mergeStateStatus is DIRTY.
… not argv, so Windows MSYS cannot strip the quote Windows conformance shard 1/3 on PR open-gsd#5192 failed the gsd_receipt_path unsafe-phase-dir row: the "ph'ase" argument reached bash as "phase" because Git Bash's MSYS layer re-splits and unescapes argv. The helper was right to accept "phase". Read the value from a file inside the script instead, the same transport hazard extractStallHelpersBash() documents.
…and make the receipt helpers locale-proof Review follow-up on open-gsd#5192. - Rebased onto origin/next 47b22b8. next grew plan-phase.md to 98,051 LF bytes (open-gsd#5171), so the receipt binding is now stated once at step 7.99 (`{receipt}` = gsd_receipt_path "${PHASE_DIR}" <spawn>) and each watch site carries only its spawn label. Step 11 keeps its original marker_received bullets; the rule that the routing marker comes from gsd_return_marker over {receipt} moves into the helpers doc that 7.99 loads. plan-phase.md is 98,273 LF bytes (cap < 98,300). - gsd_receipt_path and gsd_return_marker spell out their ASCII word characters instead of bracket ranges, so no locale (bash 3.2 has no globasciiranges) can widen them. - gsd_return_marker drops its trailing-CR strip: a CR after the marker is already a non-word character, so the strip changed no result (a mutation of it survived the new property test). - The compact-content benchmark baseline is regenerated against the rebased plan-phase.md (`benchmark-compact-content.cjs --write`), not hand-merged.
…s, AC4 boundary rows and token-level prose asserts Review follow-up on open-gsd#5192. - Property: gsd_return_marker matches iff the line starts with the marker and the next character is not [A-Za-z0-9_], with a leading BOM, a trailing CR and a missing final newline tolerated. Property: gsd_receipt_path never leaves <phase>/.gsd-returns/ for any spawn label, and an unsafe phase dir (quote, $ or backtick) fails closed. Each runs one bash invocation per generated batch, with values passed through files, never argv. - AC4 rows now cover limit-1 / limit / limit+1 (599 / 600 / 601 s) against an injected clock (`date +%s` stubbed); no open-gsd#5182 row reads the wall clock. - Prose-contract asserts are reduced to command tokens (`rm -f "{receipt}"`, `gsd_return_marker "{receipt}"`, `<return_receipt>`, printf) plus one keyword, so rewording the surrounding prose does not break the suite.
47b8993 to
345d13e
Compare
|
Thanks for the review. Addressed at 1. Merge conflict (blocker). Rebased onto The rebase also put 2. Property tests (major). Added two
Each property run is one bash invocation over a whole generated batch. Since Red proof against deliberately broken helpers (each break reverted, file verified byte-identical afterwards):
A fifth break, removing the trailing-CR strip, survived. That was correct: a CR after the marker is already a non-word character, so the strip never changed a result. I removed it as dead code in The same commit spells out the ASCII word characters in both helpers instead of using bracket ranges, so no locale can widen them (bash 3.2 has no 3. Checker on a read-only host (your call). Two ways to close it:
I'd prefer the follow-up. Moving the checker to Group B widens its security posture: on Codex, 4. Threshold boundary (minor). The AC4 rows now cover 5. Prose-contract asserts (nit). Agreed. I removed the asserts that were pure prose ("last action", "only write", "never counted as 0 issues", "nothing persists across fences", the completion-result wording). The rest now anchor on command tokens the orchestrator or agent actually executes: CI on
|
… not argv, so Windows MSYS cannot strip the quote Windows conformance shard 1/3 on PR open-gsd#5192 failed the gsd_receipt_path unsafe-phase-dir row: the "ph'ase" argument reached bash as "phase" because Git Bash's MSYS layer re-splits and unescapes argv. The helper was right to accept "phase". Read the value from a file inside the script instead, the same transport hazard extractStallHelpersBash() documents.
…and make the receipt helpers locale-proof Review follow-up on open-gsd#5192. - Rebased onto origin/next 47b22b8. next grew plan-phase.md to 98,051 LF bytes (open-gsd#5171), so the receipt binding is now stated once at step 7.99 (`{receipt}` = gsd_receipt_path "${PHASE_DIR}" <spawn>) and each watch site carries only its spawn label. Step 11 keeps its original marker_received bullets; the rule that the routing marker comes from gsd_return_marker over {receipt} moves into the helpers doc that 7.99 loads. plan-phase.md is 98,273 LF bytes (cap < 98,300). - gsd_receipt_path and gsd_return_marker spell out their ASCII word characters instead of bracket ranges, so no locale (bash 3.2 has no globasciiranges) can widen them. - gsd_return_marker drops its trailing-CR strip: a CR after the marker is already a non-word character, so the strip changed no result (a mutation of it survived the new property test). - The compact-content benchmark baseline is regenerated against the rebased plan-phase.md (`benchmark-compact-content.cjs --write`), not hand-merged.
57aab6e to
08ddb32
Compare
…s, AC4 boundary rows and token-level prose asserts Review follow-up on open-gsd#5192. - Property: gsd_return_marker matches iff the line starts with the marker and the next character is not [A-Za-z0-9_], with a leading BOM, a trailing CR and a missing final newline tolerated. Property: gsd_receipt_path never leaves <phase>/.gsd-returns/ for any spawn label, and an unsafe phase dir (quote, $ or backtick) fails closed. Each runs one bash invocation per generated batch, with values passed through files, never argv. - AC4 rows now cover limit-1 / limit / limit+1 (599 / 600 / 601 s) against an injected clock (`date +%s` stubbed); no open-gsd#5182 row reads the wall clock. - Prose-contract asserts are reduced to command tokens (`rm -f "{receipt}"`, `gsd_return_marker "{receipt}"`, `<return_receipt>`, printf) plus one keyword, so rewording the surrounding prose does not break the suite.
|
Rebased onto Local on the new head: Finding 3 (move gsd-plan-checker to Group B here, or a follow-up issue) still needs your call; see my 10-03 reply.
|
trek-e
left a comment
There was a problem hiding this comment.
Review — fix(#5182): watch a GSD-owned return receipt instead of the host transcript in the plan-phase stall watch
Head reviewed: 08ddb32 (mergeable=MERGEABLE, mergeStateStatus=BEHIND by 1, reviewDecision=CHANGES_REQUESTED).
Summary
Replaces the host {outputFile} grep in gsd_stall_watch with a per-dispatch GSD-owned receipt (gsd_receipt_path, gsd_return_marker), wires all five watch sites, passes revision markers, and adds agent receipt rules. Prior findings 1, 2, 4 and 5 are fixed at this head. Finding 3 (checker on a read-only host) remains open pending a maintainer decision, and one new Minor test finding is added.
Classification & gate compliance
- #5182: OPEN, labels
bug,area: workflow,confirmed-bug-> bug-fix track.Fixes #5182present, not draft, conventional title, branchfix/5182-stall-watch-return-receipt. - Changeset
.changeset/lively-sloths-forage.md: type Fixed, pr 5192, format OK. Docs exempt (Fixed). CHANGELOG untouched. No new references/ or workflows/ files. Emitted-Drift-Ack-Growthtrailers present for plan-phase.md, gsd-planner.md, gsd-plan-checker.md.- No competing open PR for #5182.
Prior-finding resolution (trek-e, 2026-10-03)
| # | Finding | Status |
|---|---|---|
| 1 Blocker | baseline-fixture merge conflict | Resolved; merge of origin/next and head is clean |
| 2 Major | no fast-check property | Resolved; two oracle-backed properties |
| 3 Major | checker on read-only host / AC5 | OPEN (maintainer decision) |
| 4 Minor | boundary triple, wall clock | Resolved; 599/600/601 s with stubbed date +%s |
| 5 Nit | prose asserts | Resolved |
Functional checklist / root-cause verdict
Root cause: gsd_stall_watch grepped the whole host transcript, which already quotes the markers via prompt/definition, and the revision site passed no markers. The fix changes the watch's input to a GSD-owned file and uses one marker matcher shared with step 11 routing; gsd_stall_should_recover is untouched. Root-cause fix, not symptom handling.
AC1 pass, AC2 pass, AC3 pass, AC4 pass (limit-1/limit/limit+1), AC5 partial (Finding 1), AC6 per PR body (20 failing rows on next; not executed here).
Blast radius
Shipped prose and bash-in-markdown: five plan-phase watch sites, gsd-planner, gsd-plan-checker, agent-contracts registry. Argument 2 of gsd_stall_watch changes meaning (receipt, not host output file); only the five plan-phase sites call it. Risk: medium (runtime-loaded text, size-capped; growth acked by trailers).
Co-change completeness
agent-contracts.md updated for ## REVISION COMPLETE; benchmark baseline regenerated. No missing INVENTORY, CONTEXT or docs requirement identified.
CodeGraph Evidence
codegraph_impacttargetgsd_stall_watch(base index): 2 affected symbols, both in tests/plan-phase-stall-detection.test.cjs (extractStallHelpersBash). Shell helpers inside markdown are not graph-indexed, so this is a lower bound.codegraph_callerstargetgsd_return_marker: symbol not found (new in this PR, absent from base index).codegraph_nodetargetgsd_stall_should_recover: only the test wrapperrunShouldRecoveris indexed; the function body is unchanged by this PR.
Memtrace unavailable: monthly query quota exhausted (20972/20933, resets 2026-10-20)
Findings
Major
- [judgment, carried from prior review #3]
agents/gsd-plan-checker.md:962-966,gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md("Plan-checker on a read-only host"): on a host that runs the checker read-only (Codex derivesread-onlyfromtools:) the receiptprintfis refused and the checker watch endsstalled, so "identical on every host" (AC5) is not met for the checker. The contributor asked twice for a decision (Group B move here vs follow-up issue); it needs a maintainer answer in chat before approval.
Minor
2. [judgment] tests/plan-phase-stall-detection.test.cjs, AC5 row (bind()): once the call site binds {receipt}, both "hosts" receive f.receipt, so the comparison is between identical inputs and cannot fail. Fix: supply a transcript file and a missing file as argument 2 and assert neither is consulted, or remove the row.
Nit
3. [judgment] fast-check numRuns of 10/8/6 is small; acceptable given per-run batching.
4. [judgment] stall-detection-helpers.md ("The runtime's completion result ends the wait"): only checker ## ISSUES FOUND has an explicit fail-closed route when the receipt arrives without a body; planner receipt-only ## CHECKPOINT REACHED / ## PHASE SPLIT RECOMMENDED have no stated step-9 behavior.
Verdict
Request changes (Finding 1 open, Finding 2 Minor). CI at head: 36 check-runs, 32 success / 4 skipped, 0 red, 0 pending-approval runs. Rebase is optional (behind by 1, docs-only chore, clean): git fetch origin next && git rebase origin/next.
… not argv, so Windows MSYS cannot strip the quote Windows conformance shard 1/3 on PR open-gsd#5192 failed the gsd_receipt_path unsafe-phase-dir row: the "ph'ase" argument reached bash as "phase" because Git Bash's MSYS layer re-splits and unescapes argv. The helper was right to accept "phase". Read the value from a file inside the script instead, the same transport hazard extractStallHelpersBash() documents.
…and make the receipt helpers locale-proof Review follow-up on open-gsd#5192. - Rebased onto origin/next 47b22b8. next grew plan-phase.md to 98,051 LF bytes (open-gsd#5171), so the receipt binding is now stated once at step 7.99 (`{receipt}` = gsd_receipt_path "${PHASE_DIR}" <spawn>) and each watch site carries only its spawn label. Step 11 keeps its original marker_received bullets; the rule that the routing marker comes from gsd_return_marker over {receipt} moves into the helpers doc that 7.99 loads. plan-phase.md is 98,273 LF bytes (cap < 98,300). - gsd_receipt_path and gsd_return_marker spell out their ASCII word characters instead of bracket ranges, so no locale (bash 3.2 has no globasciiranges) can widen them. - gsd_return_marker drops its trailing-CR strip: a CR after the marker is already a non-word character, so the strip changed no result (a mutation of it survived the new property test). - The compact-content benchmark baseline is regenerated against the rebased plan-phase.md (`benchmark-compact-content.cjs --write`), not hand-merged.
08ddb32 to
48ee141
Compare
…s, AC4 boundary rows and token-level prose asserts Review follow-up on open-gsd#5192. - Property: gsd_return_marker matches iff the line starts with the marker and the next character is not [A-Za-z0-9_], with a leading BOM, a trailing CR and a missing final newline tolerated. Property: gsd_receipt_path never leaves <phase>/.gsd-returns/ for any spawn label, and an unsafe phase dir (quote, $ or backtick) fails closed. Each runs one bash invocation per generated batch, with values passed through files, never argv. - AC4 rows now cover limit-1 / limit / limit+1 (599 / 600 / 601 s) against an injected clock (`date +%s` stubbed); no open-gsd#5182 row reads the wall clock. - Prose-contract asserts are reduced to command tokens (`rm -f "{receipt}"`, `gsd_return_marker "{receipt}"`, `<return_receipt>`, printf) plus one keyword, so rewording the surrounding prose does not break the suite.
|
@trek-e thanks. I rebased onto
Re-requested your review. Verification (local, head 48ee141)
|
|
Maintainer decision on the open question (review finding 3, AC5): the plan-checker return-receipt write on read-only hosts must be fixed in this PR. AC5 is a must-have acceptance criterion, so it is not deferred to a follow-up and the closing keyword stays. The Major finding remains open until the checker watch works on a read-only host; the Minor and Nits from the latest review also still stand. |
… not argv, so Windows MSYS cannot strip the quote Windows conformance shard 1/3 on PR open-gsd#5192 failed the gsd_receipt_path unsafe-phase-dir row: the "ph'ase" argument reached bash as "phase" because Git Bash's MSYS layer re-splits and unescapes argv. The helper was right to accept "phase". Read the value from a file inside the script instead, the same transport hazard extractStallHelpersBash() documents.
1b8d051 to
9441faf
Compare
… not argv, so Windows MSYS cannot strip the quote Windows conformance shard 1/3 on PR open-gsd#5192 failed the gsd_receipt_path unsafe-phase-dir row: the "ph'ase" argument reached bash as "phase" because Git Bash's MSYS layer re-splits and unescapes argv. The helper was right to accept "phase". Read the value from a file inside the script instead, the same transport hazard extractStallHelpersBash() documents.
…and make the receipt helpers locale-proof Review follow-up on open-gsd#5192. - Rebased onto origin/next 47b22b8. next grew plan-phase.md to 98,051 LF bytes (open-gsd#5171), so the receipt binding is now stated once at step 7.99 (`{receipt}` = gsd_receipt_path "${PHASE_DIR}" <spawn>) and each watch site carries only its spawn label. Step 11 keeps its original marker_received bullets; the rule that the routing marker comes from gsd_return_marker over {receipt} moves into the helpers doc that 7.99 loads. plan-phase.md is 98,273 LF bytes (cap < 98,300). - gsd_receipt_path and gsd_return_marker spell out their ASCII word characters instead of bracket ranges, so no locale (bash 3.2 has no globasciiranges) can widen them. - gsd_return_marker drops its trailing-CR strip: a CR after the marker is already a non-word character, so the strip changed no result (a mutation of it survived the new property test). - The compact-content benchmark baseline is regenerated against the rebased plan-phase.md (`benchmark-compact-content.cjs --write`), not hand-merged.
…s, AC4 boundary rows and token-level prose asserts Review follow-up on open-gsd#5192. - Property: gsd_return_marker matches iff the line starts with the marker and the next character is not [A-Za-z0-9_], with a leading BOM, a trailing CR and a missing final newline tolerated. Property: gsd_receipt_path never leaves <phase>/.gsd-returns/ for any spawn label, and an unsafe phase dir (quote, $ or backtick) fails closed. Each runs one bash invocation per generated batch, with values passed through files, never argv. - AC4 rows now cover limit-1 / limit / limit+1 (599 / 600 / 601 s) against an injected clock (`date +%s` stubbed); no open-gsd#5182 row reads the wall clock. - Prose-contract asserts are reduced to command tokens (`rm -f "{receipt}"`, `gsd_return_marker "{receipt}"`, `<return_receipt>`, printf) plus one keyword, so rewording the surrounding prose does not break the suite.
|
@trek-e I pushed
|
|
@trek-e the branch is conflict-free: I pushed two commits on top; the PR head is now Still open: Stryker does not run on this PR, and the properties do not generate Re-requested your review. Review 5477738387: finding to current commit
Local, empty HOME/GSD_HOME: stall-detection 78/78, emitted gates 184/184, Minor 5 is only partly addressed. The new in-project link row goes through
|
trek-e
left a comment
There was a problem hiding this comment.
Merge conflict with next — blocks review and merge.
git merge-tree --write-tree origin/next <PR head> against next @ f7efbf110a reports conflicts in:
tests/fixtures/compact-content-benchmark-baseline.json
Please bring the branch current with next and resolve the conflict(s). The substantive review resumes once the PR is mergeable. This notice is about the conflict only; it does not retire any open finding from earlier reviews.
…ost transcript in the plan-phase stall watch
gsd_stall_watch grepped the whole host {outputFile} for the return markers.
On Claude Code that file is the subagent transcript, which already holds the
agent's prompt and definition snapshot (both quote the markers), so the
planner and checker waits reported marker_received at their first check. The
revision watch passed no markers, so it could only end stalled, and a host
with no output file could only ever reach stalled.
Each watched spawn (planner, checker, revision, chunked outline, chunked
per-plan) now gets a per-dispatch receipt from gsd_receipt_path, named in its
prompt as <return_receipt>; the agent writes its marker line there as its last
action. gsd_return_marker, a literal line-start match, is the one matcher used
by the watch and by step 11's checker routing. The revision watch passes
Return Markers and the agent-contracts registry). The checker writes its
receipt with one Bash printf and gains no tool; a refused write is non-fatal.
Emitted-Drift-Ack-Growth: plan-phase.md — the five-site receipt binding, the <return_receipt> prompt lines, the revision markers and receipt-based checker routing (open-gsd#5182); the bytes are the fix itself.
Emitted-Drift-Ack-Growth: gsd-planner.md — the return-receipt rule and the ## REVISION COMPLETE marker declaration (open-gsd#5182); the bytes are the fix itself.
Emitted-Drift-Ack-Growth: gsd-plan-checker.md — the return-receipt rule, written with the Write tool as its only write (open-gsd#5182); the bytes are the fix itself.
… not argv, so Windows MSYS cannot strip the quote Windows conformance shard 1/3 on PR open-gsd#5192 failed the gsd_receipt_path unsafe-phase-dir row: the "ph'ase" argument reached bash as "phase" because Git Bash's MSYS layer re-splits and unescapes argv. The helper was right to accept "phase". Read the value from a file inside the script instead, the same transport hazard extractStallHelpersBash() documents.
…and make the receipt helpers locale-proof Review follow-up on open-gsd#5192. - Rebased onto origin/next 47b22b8. next grew plan-phase.md to 98,051 LF bytes (open-gsd#5171), so the receipt binding is now stated once at step 7.99 (`{receipt}` = gsd_receipt_path "${PHASE_DIR}" <spawn>) and each watch site carries only its spawn label. Step 11 keeps its original marker_received bullets; the rule that the routing marker comes from gsd_return_marker over {receipt} moves into the helpers doc that 7.99 loads. plan-phase.md is 98,273 LF bytes (cap < 98,300). - gsd_receipt_path and gsd_return_marker spell out their ASCII word characters instead of bracket ranges, so no locale (bash 3.2 has no globasciiranges) can widen them. - gsd_return_marker drops its trailing-CR strip: a CR after the marker is already a non-word character, so the strip changed no result (a mutation of it survived the new property test). - The compact-content benchmark baseline is regenerated against the rebased plan-phase.md (`benchmark-compact-content.cjs --write`), not hand-merged.
…s, AC4 boundary rows and token-level prose asserts Review follow-up on open-gsd#5192. - Property: gsd_return_marker matches iff the line starts with the marker and the next character is not [A-Za-z0-9_], with a leading BOM, a trailing CR and a missing final newline tolerated. Property: gsd_receipt_path never leaves <phase>/.gsd-returns/ for any spawn label, and an unsafe phase dir (quote, $ or backtick) fails closed. Each runs one bash invocation per generated batch, with values passed through files, never argv. - AC4 rows now cover limit-1 / limit / limit+1 (599 / 600 / 601 s) against an injected clock (`date +%s` stubbed); no open-gsd#5182 row reads the wall clock. - Prose-contract asserts are reduced to command tokens (`rm -f "{receipt}"`, `gsd_return_marker "{receipt}"`, `<return_receipt>`, printf) plus one keyword, so rewording the surrounding prose does not break the suite.
… functions only, without the per-call Node preamble Every open-gsd#5182 row ran the whole helpers fence per bash call, including the runtime-locator preamble and two `gsd_run query config-get` Node spawns. On Windows CI shard 1/3 (run 37148401740) that put the open-gsd#5182 block at 169.5 s inside a concurrent chunk, and a sibling file (execute-phase-decimal-arithmetic) then hit `spawnSync bash ETIMEDOUT`. The open-gsd#5182 rows now slice the fence from gsd_stall_should_recover() on (extractStallFunctionsBash), so each call is one bare bash process running the same shipped functions. Config resolution stays covered by the open-gsd#2650 rows, which still run the full fence. Locally the block drops from about 16 s to 2.1 s. Both properties still fail against the four deliberate helper breaks.
… runs with identical inputs
Once every call site bound {receipt}, the row's bind() gave both "hosts"
the same receipt, so its comparison could not fail (review 5436473886,
Minor 2). The row now gives argument 2 each host's output, a transcript
that contains the checker marker and a missing file, and expects waiting
before the threshold, stalled past it, and marker_received only from the
receipt. The call-site binding stays with the "AC1 wiring" row. A watch
that ignores argument 2 is now caught by this row alone.
… body never arrives fails closed to 9a The receipt carries only the marker. A receipt-routed CHECKPOINT REACHED, PHASE SPLIT RECOMMENDED or Source Audit whose body has not arrived by the next cycle now goes to 9a, naming the marker received and offering Retry planner before Accept, instead of an empty checkpoint, 9b or 9c (review 5436473886, Nit 4). This matches the checker's ISSUES FOUND rule.
…its return receipt is written on read-only hosts
The checker wrote its stall-watch receipt with a Bash printf because it had
no Write tool. Codex derives a read-only sandbox from that tools: line and
refused the write, so a passing checker's watch ended stalled at the
threshold and AC5 ("identical on every host") did not hold for it.
The checker now declares Write, like the other report-writers (gsd-verifier,
gsd-doc-verifier, gsd-eval-auditor, gsd-ui-auditor): Claude Code denies it
only Edit and MultiEdit, Codex derives workspace-write, and its definition
states the receipt is its only write. Maintainer decision on review
5436473886 Finding 3 (issuecomment-6036744404): fixed in this PR.
The write limit is also stated unconditionally in <anti_patterns>, because
the quick, quick-batch and import spawns send no receipt. As a Write-capable
agent the checker now carries the commented hooks block and the
anti-heredoc instruction (tests/agent-frontmatter.test.cjs). The growth is
covered by this PR's existing Emitted-Drift-Ack-Growth key for
gsd-plan-checker.md (the receipt rule).
…e receipt read gsd_receipt_path ran mkdir -p on <phase>/.gsd-returns and wrote its .gitignore without checking for symlinks, so a checkout that commits either one as a link sent the directory creation and the '*' write outside the phase. It now fails closed (prints nothing, returns 1) when .gsd-returns or .gsd-returns/.gitignore is a symlink, checked before each write. New rows cover a linked directory, a linked .gitignore and a dangling .gitignore link; the link targets stay untouched. gsd_return_marker read the agent-written receipt with no limit. It now reads at most the first 64 lines through an in-loop counter (no extra process per call), keeping the BOM, missing-final-newline and first-match behavior. A new row shows a marker on line 1 or 64 is found and one only on line 65 is not. Answers review Minor 1 (symlinked .gsd-returns) and Nit 5 (unbounded receipt read).
…atch helpers kimi-code dispatches -planner to its plan built-in and -checker to explore, and neither can write files (runtime-aware-dispatch.md), so no return receipt is written there. The helpers doc now says so beside the plan-checker receipt rule: the wait ends on the runtime's completion result, or as stalled at the threshold. Answers review Finding 3 (kimi-code known limit).
…h and harden the receipt read gsd_receipt_path checked for a symlink only at .gsd-returns and its .gitignore, so a checkout that commits the phase dir, .planning or .planning/phases as a link sent mkdir -p and the '*' .gitignore write outside the project, and the printed receipt path told the planner and checker to Write there. It now walks from PHASE_DIR up to its nearest existing ancestor, refuses any walked component that is a link (tested before -e, so a dangling one counts), resolves that ancestor with cd -P/pwd -P and requires it to sit under the orchestrator's pwd -P, then re-verifies the created .gsd-returns physically after mkdir. Both sides go through pwd -P, so the macOS /var -> /private/var temp link and Git Bash's C:/ -> /c/ mapping compare equal. A refused receipt prints nothing and returns 1; the watch then ends on the runtime's completion result or as stalled. Only static, checked-in links are in scope (no check-then-act race guard). It also refuses a PHASE_DIR holding any control character (newline, tab, CR, ESC, DEL) or < or >, which would land in the <return_receipt> prompt line. gsd_return_marker now ignores a receipt that is a symlink, reads at most the first 4096 bytes (head -c) as well as the first 64 lines, so one huge line no longer costs tens of seconds per watch cycle (1,000,000 bytes took 35 s on bash 5.2 and 73-100 s on bash 3.2 before; 0 s now), and counts a marker only when it is followed by the end of the line, a space, tab, CR, VT, FF or a colon, so "## PLAN COMPLETE-x" is not "## PLAN COMPLETE". The colon is kept on purpose: the planner returns its Source Audit heading with ": Unplanned Items Found" after the marker the watch passes. gsd_stall_watch passed its artifact glob to find unquoted, so a space in PHASE_DIR split it and a fresh *-PLAN.md read as stalled. The directory part is now quoted and only the basename is globbed, under nullglob (restored after) with IFS empty; no match gives an empty list and artifact_fresh false. The call contract is unchanged. Tests: rows for a symlinked phase dir (relative, absolute, a child of it), a phase dir under a .planning linked outside the project, dangling .gsd-returns and phase-dir links, a symlinked receipt, the 4096-byte cap and a 1,000,000-byte line under the 15 s probe bound, the marker boundary (-x, ., a letter, space, tab, CR, colon) and a spaced phase dir with a fresh plan. The unsafe-dir row and property now read values with read -d '' and cover newline, tab, CR, ESC, DEL, < and >. The receipt-path property no longer exempts every non-ASCII label from the stem check (!ascii ||): ASCII labels must match exactly and non-ASCII labels match with runs of _ collapsed, since bash replaces per character in a UTF-8 locale and per byte in C. Looping rows assert their executed count. Answers review 5477738387 Major 1, Minor 1, Minor 2, Minor 4, Minor 6, Nit 1 and Nit 3.
…the no-receipt outcome
The helper doc said the orchestrator removes the receipt "after routing",
but no watch site carried the command, so stalled and Retry paths left
orphan .gsd-returns files. Each of the five watch rules (planner, checker,
revision, chunked outline, chunked per-plan) now ends with
`rm -f "{receipt}"` after routing any result, stalled and Retry included.
The helper doc now states the other half of Minor 3: a checker with no
receipt touches no *-PLAN.md, and neither does a planner before its first
PLAN.md, so that watch can only end on the completion result or as
stalled at the threshold. There is no heartbeat protocol.
Tests: a row per watch site asserting the cleanup clause follows its
gsd_stall_watch call (5 sites, count asserted), a doc row for the
no-receipt outcome, and a *-PLAN.md freshness row just inside and just
outside the interval. The two wiring rows fail on the previous docs.
Also: Write added to the plan-checker row of docs/ja-JP and docs/ko-KR
AGENTS.md (matching docs/AGENTS.md), the changeset states that no receipt
is created for a symlinked or control-character phase path, and the
compact-content baseline is refreshed.
Answers review 5477738387 Minor 3, Minor 5 (remaining rows), Nit 4 and
Nit 6.
The plan-phase.md growth is covered by this PR's existing
Emitted-Drift-Ack-Growth key for plan-phase.md (a second key with a
different reason would be an ambiguous duplicate).
…t is refused gsd_receipt_path now fails closed on an unsafe or symlinked phase dir, but the helpers doc did not say what the orchestrator does then. It now says: omit the <return_receipt> line and pass "" as the watch's second argument, so the wait ends on the runtime's completion result or as stalled. A test pins the doc line and that an empty receipt reads waiting before the threshold and stalled after it, never a match. The changeset's issue backlink moves to the end of the entry (review 5477738387 Nit 6).
…nd bind the receipt under either toggle
A `..` after a phase-dir component that does not exist yet passed the
nearest-existing-ancestor check (the project itself), so mkdir -p resolved
it and created `.gsd-returns` outside the project before the post-mkdir
check refused the path. gsd_receipt_path now refuses any `..` component
with the other unsafe-input checks, before touching the filesystem; a row
covers the relative, absolute and existing-ancestor forms and asserts
nothing is created outside.
The helpers doc bound `{receipt}` only "before each watched Agent() call"
while also saying the toggle-false prompt still carries the receipt line;
it now binds before every call that carries the line, under either toggle.
The ja-JP and ko-KR plan-checker tools rows gain Write, as the English row
did.
…ect link rows Every existing symlink row links outside the project, so the physical containment check alone refuses it; deleting the walk's `[ -L "$a" ]` test left the file at 77/77. A link that stays inside the project is refused only by that test. The new row covers a phase dir that is itself an in-project link and a walked link back to the project followed by a missing child, and asserts nothing is created through either. It also accepts an existing phase dir reached through an in-project parent link, which pins the scope of the claim. RED on the mutant, GREEN on the head.
…eceipt helper checks The changeset said no receipt is created when the phase directory or one of its parents is a symlink. A parent link that stays inside the project is accepted: the helper refuses a linked phase dir, a link on the walked missing components, and any path that resolves outside the project. The sentence now says that, and names the `..` refusal.
…l edit on OpenCode and Kilo
efb6dcb to
35bf07e
Compare
|
@trek-e rebased onto Line refs in my 10-16 finding map moved: One disclosure I missed: on OpenCode and Kilo, Write/Edit/MultiEdit collapse to one
|
trek-e
left a comment
There was a problem hiding this comment.
Review — fix(#5182): watch a GSD-owned return receipt instead of the host transcript in the plan-phase stall watch
Head reviewed: 35bf07e3754e3235df17064c2af0f82bc31e5ed6 (gh pr view 5192 --repo open-gsd/gsd-core --json headRefOid,mergeable,mergeStateStatus,reviewDecision this turn: MERGEABLE, BEHIND, CHANGES_REQUESTED). Reviewer is isolated (did not author). Nothing was executed with node --test; CI is the executor.
Summary
The substance is largely sound and the earlier trek-e findings are resolved at this head: the symlinked-ancestor Major (containment via cd -P/pwd -P, walk-up link test, .. refusal), control characters and </> in PHASE_DIR, symlinked receipt, rm -f "{receipt}" at all five sites, quoted glob expansion, head -c 4096, word-boundary rule, ja/ko tools rows. The head is BEHIND next (clean per git merge-tree), CI is fully green. What remains is one security-posture decision that was only accepted for Codex, several test-quality items the author already disclosed as open, a silent-degradation path in the containment check, one unhandled body-less route, and nits. Zero tolerance applies: changes requested. Blocker 0, Major 1, Minor 7, Nit 4.
Classification & gate compliance
| Gate | Result | Evidence |
|---|---|---|
| Track / label | PASS | #5182 labels bug, area: workflow, confirmed-bug -> Bug-Fix track |
| Scope vs approved issue | PASS | Agent Brief names planner, checker, revision; the two chunked sites are disclosed in the brief thread and pinned by the existing five-site test. #2699 and #3923 untouched |
| Closing keyword | PASS | Fixes #5182 in body; closingIssuesReferences = #5182 |
| Not a draft; title; branch | PASS | isDraft false; fix(#5182): ...; branch fix/5182-stall-watch-return-receipt |
| Commits | PASS | 18 conventional fix/test/docs(#5182) commits |
| PR template | PARTIAL | "Fix PR" template used; "All existing tests pass" is unchecked with a stale local-failure narrative and stale head sha 18695364a (Nit 2) |
| Changeset | PASS | .changeset/lively-sloths-forage.md: type: Fixed, pr: 5192, bold lead, ends with (#5182) |
| Docs gate | PASS (Fixed type is exempt) | docs/AGENTS.md updated anyway; ja-JP/ko-KR partially (Nit 1) |
| Inventory | N/A | No file added under references/ or workflows/ |
| ADR gate | PASS (judgment) | Body names ADR-1239, 1016, 5057, 857 P6, 1610, 1235, 3574 and #767; no conflicting ADR found. recall_decision unavailable (see Memtrace Evidence) |
| CONTEXT.md | NIT | No entry for the new return-receipt concept or the #767 Group B move of plan-checker (Nit 3) |
| Labels | NOTE | PR still carries needs-review: merge-conflict although mergeable is MERGEABLE; bot-owned, stale |
Must-Have Acceptance Checklist (issue #5182, verbatim)
| # | Criterion | Result |
|---|---|---|
| 1 | "Markers present only in a prompt, agent definition or transcript yield waiting." | PASS. gsd_stall_watch (stall-detection-helpers.md) reads only the receipt through gsd_return_marker; rows AC1, AC5 pass a transcript holding the marker text and expect waiting. Verified by reading and by probing gsd_return_marker directly (## ISSUES FOUND-x -> empty) |
| 2 | "A checker returning issues routes to the revision step, never to the pass step." | PASS when the receipt or result arrives. Routing is orchestrator prose (plan-phase.md step 11); a receipt-only ISSUES FOUND with no body fails closed to 11a |
| 3 | "A finished revision yields a success result within one poll interval." | PASS. Revision watch now passes ## REVISION COMPLETE and ## REVISION_CONFLICT. Body-less REVISION_CONFLICT is not covered (Minor 2) |
| 4 | "A spawn that never signals completion yields stalled after the threshold." | PASS. Boundary rows at 599/600/601 s with a stubbed clock |
| 5 | "Results are identical on Claude Code and on a host with no output file." | PARTIAL, accepted limits: kimi-code cannot write a receipt (documented, stall-detection-helpers.md "Known limit, kimi-code"); a refused receipt (symlink, .., outside cwd) degrades to completion-result-or-stalled (Minor 1). Not exercised live (Minor 7) |
| 6 | "Regression tests fail on current next first." |
PASS by construction (helpers and {receipt} binding do not exist on base; AC1/AC5 rows would see marker_received from the transcript on base). I did not execute them; PR body reports tests 60 / pass 40 / fail 20 against untouched next |
Root-cause / functional verdict
Root cause (one sentence): gsd_stall_watch decided completion by grep -qF over the host output file, which on Claude Code is a transcript that already contains the agent's prompt and definition quoting the markers, and the revision site passed no markers at all, so the success exit was unreachable.
Verdict: root-cause fix. The input to the decision is replaced with a GSD-owned per-dispatch file, and gsd_return_marker is the single owner of "which marker was returned" for both the watch and step 11 routing (one owner, no parallel parser). gsd_stall_should_recover is unchanged, correctly. I ran the shipped gsd_receipt_path and gsd_return_marker from the head under bash in a scratch directory outside the repo: relative phase dir accepted with sanitized label (a b -> a_b), .gitignore written, marker/word-boundary behavior as specified.
Blast radius
- Runtime-visible:
READONLY_AGENT_DISALLOWED_TOOLS['gsd-plan-checker'](src/runtime-artifact-conversion.cts:4067): CodeGraphimpact= 3 symbols, sole consumerapplyAgentFrontmatterExtensions(:4099) viainjectDisallowedToolsFrontmatter(Claude-only). Codex sandbox derivation moves the checker fromread-onlytoworkspace-write. The checker is spawned from quick, quick-batch and import as well, and now holdsWritein all of them (the limit is prose in<anti_patterns>). - Tool-grant widening by host (see Major 1): Claude (Edit/MultiEdit still denied), Codex (workspace-write), OpenCode and Kilo (
Write->edit,src/runtime-artifact-conversion.cts:321;buildOpencodeAgentPermissionBlockemits denials only, anddisallowedToolsinjection is Claude-only, so nothing re-denies), Copilot (Write: 'edit',bin/install.js:556). - Workflow surface: five watch sites in
plan-phase.mdandchunked-planning-mode.md;gsd_stall_watchsecond argument changes meaning (host file -> receipt). No callers outside those five (the existing five-site test pins it). - Shell-in-markdown is not graph-indexed; risk rated medium on reading.
Co-change completeness
- Present: agents (planner, checker),
agent-contracts.md,docs/AGENTS.md, ja-JP/ko-KR tools rows, install/Codex/descriptor-parity tests, compact-content baseline fixture, changeset. agents/*.compact.mdfor planner/checker: not tracked in the tree (onlygsd-eval-planner.compact.mdexists), no gap.- Absent: ja-JP/ko-KR "Principle of Least Privilege" line still says checkers are read-only (Nit 1); CONTEXT.md (Nit 3).
Test quality
- Fail-first: AC1 (
tests/plan-phase-stall-detection.test.cjs:1032) and AC5 (:1116) are the discriminating rows; on base they getmarker_receivedfrom the transcript wherewaitingis expected. - Persisted artifact: receipt rows assert the file system (
treeOf(outside)unchanged,.gitignorecontent, victim file byte-identical). Good. - Controls:
symlinkOrSkiprows carry a control (gsd_return_markerrow asserts the target itself carries the marker). Good. - Parsers/budgets: two fast-check properties exist (
:1209,:1235) with an independent oracle;numRuns8-12 each over batches; generators never produce/,..or links (Minor 3). - Boundaries: 64-line cap has 64/65; 600 s threshold has 599/600/601; the 4096-byte cap does not (Minor 4).
- Wall clock:
:1693-1709usesDate.now()(Minor 5). - Prose asserts remain (Nit 4).
Memtrace Evidence
get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The ADR/decision check was not evaluated against recorded decisions; the ADR list above is the PR body's own claim, not verified.find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The deterministic review pass was not run and is not reported clean._graph_state: no Memtrace graph state available.
CodeGraph / manual evidence used instead: codegraph impact READONLY_AGENT_DISALLOWED_TOOLS (3 symbols, one consumer); codegraph callers injectDisallowedToolsFrontmatter (1 caller, Claude-only path); codegraph node spans of src/runtime-artifact-conversion.cts:135-364 (Kimi/Kilo/OpenCode permission maps and buildOpencodeAgentPermissionBlock) and of the full test file from 900 to 1711; git diff origin/next...refs/prrev/5192 on every changed non-test file; direct execution of the head's gsd_receipt_path and gsd_return_marker in a scratch directory outside the repo; worktree at the head for index freshness. No test was executed.
Findings
Source tags: [fact] = verified on disk or by running the shipped helper; [judgment] = reviewer assessment.
Major
- Major 1 — [judgment, built on verified facts] The plan-checker's
Writegrant widens to fullediton OpenCode, Kilo and Copilot, and the maintainer's security acceptance covered Codex only.agents/gsd-plan-checker.md:4addsWrite;src/runtime-artifact-conversion.cts:321mapsWriteandEditandMultiEditto the same OpenCodeeditpermission,:343-364emits denials only, anddisallowedToolsis injected for Claude only (:4047), so nothing re-denies Edit there; Kilo (:241) and Copilot (bin/install.js:556) mapWritetoeditthe same way. The checker reads contributor-controlledPLAN.mdand source and holdsBash. The PR body states "The maintainer's 10-10 security acceptance covered Codex only and predates #5338", and the PR discloses OpenCode and Kilo but not Copilot or the othertools:-deriving hosts (Antigravity and others were not audited here). The "receipt is the only write" limit is prose on all of them. Fix: obtain explicit maintainer acceptance for each widened host in the PR thread, or scope the grant (for example keep the checker's receipt via the host's completion result on hosts whereWritecannot be separated fromedit), and extend the disclosure indocs/AGENTS.md:885and the changeset to every host whose conversion mapsWriteto a broader permission.
Minor
- Minor 1 — [fact] Containment is anchored to the orchestrator's cwd and a refusal is silent.
stall-detection-helpers.mdgsd_receipt_path(theroot=$(pwd -P)andcase "$phys/" in "$root"/*lines). Probed: with cwdp/proj, an absolute phase dirp/other/phreturnsrc=1and prints nothing. A legitimate absolutePHASE_DIRoutside the cwd (multi-projectGSD_PROJECTworkspaces perdocs/CONFIGURATION.md:2279, or an orchestrator whose cwd is a subdirectory) loses its receipt with no diagnostic and reverts to the original stalled-only behavior. Fix: anchor on the project root the workflow already resolves, and emit a one-line stderr reason on refusal so the orchestrator can name it; add a row for an absolute phase dir under the project root but outside the cwd. - Minor 2 — [fact on code path, judgment on effect] A receipt-only
## REVISION_CONFLICTis not in the fail-closed list.stall-detection-helpers.mdlists ISSUES FOUND, CHECKPOINT REACHED, PHASE SPLIT RECOMMENDED and Source Audit as body-needing routes;plan-phase.md(revision conflict block, after the step 12 watch rule) exportsCONFLICT_DIMENSION/_PLAN/_PROPERTY/_CONSTRAINT/_ALTERNATIVESfrom the return body, which the receipt does not carry. With the receipt arriving first, the conflict record written to REVIEWS.md would have empty fields. Fix: add REVISION_CONFLICT to the list (route to the existing revision fallback and offer Retry planner) and add a row beside the ISSUES FOUND fail-closed row. - Minor 3 — [fact, author-disclosed] Test gaps from the prior Minor 5 remain open.
tests/plan-phase-stall-detection.test.cjs:1209-1284fast-check properties never generate/,..or links (example rows cover them);:1333-1344symlinkOrSkipskips onEPERM/EACCES/ENOTSUP, so every symlink row (:1346-1381,:1458-1572) passes with zero assertions on an unprivileged Windows runner; Stryker does not run on this PR so the 80% kill threshold is unmeasured. Fix: add path-shaped generators (segments froma,..,.,link) with a oracle for the refusal set, and make the Windows skip fail loudly where CI is known to have the privilege, or add a runner-side privilege check. - Minor 4 — [fact] The 4096-byte cap has no limit-1/limit/limit+1 rows.
:1584-1587uses 4000 and 5000 bytes only. Fix: place the marker so it ends at byte 4095, 4096 and 4097 and assert found / found / not found (a marker straddling the cut must not match). - Minor 5 — [fact] Wall-clock use in the freshness test.
:1699-1708sets mtimes fromDate.now()and relies onfind's real clock, and:1580asserts the read "must finish withinPROBE_TIMEOUT_MS". CLAUDE.md: "Do not assert on wall-clock time." Fix: put afindshim ahead ofPATHthat reports a controlled age, and assert the cap by output (the marker is not read) rather than by elapsed time. - Minor 6 — [fact]
gsd_return_markerstrips a BOM on every line, not only the first.stall-detection-helpers.mdline="${line#$'\xef\xbb\xbf'}"runs inside the loop, so a BOM at the start of line 2..64 is also tolerated; the property oracle (:1203-1207) matches that, so the property cannot catch it. Low impact (a BOM mid-file does not occur in a Write-tool receipt) but it contradicts the stated "leading BOM" contract. Fix: strip only whenn -eq 1, and add a row with a BOM on line 2. - Minor 7 — [judgment] No live-runtime evidence. PR body: "Runtimes tested: Other: runtime-neutral bash helper exercised directly; no live runtime session was run". The mechanism the fix depends on (a background planner or checker writing its receipt with
Writeas its last action, and the orchestrator binding{receipt}across tool calls) is exercised only through shell-stubbed unit rows; the issue reproduced on live Claude Code. Fix: one recorded live Claude Codeplan-phaserun (planner, checker returning ISSUES FOUND, revision) showing the three receipts and the routing, attached to the PR.
Nit
- Nit 1 — [fact]
docs/ja-JP/AGENTS.md:425anddocs/ko-KR/AGENTS.md:425still read "checkers are read-only (no Write/Edit)" while the same files' tools row (edited here) now listsWrite. Englishdocs/AGENTS.md:885was updated. Update the line or revert the partial edit. - Nit 2 — [fact] PR body is stale: cites head
18695364a, leaves "All existing tests pass (npm test)" unchecked with a local-failure narrative, and lists Linux only; CI at35bf07e37is green. Refresh the body to the current head. - Nit 3 — [judgment] CONTEXT.md carries no entry for the return-receipt concept,
gsd_receipt_path/gsd_return_markeras the single owner of "which marker did the agent return", or plan-checker's #767 Group B move. Gate: "CONTEXT.md updated when vocabulary or modules change". CONTEXT.md has no stall-detection entry at all, so precedent is weak. - Nit 4 — [fact] Prose-contract asserts remain after the stated reduction ("Prose-contract rows are deliberately reduced to command tokens and one keyword",
:1416):/only write/,/the Write tool/,/Group B report-writer/,doesNotMatch(/refuses that write/),doesNotMatch(/After routing, the orchestrator runs/)(:1405-1423,:1671),/no heartbeat/i(:1670). They read workflow and agent markdown by regex (file carries thesource-text-is-the-productexemption), but they pin wording, not behavior. - Nit 5 — [fact]
mergeStateStatusisBEHIND:origin/nextis274f8cae13d03ad80e18959b135b0e51b33329e8;git merge-tree --write-tree origin/next refs/prrev/5192exits clean (tree999a96f39f03dac67ec2b5679ead27fdf9226f89). The branch has needed a rebase ontests/fixtures/compact-content-benchmark-baseline.jsonat each of its last five pushes; regenerate that fixture again withnode scripts/benchmark-compact-content.cjs --writeafter the next rebase.
CI (fresh this turn, head 35bf07e3754e3235df17064c2af0f82bc31e5ed6)
- 50 check runs, collapsed to latest-per-name: 43 SUCCESS, 7 SKIPPED, 0 failing, 0 queued, 0 pending.
- Non-success non-skipped rows by the prescribed jq/awk collapse: none.
action_requiredworkflow runs for this head sha: none.reviewDecision:CHANGES_REQUESTED(trek-e, 2026-10-10 on8ce426bde; the 2026-10-17COMMENTEDreview onefb6dcb7ewas a merge-conflict notice that retires nothing).mergeStateStatusBEHIND.- No red to attribute.
Verdict
Changes requested. Blocker 0, Major 1, Minor 7, Nit 5. CI is green and the branch is mergeable; the open items are Major 1 (maintainer decision on the Write grant for OpenCode, Kilo and Copilot), the silent containment degradation (Minor 1), the body-less REVISION_CONFLICT route (Minor 2), the disclosed and still-open test gaps (Minor 3 to Minor 5), and the remaining minors and nits above.
Fix PR
Linked Issue
Fixes #5182
The linked issue carries
confirmed-bug. This PR follows @trek-e's diagnosis and Agent Brief on the issue (issuecomment-5963253983); its six acceptance criteria are mapped below. Out of scope, per the brief: #2699 (general subagent watchdog) and #3923 (threshold default).What was broken
With the default
planner.stall_detection_enabled: true,/gsd:plan-phasedecides that a background planner or plan-checker has finished by grepping the host's{outputFile}for the return markers. On Claude Code that file is the subagent transcript. The transcript already holds the agent's prompt and its definition snapshot, and both quote the markers, so the wait reportedmarker_receivedat its first check whether or not the agent had returned. The revision watch passed no markers, so every revision round could only end in the "Accept as revised / Retry / Stop" stall menu. A host that returns no output file could only ever reachstalled.What this fix does
Each watched spawn now gets a GSD-owned, per-dispatch return receipt, and the watch reads only that file:
gsd_receipt_path "${PHASE_DIR}" <spawn>prints a fresh absolute path,<phase>/.gsd-returns/<spawn>.<epoch>.XXXXXXXX. It creates the directory with a catch-all.gitignorebut not the file. It fails closed (prints nothing, returns 1) unless the phase directory's nearest existing ancestor resolves, withpwd -P, under the orchestrator'spwd -P, and no walked component,.gsd-returnsor its.gitignoreis a symlink. It also fails closed on a phase path holding a quote,$, a backtick,<,>or any control character. Only static, checked-in links are in scope; the check-then-act race is not. The orchestrator binds the printed path as{receipt}in the spawn prompt (<return_receipt>{receipt}</return_receipt>) and in the watch call.Writefor this one file (see "Checker posture" below).gsd_return_marker FILE MARKER...is the single owner of "which marker did the agent return". It does a literal line-start match within the first 64 lines and 4096 bytes. The marker must be followed by the end of the line, whitespace, CR or a colon. It ignores a receipt that is a symlink, and it tolerates a BOM and a missing final newline.gsd_stall_watchuses it formarker_found, and step 11 routes the checker on it, so the watch and the router cannot disagree. Prompt, definition and JSONL transcript text never match it.## REVISION COMPLETEand## REVISION_CONFLICT, so it has a success exit.## REVISION COMPLETEwas already emitted (planner-revision.mdStep 7). It is now declared in gsd-planner's Return Markers and in theagent-contracts.mdregistry, andcheck-contract-driftstays at 0 violations.## ISSUES FOUNDwith no issue list fails closed: it is never counted as 0 issues and goes to 11a.$TSlost between tool calls falls back to the epoch stamped into the receipt name, so the threshold stays reachable.All five watch sites get the receipt, and each rule removes it with
rm -f "{receipt}"after routing any result, stalled and Retry included: the three inplan-phase.mdthat the brief names (steps 8, 10, 12) and the chunked outline and per-plan sites insteps/chunked-planning-mode.md. The two chunked prompts also sayReturn: ## ... COMPLETE, so they had the same first-check match, andtests/plan-phase-stall-detection.test.cjspins the helper contract at exactly five sites.Root cause
gsd_stall_watch(stall-detection-helpers.md:156-159onnext) rangrep -qFover the whole host output file. On Claude Code that file contains the agent's inputs as well as its output. The revision call site (plan-phase.md:1189) passed no markers, so themarker_receivedexit ingsd_stall_should_recover(:124-126) was unreachable for it. Introduced by 067a4d1 (#3015).gsd_stall_should_recoveritself is unchanged on purpose: its order (marker, then fresh artifact, then threshold) was correct. The defect was the input it was given, a host file and an empty marker list at the revision site.Checker posture (maintainer decision, issuecomment-6036744404)
gsd-plan-checker moves from #767 Group A to Group B, the report-writers (gsd-verifier, gsd-doc-verifier, gsd-eval-auditor, gsd-ui-auditor). Its
tools:gainsWrite, and itsREADONLY_AGENT_DISALLOWED_TOOLSentry (src/runtime-artifact-conversion.cts) becomesEdit, MultiEdit. Codex then derivessandbox_mode = "workspace-write"from itstools:(src/codex-agent-toml.cts; no hold applies). The receipt rule says the receipt is the checker's only write.<anti_patterns>repeats that limit unconditionally, because the quick, quick-batch and import spawns send no receipt. As a Write-capable agent the checker also carries the commented# hooks:block and the anti-heredoc line (tests/agent-frontmatter.test.cjs). So AC5 holds for the checker as it does for the planner, on Claude Code and on Codex.One host-level limit applies to the planner and the checker alike.
references/runtime-aware-dispatch.mdmaps-plannerand-checkerroles on kimi-code to built-ins that do not write files, so neither receipt is written there. On that host the watch behaves as it does onnextfor a host with no output file. Changing that mapping is outside this fix.Security posture, stated plainly: on Claude Code the checker is no longer denied
Write(it is still deniedEditandMultiEdit). On Codex its sandbox can write anywhere in the workspace, not only the receipt. That is the posture the four existing Group B agents already have, and the "only write" limit is prose, as it is for them. On OpenCode and Kilo the grant is wider still: Write, Edit and MultiEdit collapse to oneeditpermission there (src/runtime-artifact-conversion.cts), so the checker'sWritemakes full edit allowed, the same as the other Group B agents. The maintainer's 10-10 security acceptance covered Codex only and predates #5338.Testing
How I verified the fix
New rows in
tests/plan-phase-stall-detection.test.cjs(the module's own test file). They run the shipped bash withsleepstubbed, and they read each call site's markers fromplan-phase.mdrather than copying them:AC1: markers present only in a prompt or definition snapshot yield waiting(+ wiring row: 5 sites bind{receipt}, none binds{outputFile})AC2: a checker receipt of ## ISSUES FOUND routes to the revision step(+ fail-closed row for a missing issue list)AC3: a finished revision yields marker_received within one poll intervalAC4(preservation row) + lost-$TSepoch rowAC5: identical results on a host with a transcript output file and on a host with nonenextfirstPlus edge rows for
gsd_return_marker(missing, empty, CRLF, BOM, no final newline, trailing text, word boundary, indented, mid-line, JSONL, directory) andgsd_receipt_path(absolute, distinct within one second, not pre-created, label sanitized, unsafe phase dir refused,.gitignore).Red first: with only the test file changed, on
next@ af46465,node --test tests/plan-phase-stall-detection.test.cjsgave tests 57, pass 47, fail 10. AC1 and AC5 failed withmarker_receivedwherewaitingwas expected, which is the issue's mechanism. AC4 was green onnextby design.Green (head
18695364a, emptyHOME/GSD_HOME):plan-phase-stall-detection+ the workflow and agent size budgets: 293/293. The emitted ack/attribution, agent-frontmatter and docs-parity files: 2299/2299. First round: Run against the untouchednextsource, the final test file gives tests 60, pass 40, fail 20: all 14 new regression rows fail except the AC4 preservation row, and the 6 rows retargeted from{outputFile}to{receipt}fail too.npm run lint:ciexits 0 (check-contract-drift: 41 markers, 0 violations; workflow shellcheck: 0 new findings).Regression test added?
Platforms tested
Linux (WSL2) under bash 5.x. The new helpers were also run directly under
bash:3.2.57(the macOS system bash) andbash:5.2.37containers with the same results: BOM+CRLF receipt, word boundary, non-ASCII marker with no final newline, revisionmarker_received, lost-$TSstalled, unsafe phase dir refused. Windows:gsd_receipt_pathemits a mixedC:/...path throughcygpath -mwhen it is present. That is not exercised locally; CI's Windows matrix runs the rows.Runtimes tested
Checklist
Fixes #5182— PR will be auto-closed if missingconfirmed-buglabelnpm test). Locally the full run exited 1. I re-ran each failing file alone with an emptyHOME/GSD_HOME. Five went green (machine-local config noise).host-integration.test.cjsfails the same 5 rows on untouchednext@ af46465. Two were mine and are fixed in this commit: a doc comment the contract-drift scanner read as a marker, and the compact-content benchmark baseline refreshed forplan-phase.md's growth. CI is the authority here..changeset/fragment added if this is a user-facing fix (pr:filled after open)Breaking changes
No API or output-format change:
gsd_stall_watchstill prints one ofmarker_received | active | waiting | stalled. Two behavior changes, both disclosed in the issue:The second argument of
gsd_stall_watchis now the receipt, not the host output file. Its only callers are the five plan-phase sites.ADRs consulted: ADR-1239 (per-host behavior "explicit and testable rather than scattered
runtime === '…'checks"; per-host values "documentation-sourced ... never inferred": the fix reads no host file and has no runtime branch), ADR-1016 (runtime capability descriptor; no new per-runtime conditional), ADR-5057 (Proposed; one owner per workflow verdict:gsd_return_markeris the single marker matcher for the watch and the checker routing), ADR-857 Phase 6 (plan-phase.mdstays under its 98,300-byte freeze: 98,280 LF bytes after this change), ADR-1610 (agent size caps: gsd-plan-checker and gsd-planner stay under their tier caps), ADR-1235 and ADR-3574 (the ClaudedisallowedToolsinjection step readsREADONLY_AGENT_DISALLOWED_TOOLS; this PR changes one entry, not the mechanism), #767 (the Group A/B split).