From 27af818312054bd35dc7fdd89e7a8628235fda7f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 9 Jun 2026 08:42:16 -0400 Subject: [PATCH 1/5] fix(#925): context monitor echoes the invoking hook event name (#927) Read `data.hook_event_name` from the stdin payload and fall back to the Gemini/non-Gemini heuristic only when the field is absent or blank. Fixes Claude Code rejecting output with "expected Stop but got PostToolUse" when the monitor is called by Stop, SubagentStop, or PreCompact hooks. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (cherry picked from commit cf6d3b3be5a3342780494c436f31871b05caacfc) --- .../925-context-monitor-hook-event-name.md | 5 + hooks/gsd-context-monitor.js | 3 +- ...5-context-monitor-hook-event-name.test.cjs | 205 ++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 .changeset/925-context-monitor-hook-event-name.md create mode 100644 tests/bug-925-context-monitor-hook-event-name.test.cjs diff --git a/.changeset/925-context-monitor-hook-event-name.md b/.changeset/925-context-monitor-hook-event-name.md new file mode 100644 index 000000000..8a3dd76d3 --- /dev/null +++ b/.changeset/925-context-monitor-hook-event-name.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 926 +--- +**`gsd-context-monitor.js` now echoes the actual invoking hook event name** — instead of hardcoding `hookEventName: "PostToolUse"` (or `"AfterTool"` for Gemini), the hook reads `data.hook_event_name` from the stdin payload and falls back to the runtime heuristic only when the field is absent or blank; this fixes Claude Code rejecting hook output with `"expected Stop but got PostToolUse"` when the monitor is invoked by the Stop, SubagentStop, or PreCompact hooks registered in PR #821. (#925) diff --git a/hooks/gsd-context-monitor.js b/hooks/gsd-context-monitor.js index 5aa93f67f..991535f40 100644 --- a/hooks/gsd-context-monitor.js +++ b/hooks/gsd-context-monitor.js @@ -182,7 +182,8 @@ process.stdin.on('end', () => { const output = { hookSpecificOutput: { - hookEventName: process.env.GEMINI_API_KEY ? "AfterTool" : "PostToolUse", + hookEventName: (data.hook_event_name && data.hook_event_name.trim()) + || (process.env.GEMINI_API_KEY ? "AfterTool" : "PostToolUse"), additionalContext: message } }; diff --git a/tests/bug-925-context-monitor-hook-event-name.test.cjs b/tests/bug-925-context-monitor-hook-event-name.test.cjs new file mode 100644 index 000000000..9257d5936 --- /dev/null +++ b/tests/bug-925-context-monitor-hook-event-name.test.cjs @@ -0,0 +1,205 @@ +/** + * Regression test for bug #925 + * + * hooks/gsd-context-monitor.js hardcodes `hookEventName: "PostToolUse"` (or + * "AfterTool" for Gemini) regardless of which hook event invoked it. Since + * PR #821 the same script is also registered under Stop, SubagentStop, and + * PreCompact in hooks/hooks.json. Claude Code rejects output whose + * hookSpecificOutput.hookEventName doesn't echo the triggering event: + * + * "expected Stop but got PostToolUse" + * + * Fix: derive hookEventName from the parsed stdin payload's `hook_event_name` + * field (already available in the data object), falling back to the + * Gemini / non-Gemini heuristic for runtimes that don't send it. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js'); + +/** + * Write a bridge metrics file and invoke the context monitor with the given + * payload fields. Returns the parsed stdout object (or null if the hook + * produced no output). + * + * remainingPct must be <= 35 to cross the WARNING threshold so the hook + * actually emits output. + */ +function runMonitor({ hookEventName, sessionId, remainingPct = 30, usedPct = 70, env = {} }) { + const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`); + fs.writeFileSync(bridgePath, JSON.stringify({ + session_id: sessionId, + remaining_percentage: remainingPct, + used_pct: usedPct, + timestamp: Math.floor(Date.now() / 1000), + })); + + const payload = { session_id: sessionId, cwd: os.tmpdir() }; + if (hookEventName !== undefined) { + payload.hook_event_name = hookEventName; + } + + let stdout = ''; + try { + stdout = execFileSync(process.execPath, [MONITOR_PATH], { + input: JSON.stringify(payload), + encoding: 'utf-8', + timeout: 5000, + env: { ...process.env, ...env }, + }); + } catch (e) { + stdout = e.stdout || ''; + } finally { + try { fs.unlinkSync(bridgePath); } catch { /* noop */ } + try { + fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`)); + } catch { /* noop */ } + } + + if (!stdout) return null; + return JSON.parse(stdout); +} + +function makeSessionId(suffix) { + return `test-925-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +// ─── hookEventName echoing ──────────────────────────────────────────────────── + +describe('bug #925: context monitor echoes the invoking hook event name', () => { + test('hookEventName is "Stop" when payload contains hook_event_name: "Stop"', () => { + const out = runMonitor({ hookEventName: 'Stop', sessionId: makeSessionId('stop') }); + assert.ok(out, 'hook must emit output when context is below WARNING threshold (remaining=30)'); + assert.strictEqual( + out.hookSpecificOutput?.hookEventName, + 'Stop', + `Expected hookEventName "Stop" but got "${out.hookSpecificOutput?.hookEventName}". ` + + 'The hook must echo the hook_event_name from stdin, not hardcode "PostToolUse".' + ); + }); + + test('hookEventName is "SubagentStop" when payload contains hook_event_name: "SubagentStop"', () => { + const out = runMonitor({ hookEventName: 'SubagentStop', sessionId: makeSessionId('subagent-stop') }); + assert.ok(out, 'hook must emit output when context is below WARNING threshold'); + assert.strictEqual( + out.hookSpecificOutput?.hookEventName, + 'SubagentStop', + `Expected hookEventName "SubagentStop" but got "${out.hookSpecificOutput?.hookEventName}".` + ); + }); + + test('hookEventName is "PreCompact" when payload contains hook_event_name: "PreCompact"', () => { + const out = runMonitor({ hookEventName: 'PreCompact', sessionId: makeSessionId('precompact') }); + assert.ok(out, 'hook must emit output when context is below WARNING threshold'); + assert.strictEqual( + out.hookSpecificOutput?.hookEventName, + 'PreCompact', + `Expected hookEventName "PreCompact" but got "${out.hookSpecificOutput?.hookEventName}".` + ); + }); + + test('hookEventName is "PostToolUse" when payload contains hook_event_name: "PostToolUse"', () => { + const out = runMonitor({ hookEventName: 'PostToolUse', sessionId: makeSessionId('posttools') }); + assert.ok(out, 'hook must emit output when context is below WARNING threshold'); + assert.strictEqual( + out.hookSpecificOutput?.hookEventName, + 'PostToolUse', + `Expected hookEventName "PostToolUse" but got "${out.hookSpecificOutput?.hookEventName}".` + ); + }); +}); + +// ─── Fallback behaviour (no hook_event_name in payload) ────────────────────── + +describe('bug #925: context monitor falls back to heuristic when hook_event_name absent', () => { + test('falls back to "PostToolUse" when hook_event_name is absent (non-Gemini)', () => { + const env = { ...process.env }; + delete env.GEMINI_API_KEY; + const out = runMonitor({ + hookEventName: undefined, + sessionId: makeSessionId('fallback-non-gemini'), + env: { GEMINI_API_KEY: '' }, // ensure unset + }); + assert.ok(out, 'hook must emit output when context is below WARNING threshold'); + assert.strictEqual( + out.hookSpecificOutput?.hookEventName, + 'PostToolUse', + `Expected fallback "PostToolUse" for non-Gemini but got "${out.hookSpecificOutput?.hookEventName}".` + ); + }); + + test('falls back to "AfterTool" when hook_event_name is absent and GEMINI_API_KEY is set', () => { + const out = runMonitor({ + hookEventName: undefined, + sessionId: makeSessionId('fallback-gemini'), + env: { GEMINI_API_KEY: 'fake-key-for-test' }, + }); + assert.ok(out, 'hook must emit output when context is below WARNING threshold'); + assert.strictEqual( + out.hookSpecificOutput?.hookEventName, + 'AfterTool', + `Expected fallback "AfterTool" for Gemini but got "${out.hookSpecificOutput?.hookEventName}".` + ); + }); + + test('falls back to "PostToolUse" when hook_event_name is an empty string (non-Gemini)', () => { + const out = runMonitor({ + hookEventName: '', + sessionId: makeSessionId('fallback-empty'), + env: { GEMINI_API_KEY: '' }, + }); + assert.ok(out, 'hook must emit output when context is below WARNING threshold'); + assert.strictEqual( + out.hookSpecificOutput?.hookEventName, + 'PostToolUse', + `Expected fallback "PostToolUse" for empty hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".` + ); + }); + + test('falls back to "PostToolUse" when hook_event_name is whitespace-only (non-Gemini)', () => { + // trim() makes " " → "" which is falsy, so the || fallback fires + const out = runMonitor({ + hookEventName: ' ', + sessionId: makeSessionId('fallback-whitespace'), + env: { GEMINI_API_KEY: '' }, + }); + assert.ok(out, 'hook must emit output when context is below WARNING threshold'); + assert.strictEqual( + out.hookSpecificOutput?.hookEventName, + 'PostToolUse', + `Expected fallback "PostToolUse" for whitespace-only hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".` + ); + }); +}); + +// ─── Critical threshold also echoes the event name ─────────────────────────── + +describe('bug #925: critical threshold warning also uses correct hookEventName', () => { + test('CRITICAL warning emitted under Stop also echoes "Stop"', () => { + const out = runMonitor({ + hookEventName: 'Stop', + sessionId: makeSessionId('critical-stop'), + remainingPct: 20, + usedPct: 80, + }); + assert.ok(out, 'hook must emit output at critical threshold (remaining=20)'); + assert.strictEqual( + out.hookSpecificOutput?.hookEventName, + 'Stop', + `Expected hookEventName "Stop" at critical threshold, got "${out.hookSpecificOutput?.hookEventName}".` + ); + assert.match( + out.hookSpecificOutput?.additionalContext || '', + /CONTEXT CRITICAL/, + 'Output should be a CRITICAL warning at remaining=20' + ); + }); +}); From a6229ec123cf1d5bd65c6490cc295f97f29ffbf2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 9 Jun 2026 08:42:25 -0400 Subject: [PATCH 2/5] fix(#921,#922): orchestrators must not fork; plan-phase Agent gate is attempt-based (#926) `context: fork` strips the `Agent` tool from a subagent's environment. Spawning orchestrators (`/gsd-autonomous`, `/gsd-execute-phase`, `/gsd-plan-phase`) depend on `Agent` to dispatch sub-agents; running them forked silently disables the core capability they exist to provide (#921). Remove `context: fork` from all three command frontmatter files. `effort: xhigh` (introduced by #769) is preserved. The `` Agent-availability guard added by #913 was checking whether `Agent` was present *before* attempting the call. On runtimes where the tool list is dynamically resolved this produced false-negative aborts in sessions that have the tool (#922). Replace the introspection-based pattern with an attempt-based gate: always attempt the `Agent()` call; stop only if a real tool-unavailable error is returned. This preserves #853's backgrounded-session close-off and false negatives. Tests updated: enh-769-context-fork-effort.install.test.cjs asserts the three orchestrators lack `context: fork` and that the converter still passes the field through for non-orchestrator commands; plan-phase-drift- guard.test.cjs adds four assertions for the attempt-based gate language; workflow-size-budget unchanged (budgets not exceeded). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (cherry picked from commit b866b95296aa6a277baa578c5e9c042e122ca64b) --- .changeset/921-922-orchestrators-no-fork.md | 5 ++ commands/gsd/autonomous.md | 1 - commands/gsd/execute-phase.md | 1 - commands/gsd/plan-phase.md | 1 - docs/COMMANDS.md | 2 +- gsd-core/workflows/plan-phase.md | 11 +-- ...h-769-context-fork-effort.install.test.cjs | 70 +++++++++++-------- tests/plan-phase-drift-guard.test.cjs | 67 ++++++++++++++++++ tests/workflow-size-budget.test.cjs | 4 +- 9 files changed, 124 insertions(+), 38 deletions(-) create mode 100644 .changeset/921-922-orchestrators-no-fork.md diff --git a/.changeset/921-922-orchestrators-no-fork.md b/.changeset/921-922-orchestrators-no-fork.md new file mode 100644 index 000000000..92e481ef7 --- /dev/null +++ b/.changeset/921-922-orchestrators-no-fork.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 921 +--- +**`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous` no longer carry `context: fork`** — these are spawning orchestrators; a forked subagent context has no `Agent` tool, preventing them from spawning the subagents they require. `effort: xhigh` is preserved. Fixes `/gsd:autonomous` halting with "running as a forked subagent" on 1.4.1 (#921). Also replaces the introspection-based Agent-availability check in `plan-phase`'s `` block with an attempt-based gate: the workflow now always attempts the `Agent()` call and only stops if a real tool-unavailable error is returned, eliminating false-negative aborts in top-level sessions (#922). diff --git a/commands/gsd/autonomous.md b/commands/gsd/autonomous.md index fce955925..9a13ebf21 100644 --- a/commands/gsd/autonomous.md +++ b/commands/gsd/autonomous.md @@ -2,7 +2,6 @@ name: gsd:autonomous description: Run all remaining phases autonomously — discuss→plan→execute per phase argument-hint: "[--from N] [--to N] [--only N] [--interactive]" -context: fork effort: xhigh allowed-tools: - Read diff --git a/commands/gsd/execute-phase.md b/commands/gsd/execute-phase.md index b7acb5885..c7eb7bdca 100644 --- a/commands/gsd/execute-phase.md +++ b/commands/gsd/execute-phase.md @@ -2,7 +2,6 @@ name: gsd:execute-phase description: Execute all plans in a phase with wave-based parallelization argument-hint: " [--wave N] [--gaps-only] [--interactive] [--tdd]" -context: fork effort: xhigh allowed-tools: - Read diff --git a/commands/gsd/plan-phase.md b/commands/gsd/plan-phase.md index 47549dfbf..d4f071145 100644 --- a/commands/gsd/plan-phase.md +++ b/commands/gsd/plan-phase.md @@ -2,7 +2,6 @@ name: gsd:plan-phase description: Create detailed phase plan (PLAN.md) with verification loop argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--tdd] [--mvp]" -context: fork effort: xhigh allowed-tools: - Read diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 438bcb6dd..4b3b67ba0 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -14,7 +14,7 @@ The hyphen and colon forms are *runtime-specific spellings of the same command*. ### Skill Runtime Behavior (Claude Code) -Heavy workflow skills (`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous`) carry `context: fork` in their frontmatter. On Claude Code, this runs each skill in an isolated subagent context window, protecting the main session's context budget. The skills also declare `effort: xhigh`, signalling maximum token budget to the runtime. +Heavy workflow skills (`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous`) declare `effort: xhigh`, signalling maximum token budget to the runtime. These skills are spawning orchestrators — they must run at top level so they retain the `Agent` tool needed to spawn subagents. They do **not** carry `context: fork` (see #921). Quick-status skills (`/gsd-progress`, `/gsd-stats`) declare `effort: low`, directing the runtime to use a minimal token budget for fast reads. diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index 303b5d299..e3989395a 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -39,10 +39,13 @@ discuss-phase early-exit path. It does NOT authorize inline role performance for plan-phase agents. **Other runtimes:** -If the Agent tool is genuinely absent (e.g. a backgrounded Claude Code agent per -#853, or a non-Claude runtime that does not expose Agent/agent), log the gap and -stop — do NOT perform researcher/planner/checker roles inline. Independent agent -contexts are required for the plan-checker gate to be meaningful. +Do not pre-judge Agent availability by introspection. Always attempt the actual +Agent() call for gsd-phase-researcher, gsd-planner, and gsd-plan-checker. Only +a real tool-unavailable error returned by Agent() is a reliable absence signal — +never stop based on a self-assessed "I think Agent is unavailable." If the call +fails with a tool-unavailable error, log the gap and stop — do NOT collapse +researcher/planner/checker roles inline. Independent agent contexts are required +for the plan-checker gate to be meaningful. diff --git a/tests/enh-769-context-fork-effort.install.test.cjs b/tests/enh-769-context-fork-effort.install.test.cjs index 125730723..6fff8c9df 100644 --- a/tests/enh-769-context-fork-effort.install.test.cjs +++ b/tests/enh-769-context-fork-effort.install.test.cjs @@ -4,20 +4,28 @@ // transformation is asserted — not inspected for string presence. /** - * #769 — context:fork + effort: frontmatter on heavy workflow skills. + * #769 — effort: frontmatter on heavy workflow skills. + * #921 — spawning orchestrators must NOT carry context: fork. + * + * Context: context:fork was added by #769 to protect context budget, but + * plan-phase, execute-phase, and autonomous are spawning orchestrators — a + * forked subagent has no Agent/Task tool, breaking their core function. + * effort: xhigh is preserved; context: fork is removed from these three. + * The converter still passes context: fork through if a source file has it + * (for any future leaf skill that legitimately needs isolation). * * Verifies: - * 1. Source commands/gsd/autonomous.md has context: fork and effort: xhigh - * 2. Source commands/gsd/execute-phase.md has context: fork and effort: xhigh - * 3. Source commands/gsd/plan-phase.md has context: fork and effort: xhigh + * 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: xhigh + * 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: xhigh + * 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: xhigh * 4. Source commands/gsd/progress.md has effort: low * 5. Source commands/gsd/stats.md has effort: low - * 6. Claude global install: SKILL.md for autonomous has context: fork and effort: xhigh - * 7. Claude global install: SKILL.md for execute-phase has context: fork and effort: xhigh - * 8. Claude global install: SKILL.md for plan-phase has context: fork and effort: xhigh + * 6. Claude global install: SKILL.md for autonomous has effort: xhigh, NOT context: fork + * 7. Claude global install: SKILL.md for execute-phase has effort: xhigh, NOT context: fork + * 8. Claude global install: SKILL.md for plan-phase has effort: xhigh, NOT context: fork * 9. Claude global install: SKILL.md for progress has effort: low * 10. Claude global install: SKILL.md for stats has effort: low - * 11. convertClaudeCommandToClaudeSkill preserves context: fork field + * 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills) * 12. convertClaudeCommandToClaudeSkill preserves effort: field */ @@ -90,11 +98,15 @@ function runClaudeGlobalInstall(claudeHome) { // ─── describe 1: Source command files have correct frontmatter ──────────────── -describe('#769 source commands: heavy skills have context: fork and effort: xhigh', () => { - test('commands/gsd/autonomous.md has context: fork', () => { +// #921/#922: spawning orchestrators must NOT carry context: fork — a forked +// subagent has no Agent/Task tool, making it impossible for orchestrators to +// spawn their required subagents. context: fork is appropriate only for leaf +// skills that do not themselves dispatch agents. effort: xhigh is preserved. +describe('#769/#921 source commands: spawning orchestrators have effort: xhigh but NOT context: fork', () => { + test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => { const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); - assert.match(fm, /^context:[ \t]*fork$/m, - `autonomous.md frontmatter must have context: fork\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); }); test('commands/gsd/autonomous.md has effort: xhigh', () => { @@ -103,10 +115,10 @@ describe('#769 source commands: heavy skills have context: fork and effort: xhig `autonomous.md frontmatter must have effort: xhigh\nActual:\n${fm}`); }); - test('commands/gsd/execute-phase.md has context: fork', () => { + test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => { const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); - assert.match(fm, /^context:[ \t]*fork$/m, - `execute-phase.md frontmatter must have context: fork\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); }); test('commands/gsd/execute-phase.md has effort: xhigh', () => { @@ -115,10 +127,10 @@ describe('#769 source commands: heavy skills have context: fork and effort: xhig `execute-phase.md frontmatter must have effort: xhigh\nActual:\n${fm}`); }); - test('commands/gsd/plan-phase.md has context: fork', () => { + test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => { const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); - assert.match(fm, /^context:[ \t]*fork$/m, - `plan-phase.md frontmatter must have context: fork\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); }); test('commands/gsd/plan-phase.md has effort: xhigh', () => { @@ -237,7 +249,9 @@ describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort f // ─── describe 3: Claude global install — SKILL.md files include new fields ──── -describe('#769 Claude global install: SKILL.md files preserve context: fork and effort:', () => { +// #921/#922: after install, spawning orchestrators must NOT carry context: fork +// in their emitted SKILL.md. effort: xhigh is still emitted (preserved from source). +describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files have effort: xhigh but NOT context: fork', () => { let tmpDir; let claudeHome; @@ -251,12 +265,12 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and cleanup(tmpDir); }); - test('gsd-autonomous SKILL.md has context: fork after global install', () => { + test('gsd-autonomous SKILL.md does NOT have context: fork after global install (#921)', () => { runClaudeGlobalInstall(claudeHome); const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md'); const fm = readFrontmatter(skillPath); - assert.match(fm, /^context:[ \t]*fork$/m, - `gsd-autonomous SKILL.md must have context: fork\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); }); test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => { @@ -267,12 +281,12 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and `gsd-autonomous SKILL.md must have effort: xhigh\nActual:\n${fm}`); }); - test('gsd-execute-phase SKILL.md has context: fork after global install', () => { + test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => { runClaudeGlobalInstall(claudeHome); const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md'); const fm = readFrontmatter(skillPath); - assert.match(fm, /^context:[ \t]*fork$/m, - `gsd-execute-phase SKILL.md must have context: fork\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); }); test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => { @@ -283,12 +297,12 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and `gsd-execute-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`); }); - test('gsd-plan-phase SKILL.md has context: fork after global install', () => { + test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => { runClaudeGlobalInstall(claudeHome); const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md'); const fm = readFrontmatter(skillPath); - assert.match(fm, /^context:[ \t]*fork$/m, - `gsd-plan-phase SKILL.md must have context: fork\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); }); test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => { diff --git a/tests/plan-phase-drift-guard.test.cjs b/tests/plan-phase-drift-guard.test.cjs index 9ef1e5bf3..3ae66f09d 100644 --- a/tests/plan-phase-drift-guard.test.cjs +++ b/tests/plan-phase-drift-guard.test.cjs @@ -193,3 +193,70 @@ describe('plan-phase workflow: top-level spawn guard (#913)', () => { ); }); }); + +// ─── (D) Attempt-based Agent gate (#922) ───────────────────────────────────── + +describe('plan-phase workflow: attempt-based Agent availability gate (#922)', () => { + // Extract the runtime_compatibility block for targeted assertions + const rtBlock = (() => { + const m = workflow.match(/([\s\S]*?)<\/runtime_compatibility>/); + return m ? m[1] : ''; + })(); + + // Extract the "Other runtimes" clause specifically + const otherRuntimesClause = (() => { + const m = rtBlock.match(/\*\*Other runtimes[^*]*\*\*[^\n]*\n([\s\S]*?)(?=\n\*\*|$)/); + return m ? m[0] : rtBlock; + })(); + + test('Other runtimes clause does not authorize stopping on a self-assessed absence (#922)', () => { + // The pre-#922 wording ("if the Agent tool is genuinely absent") let the model + // self-assess and stop without ever attempting a call. The fixed wording must + // not contain phrasing that authorizes that pattern. + const forbiddenPatterns = [ + /if the Agent tool is genuinely absent/i, + /if.*Agent.*genuinely absent/i, + ]; + for (const pattern of forbiddenPatterns) { + assert.ok( + !pattern.test(otherRuntimesClause), + `plan-phase "Other runtimes" clause must not authorize stopping on a self-assessed Agent absence — ` + + `use attempt-based gate instead (#922). Found: ${otherRuntimesClause.trim()}` + ); + } + }); + + test('Other runtimes clause pins "Always attempt the actual Agent() call" language (#922)', () => { + // Pin the exact contract phrase so a future edit that changes to "try to determine + // availability" or "check if Agent is available" does not silently reintroduce introspection. + assert.ok( + otherRuntimesClause.includes('Always attempt the actual') || + otherRuntimesClause.includes('always attempt the actual'), + `plan-phase "Other runtimes" clause must pin "Always attempt the actual Agent() call" (or equivalent) (#922). ` + + `Found: ${otherRuntimesClause.trim()}` + ); + }); + + test('Other runtimes clause pins "real tool-unavailable error" as the only valid stop signal (#922)', () => { + // Must tie the stop to a real returned error, not a self-assessed absence. + assert.ok( + otherRuntimesClause.includes('real tool-unavailable error') || + otherRuntimesClause.includes('tool-unavailable error returned'), + `plan-phase "Other runtimes" clause must state only a real tool-unavailable error from Agent() authorizes stopping (#922). ` + + `Found: ${otherRuntimesClause.trim()}` + ); + }); + + test('Other runtimes clause still prohibits inline role collapse (#922 preserves #913)', () => { + // Even after the attempt-based rewrite the clause must keep the no-inline-collapse guard. + const hasNoInline = + otherRuntimesClause.toLowerCase().includes('do not') && + (otherRuntimesClause.toLowerCase().includes('inline') || + otherRuntimesClause.toLowerCase().includes('collapse')); + assert.ok( + hasNoInline, + `plan-phase "Other runtimes" clause must still prohibit inline role collapse even with the attempt-based gate (#922). ` + + `Found: ${otherRuntimesClause.trim()}` + ); + }); +}); diff --git a/tests/workflow-size-budget.test.cjs b/tests/workflow-size-budget.test.cjs index 7713b2be7..c8b96bd9d 100644 --- a/tests/workflow-size-budget.test.cjs +++ b/tests/workflow-size-budget.test.cjs @@ -82,7 +82,7 @@ const GRACE = 3000; // XL high-water mark is execute-phase.md — note that under LINES it was // plan-phase; bytes genuinely re-rank the tier, which is the point of #717. // actualMax=92525 (execute-phase, #913 inline-fallback scope clarification); -// slack=475 ≤ GRACE. plan-phase.md=90501 (#913 runtime_compatibility block + label rename), new-project.md=58110. +// slack=475 ≤ GRACE. plan-phase.md=90748 (#922 attempt-based Agent gate), new-project.md=58110. const XL_BUDGET = 93000; // LARGE high-water mark is docs-update.md. actualMax=54410 (#891 launcher shim expansion); // slack=1590 ≤ GRACE. quick.md=45710, autonomous.md=38030. @@ -96,7 +96,7 @@ const DEFAULT_BUDGET = 40000; // pattern that future shrinks should follow. Byte counts noted for reference. const XL_WORKFLOWS = new Set([ 'execute-phase', // 92525 bytes (tier high-water mark; grew in #913 inline-fallback scope clarification) - 'plan-phase', // 90501 bytes (grew in #913 runtime_compatibility block + label rename) + 'plan-phase', // 90748 bytes (grew in #922 attempt-based Agent gate) 'new-project', // 55850 bytes ]); From 3884b37b09ba305b09a7dec0c48c2822f1fe282b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:07:45 -0400 Subject: [PATCH 3/5] chore: bump version to 1.4.2 for hotfix --- .claude-plugin/plugin.json | 2 +- gemini-extension.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6b915ec15..0d9e8976f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "gsd-core", "displayName": "GSD Core", - "version": "1.4.1", + "version": "1.4.2", "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.", "author": { "name": "open-gsd", diff --git a/gemini-extension.json b/gemini-extension.json index 57370f463..23cbd6dc8 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "gsd-core", - "version": "1.4.1", + "version": "1.4.2", "description": "GSD Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads gsd's operating context into every Gemini CLI session.", "contextFileName": "GEMINI.md" } diff --git a/package-lock.json b/package-lock.json index fa4fbf76e..56835eeb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opengsd/gsd-core", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opengsd/gsd-core", - "version": "1.4.1", + "version": "1.4.2", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.84", diff --git a/package.json b/package.json index 48cfb22c0..c5a4c7ad1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opengsd/gsd-core", - "version": "1.4.1", + "version": "1.4.2", "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.", "bin": { "gsd-core": "bin/install.js", From 732a32ea83349d0d854a2f1fbff0bb9d9c62d9d8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:14:28 -0400 Subject: [PATCH 4/5] fix(changelog): rephrase Claude Code parenthetical in 1.4.1 notes for #1777 purity gate --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c96577d36..29f46861b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`init execute-phase` and `cmdCommit` now produce correct `branch_name` when `project_code` is set** — the `{phase}` substitution in `phase_branch_template` now calls `normalizePhaseName()`, stripping the project-code prefix and zero-padding the number, so the generated branch is e.g. `gsd/phase-01-foundation` instead of `gsd/phase-CK-01-foundation`. Both the execute-phase output path (`src/init.cts`) and the pre-execution commit path (`src/commands.cts`) are fixed. (#904) (#904) - **`syncStateFrontmatter` no longer strips `current_phase`, `current_phase_name`, `current_plan`, and `progress` from `STATE.md`** — when body annotations are absent (e.g. after an agent rewrites the body), the existing frontmatter values for those scalars are now preserved, mirroring the fallback already applied in `cmdStateJson`. (#905) (#905) - **Top-level Claude Code `/gsd-plan-phase` now always spawns the researcher/planner/plan-checker agents instead of collapsing them inline** — a `` block after `` makes the Agent-availability requirement explicit and documents that the workflow fails-closed (stops with a clear log message) in genuinely Agent-less contexts; seven "ORCHESTRATOR RULE — CODEX RUNTIME" labels are renamed to "ALL RUNTIMES" so the guard applies universally; `execute-phase.md` scopes its existing "Other runtimes" inline-fallback prose to non-Claude contexts, preserving the #853 backgrounded-agent behaviour. (#913) (#913) -- **`/gsd-manager` and `/gsd-autonomous --interactive` no longer silently skip worktree isolation and independent verification on Claude Code.** They dispatched plan/execute as background agents, but a backgrounded Claude Code agent has no Agent/Task tool and cannot spawn the nested executors, plan-checker, or verifier — so isolation and verification silently never ran. Both workflows now resolve the runtime and run plan/execute inline on Claude Code (background dispatch is kept on runtimes that support nested subagents). (#863) +- **`/gsd-manager` and `/gsd-autonomous --interactive` no longer silently skip worktree isolation and independent verification on Claude Code.** They dispatched plan/execute as background agents, but a backgrounded Claude Code agent has no Agent/Task tool and cannot spawn the nested executors, plan-checker, or verifier — so isolation and verification silently never ran. Both workflows now resolve the runtime and run plan/execute inline on Claude Code; background dispatch is kept on runtimes that support nested subagents. (#863) - **Installer no longer leaks `gsd-cmd-rewrites-*` temp directories.** Each install that emitted slash commands left one `fs.mkdtempSync` directory under the system temp root; on `tmpfs` `/tmp` hosts these accumulated and consumed RAM-backed storage. `installRuntimeArtifacts()` now removes the temp copy in a `finally` once command files are copied. (#862) - Corrected the installer `--help` profile skill counts: `core` now shows 8 (was 7) and `standard` shows 14 (was 13), both derived from `PROFILES` so they can't drift again; the `full` line drops the stale hardcoded `66` for `all skills`. (#834) (#847) From 6a8b4b0ecf9f0b6690ecd5ac9168caa08e8d6a82 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:25:27 +0000 Subject: [PATCH 5/5] chore: promote CHANGELOG for v1.4.2 --- .changeset/921-922-orchestrators-no-fork.md | 5 ----- .changeset/925-context-monitor-hook-event-name.md | 5 ----- CHANGELOG.md | 7 +++++++ 3 files changed, 7 insertions(+), 10 deletions(-) delete mode 100644 .changeset/921-922-orchestrators-no-fork.md delete mode 100644 .changeset/925-context-monitor-hook-event-name.md diff --git a/.changeset/921-922-orchestrators-no-fork.md b/.changeset/921-922-orchestrators-no-fork.md deleted file mode 100644 index 92e481ef7..000000000 --- a/.changeset/921-922-orchestrators-no-fork.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -type: Fixed -pr: 921 ---- -**`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous` no longer carry `context: fork`** — these are spawning orchestrators; a forked subagent context has no `Agent` tool, preventing them from spawning the subagents they require. `effort: xhigh` is preserved. Fixes `/gsd:autonomous` halting with "running as a forked subagent" on 1.4.1 (#921). Also replaces the introspection-based Agent-availability check in `plan-phase`'s `` block with an attempt-based gate: the workflow now always attempts the `Agent()` call and only stops if a real tool-unavailable error is returned, eliminating false-negative aborts in top-level sessions (#922). diff --git a/.changeset/925-context-monitor-hook-event-name.md b/.changeset/925-context-monitor-hook-event-name.md deleted file mode 100644 index 8a3dd76d3..000000000 --- a/.changeset/925-context-monitor-hook-event-name.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -type: Fixed -pr: 926 ---- -**`gsd-context-monitor.js` now echoes the actual invoking hook event name** — instead of hardcoding `hookEventName: "PostToolUse"` (or `"AfterTool"` for Gemini), the hook reads `data.hook_event_name` from the stdin payload and falls back to the runtime heuristic only when the field is absent or blank; this fixes Claude Code rejecting hook output with `"expected Stop but got PostToolUse"` when the monitor is invoked by the Stop, SubagentStop, or PreCompact hooks registered in PR #821. (#925) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f46861b..8d1fbb669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.4.2] - 2026-06-09 + +### Fixed + +- **`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous` no longer carry `context: fork`** — these are spawning orchestrators; a forked subagent context has no `Agent` tool, preventing them from spawning the subagents they require. `effort: xhigh` is preserved. Fixes `/gsd:autonomous` halting with "running as a forked subagent" on 1.4.1 (#921). Also replaces the introspection-based Agent-availability check in `plan-phase`'s `` block with an attempt-based gate: the workflow now always attempts the `Agent()` call and only stops if a real tool-unavailable error is returned, eliminating false-negative aborts in top-level sessions (#922). (#921) +- **`gsd-context-monitor.js` now echoes the actual invoking hook event name** — instead of hardcoding `hookEventName: "PostToolUse"` (or `"AfterTool"` for Gemini), the hook reads `data.hook_event_name` from the stdin payload and falls back to the runtime heuristic only when the field is absent or blank; this fixes Claude Code rejecting hook output with `"expected Stop but got PostToolUse"` when the monitor is invoked by the Stop, SubagentStop, or PreCompact hooks registered in PR #821. (#925) (#926) + ## [1.4.1] - 2026-06-09 ### Changed