* feat(plan-phase): --research-phase flag absorbs deleted /gsd-research-phase + scrub stale refs (#3042, #3044)
#3042 (orphaned research-phase): /gsd-research-phase had a workflow file
but no slash-command stub. Rather than restore the orphan, the research-
only capability is now a flag on /gsd-plan-phase:
/gsd-plan-phase --research-phase <N>
When set, the workflow scopes to phase N, runs the research step (Section
5 of the existing plan-phase workflow), then early-exits before the
planner/plan-checker/verifier chain.
Per RCA against the deleted standalone, the flag adds two modifiers to
fully cover the original surface (Option B from the RCA discussion):
- --view : print existing RESEARCH.md to stdout, no spawn. Cheapest mode
for the correction-without-replanning loop the issue reporter
explicitly called out. Errors with a clear hint if RESEARCH.md is
missing.
- --research : reuse the existing "force re-research" semantics. In
research-only mode this skips the existing-RESEARCH.md prompt and
re-spawns unconditionally.
- Neither flag, RESEARCH.md exists : prompt update/view/skip. Mirrors
the deleted standalone's existing-artifact menu (#3042 RCA).
#3044 (stale slash-command refs): scrubbed five deleted commands from
all user-facing surfaces, including English docs, 4 localized doc sets
(ja-JP, ko-KR, zh-CN, pt-BR), workflows, templates, and references.
/gsd-check-todos → /gsd-capture --list
/gsd-new-workspace → /gsd-workspace --new
/gsd-status → /gsd-progress
/gsd-plan-milestone-gaps → table rows / orphan sections removed
(PR #3038 only scrubbed workflows/agent;
missed the docs surfaces this PR covers)
/gsd-research-phase → /gsd-plan-phase --research-phase
Includes a fix to docs/issue-driven-orchestration.md (PR #3036)
which itself referenced /gsd-new-workspace 4 times — self-correction.
Removed:
- get-shit-done/workflows/research-phase.md (orphan, capability
absorbed into --research-phase flag)
Tests:
- tests/bug-3042-3044-research-flag-and-stale-refs.test.cjs — 46
structural-IR tests across both bugs:
- argument-hint advertises --research-phase + --view
- workflow parses --research-phase, sets RESEARCH_ONLY,
early-exits before planner
- --view prints RESEARCH.md without spawning
- --research forces refresh in research-only mode
- existing-RESEARCH.md prompt path with update/view/skip
- workflows/research-phase.md is removed
- 5 deleted slash-commands absent from 17 English user-facing
surfaces + 16 localized doc surfaces (4 locales × 4 docs each)
- replacement command tokens present where deleted ones lived
6950/6950 full suite pass. Lints clean.
Closes#3042Closes#3044
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address all 8 CR findings on PR #3045
Major (3):
- get-shit-done/workflows/plan-phase.md:344 — added explicit early-exit
guard at Section 5.1: "Skip if RESEARCH_ONLY=true". Without it, an LLM
could fall through "use existing, skip to step 6" → planner spawn,
violating the research-only contract. The guard makes the early-exit
unreachable from any non-research-only branch.
- get-shit-done/references/continuation-format.md (3 examples) +
zh-CN/.../continuation-format.md (3 examples) — pointed to
`/gsd-plan-phase --research-phase` but docs/COMMANDS.md didn't
document the flag. Added a full --research-phase + --view + --research
modifier section to the /gsd-plan-phase flag table in COMMANDS.md so
the canonical reference matches the continuation examples.
Minor (5):
- docs/FEATURES.md:1632 — `/gsd-plan-phase --research-phase` →
`/gsd-plan-phase --research-phase <N>` (include required arg).
- get-shit-done/templates/README.md:46 — NN-VALIDATION.md producer
reverted from `/gsd-plan-phase --research-phase` (Nyquist) to plain
`/gsd-plan-phase` (Nyquist). VALIDATION.md is created during normal
Nyquist flow, not research-only mode — the bulk replacement was
wrong for that line.
- get-shit-done/workflows/help.md:89 — signature line was missing
`--research`; added it alongside `--research-phase` and `--view`.
- tests/bug-3042-3044-...:197 — promptHasView/promptHasSkip were
tautological (matched anywhere in 1700-line workflow). Tightened
to a proximity check anchored on "RESEARCH.md already exists" prompt
header within a 600-char window. Updated workflow to emit that
literal phrase.
- tests/feat-2840-...:95 — workspace assertion used `/gsd-workspace`
but the documented replacement is `/gsd-workspace --new`. Tightened
to require both tokens (in 3 places: requiredCommands list, regex
in conceptPairs, error message).
6950/6950 full suite pass. Lint clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(install): skip Gemini local commands/gsd when global GSD present (#3037)
Reporter showed that running `npx get-shit-done-cc --gemini --global`
followed by `--gemini --local` in a project creates the same 65 GSD
command files in both Gemini scopes:
- ~/.gemini/commands/gsd/ (user scope)
- <project>/.gemini/commands/gsd/ (workspace scope)
Gemini conflict-detects by command name across scopes and renames every
overlapping /gsd:* command to /workspace.gsd:* and /user.gsd:*, breaking
the documented /gsd:* namespace.
Fix: in bin/install.js, when handling --gemini --local, detect whether
~/.gemini/commands/gsd/ already exists with managed-shape content. If
so, skip the local copy and print a clear three-line warning explaining
the conflict avoidance. The user-scope install already provides the
same /gsd:* commands in this project; the local copy adds zero value.
Sibling fixes (test isolation):
- tests/install-minimal-all-runtimes.test.cjs: pass HOME/USERPROFILE
through the spawned installer's env so the developer's real
~/.gemini/commands/gsd/ doesn't trigger the new skip path during
test runs that want to assert the local-install populates
commands/gsd/.
- tests/gemini-namespacing.test.cjs: the "Gemini Install (Behavioral)"
describe block now creates an isolated tmpHome and points
process.env.HOME at it before calling install(false, 'gemini'),
with proper restore in afterEach.
Test:
- tests/bug-3037-gemini-duplicate-commands.test.cjs — 4 structural
tests:
1. global install populates HOME/.gemini/commands/gsd
2. local install AFTER global skips the local copy
3. local install with NO existing global still populates locally
(no-regression)
4. local install when HOME has .gemini/ but no GSD-managed
commands/gsd/ still populates locally (non-GSD-Gemini-user
no-regression)
6909/6909 full suite pass. Lints clean.
Closes#3037
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address CR feedback on PR #3041 — narrower detection + USERPROFILE restore
CR findings:
1. **bin/install.js (Major)** — userScopeHasGsd used
`fs.readdirSync(homeGeminiGsd).length > 0` which would skip the
local install for any non-empty directory, including a user who
hand-dropped a single override at ~/.gemini/commands/gsd/<thing>
.toml without ever running --gemini --global. Narrowed the
detection to require at least 3 canonical GSD command files
(help.toml, progress.toml, new-project.toml) — a marker that
ships in every GSD Gemini install (minimal mode included) and is
structurally impossible to produce by accident.
2. **tests/bug-3037-...:59 (Minor)** — beforeEach overwrites
process.env.USERPROFILE but afterEach only restores HOME, leaking
the temp home into later tests on Windows or any code path that
reads USERPROFILE. Added save/restore symmetric with HOME.
Plus added a 5th regression test covering the narrowed detection:
"local install when HOME has hand-dropped overrides UNDER commands/gsd/
(but no full GSD) still populates locally" — directly exercises the
edge case CR identified.
5/5 targeted tests pass. 6910/6910 full suite pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(workflows,docs): scrub stale /gsd-code-review-fix and /gsd-plan-milestone-gaps refs (#3029, #3034)
#2790 consolidated /gsd-code-review-fix into /gsd-code-review --fix and
deleted /gsd-plan-milestone-gaps in favor of inline gap planning as part
of /gsd-audit-milestone's output. The deletion was propagated through
some surfaces (#2950 covered help/do/settings/discuss-phase/etc.) but
several user-facing surfaces still emitted the old forms:
#3029 — /gsd-code-review-fix references in:
- agents/gsd-code-fixer.md (description, "Spawned by", recovery prose)
- get-shit-done/workflows/code-review.md (offer text)
- get-shit-done/workflows/execute-phase.md (offer text)
- get-shit-done/workflows/code-review-fix.md (internal retry hints)
- docs/INVENTORY.md (agent + workflow rows)
- docs/CONFIGURATION.md (workflow.code_review row)
- docs/USER-GUIDE.md (3 occurrences in walkthrough)
- docs/AGENTS.md (gsd-code-fixer agent stub)
- docs/FEATURES.md (commands list + REQ-REVIEW-04)
All replaced with /gsd-code-review --fix. Internal retry hints in the
workflow file itself updated to point at the new form. Release notes
(docs/RELEASE-*.md) and gsd-ns-review's "absorbed by" deletion note
left unchanged — historical/explanatory content.
#3034 — /gsd-plan-milestone-gaps references in:
- get-shit-done/workflows/audit-milestone.md (<offer_next> blocks for
gaps_found and tech_debt: lines 281, 323)
- commands/gsd/complete-milestone.md (gaps_found pre-flight: lines 46, 57)
Replaced with inline closure path:
/gsd-phase --insert <N> "Close gap: <REQ-ID> ..."
/gsd-discuss-phase <N>
/gsd-plan-phase <N>
/gsd-execute-phase <N>
Plus a Nyquist-coverage hint pointing at /gsd-validate-phase /
/gsd-secure-phase for retroactive audit-chain hygiene gaps. The
gsd-ns-project SKILL.md "deleted by #2790" note is preserved
(it's the canonical pointer for future readers asking what
happened to the command).
Tests:
- tests/bug-3029-3034-stale-command-routes.test.cjs — parser-based
assertions per fixed surface, plus a structural cross-check that
gsd-ns-project keeps the deletion note. 15 tests, all green.
- 6905/6905 full suite passes.
Closes#3029Closes#3034
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address CR feedback on PR #3038 — argument order, structural tests, agent count
CR findings on PR #3038:
1. **docs/USER-GUIDE.md (Major)** — `--fix` examples used flag-first form
(`/gsd-code-review --fix 3`), but the supported CLI grammar is
phase-first (`/gsd-code-review 3 --fix`). The original sed-based
replacement preserved the position of the `gsd-code-review-fix`
token, producing the wrong order. Fixed in USER-GUIDE.md (3
occurrences) and the same drift in the workflow surfaces:
- get-shit-done/workflows/code-review-fix.md (2 retry hints)
- get-shit-done/workflows/code-review.md (offer text)
- get-shit-done/workflows/execute-phase.md (offer text)
2. **docs/AGENTS.md (Minor)** — internal count drift: line 483 said
"Ten additional agents" but line 725 said "12 advanced/specialized".
Filesystem reality: 33 agents total, 21 primary, 12 specialized
(count of `### ` stubs in the Advanced and Specialized section).
Updated lines 3, 13, 483 to use 12/33 and added the two missing
names (doc-classifier, doc-synthesizer) to the inline list at
line 13.
3. **tests:94 (Major refactor suggestion)** — `.includes()` token checks
were source-grep style. Refactored to a typed-IR pattern: extract
the SET of slash-command tokens via regex, assert membership on the
parsed Set instead of substring scanning the raw file text. Added
the `allow-test-rule` comment explaining the IR-build vs
IR-assertion split per scripts/lint-no-source-grep.cjs convention.
4. **tests:130 (Major)** — replacement-path assertion was file-wide and
could false-pass on generic mentions of "inline" elsewhere in the
file. Refactored: `extractOfferBlocks(content)` returns the typed
list of `<offer_next>` and "Pre-flight" blocks where the deleted
command previously lived, and the assertion runs against those
blocks specifically. Now requires `/gsd-phase --insert` or
inline-audit prose to appear in the same offer block, not just
somewhere in the file.
15/15 targeted tests pass. 6905/6905 full suite pass. Lints clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* docs: add issue-driven orchestration guide (#2840)
Adds docs/issue-driven-orchestration.md — a recipe for driving GSD from a
GitHub / Linear / Jira issue using existing primitives. Maps Symphony-style
orchestration concepts onto GSD commands without vendoring code, adding a
daemon, or introducing tracker integration.
Concept mapping covers:
- WORKFLOW.md → ROADMAP.md / STATE.md / phase CONTEXT.md / phase PLAN.md
- isolated agent workspace → /gsd-new-workspace --strategy worktree
- agent dispatch → /gsd-manager (interactive), /gsd-autonomous (unattended)
- per-phase steps → /gsd-discuss-phase → /gsd-plan-phase → /gsd-execute-phase
- proof-of-work → /gsd-verify-work (UAT.md persists across /clear)
- adversarial review → /gsd-review (cross-AI peer review)
- human merge gate → /gsd-ship
- follow-up capture → /gsd-note, /gsd-plant-seed, /gsd-new-milestone
End-to-end flow walks through 7 numbered steps from picking the tracker
issue to capturing follow-ups. Safety boundaries (isolated worktrees,
explicit human review, no automatic public posting, verification before
ship) and non-goals (no vendoring, no daemon, no mandatory tracker, no
gate bypass, no command-surface expansion) are spelled out explicitly so
the doc cannot drift into "let's just add one more flag".
Cross-linked from docs/README.md (Documentation Index) and
docs/USER-GUIDE.md (Table of Contents preamble).
Tests: tests/feat-2840-issue-driven-orchestration-guide.test.cjs — 9
structural-IR tests parse the guide into a typed record and assert on
flags (commandsPresent, conceptPairs, nonGoalFlags, safetyFlags,
numberedSteps). Fence-language MD040 check enforced. Cross-link
presence enforced. No raw-text assertions on prose.
6890/6890 tests pass. Lint:tests clean (allow-test-rule comment justifies
the doc-shape parser per scripts/lint-no-source-grep.cjs escape hatch).
Lint:changeset clean.
Closes#2840
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(test): guard USER-GUIDE.md existsSync before read (CR #3036)
CR Minor: cross-linked-from-USER-GUIDE.md test called fs.readFileSync
directly without first asserting fs.existsSync, asymmetric with the
README.md test above. A missing USER-GUIDE.md would throw ENOENT instead
of producing a meaningful assertion message. Mirror the null-guard
pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(hooks): opt-in SessionStart update banner for non-statusline users (#2795)
When a user declines (or keeps a non-GSD) statusline at install time, the
installer now offers an opt-in SessionStart banner that surfaces GSD update
availability. The banner reads the existing
~/.cache/gsd/gsd-update-check.json cache (written by
gsd-check-update-worker.js) and emits a single systemMessage line only when
update_available is true:
GSD update available: <installed> → <latest>. Run /gsd-update.
It is silent when up-to-date and rate-limits "check failed" diagnostics to
once per 24h via a sentinel file so a corrupt cache doesn't nag every
session. Removed cleanly by `npx get-shit-done-cc --uninstall` which strips
both the script and the SessionStart entry. The banner is never offered when
GSD's statusline is being installed (statusline already surfaces update
info, so re-prompting would be noise).
Implementation:
- hooks/gsd-update-banner.js — pure functions buildBannerOutput,
shouldSuppressFailureWarning, readCache; thin main() wires them.
- bin/install.js — handleUpdateBanner() prompt, parseUpdateBannerInput(),
buildUpdateBannerHookEntry(), buildUpdateBannerPromptText(); chained into
installAllRuntimes() so finalize() receives both flags. updateBannerCommand
computed alongside the other JS-hook commands; finishInstall() registers
the SessionStart entry only when shouldInstallBanner === true and the
hook file is present at the target.
- Hook ships in scripts/build-hooks.js HOOKS_TO_COPY, listed in
MANAGED_HOOKS for stale-detection in gsd-check-update-worker.js, in the
uninstall hook-removal lists in install.js, and in the
rewriteLegacyManagedNodeHookCommands allowlist.
Tests:
- tests/feat-2795-update-banner.test.cjs — 22 tests, structural-IR
assertions on parsed JSON envelopes (no raw-text matching). Covers
pure-function branches (cache present/absent, parseError, rate-limit
suppression, missing version fields), end-to-end hook invocation against
fixture cache states, and install.js wiring (prompt text, input parsing,
hook entry shape).
- tests/trae-install.test.cjs — updated install() return-shape assertion to
include updateBannerCommand: null for the no-settings runtime.
- 6881/6881 tests pass.
Docs (bundled in same commit per the bundle-docs-with-code skill):
- docs/USER-GUIDE.md — new "Surface GSD Update Notifications Without GSD's
Statusline" task section with opt-in/opt-out instructions.
- docs/FEATURES.md — REQ-HOOK-08 added; "Update Banner" subsection under
the Hook System feature with cache flow + removal path.
- docs/INVENTORY.md — hook count 11 → 12, new row for gsd-update-banner.js.
- docs/INVENTORY-MANIFEST.json — regenerated.
Closes#2795
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(install): gate banner prompt on actual installability (CR #3035)
CodeRabbit findings on PR #3035:
- bin/install.js (Major): continueAfterStatusline gated banner prompt on
the raw `shouldInstallStatusline` flag from handleStatusline. But
finishInstall later silently skips the statusline write on local
installs unless --force-statusline is set (#2248). Two consequences:
1. Interactive local Claude/Gemini installs got neither a statusline
nor a banner offer.
2. Codex/Cursor/Copilot/Windsurf/Trae/Cline-only installs (where
every result.updateBannerCommand is null) still got prompted even
though the choice was silently ignored.
Fix: derive willInstallStatusline = shouldInstallStatusline &&
(isGlobal || forceStatusline), and gate the banner prompt on a
canInstallBanner precondition computed from results[].updateBannerCommand.
Pass the raw shouldInstallStatusline through to finalize unchanged so
per-runtime statusline gating in finishInstall is unaffected.
- tests/feat-2795-update-banner.test.cjs (Minor): rate-limit suppression
test parsed r1.stdout without first asserting r1.status === 0. Other
e2e tests in this file (lines 210, 241) do this. A non-zero exit would
surface as a cryptic SyntaxError instead of a status assertion failure.
Fix applied verbatim.
6881/6881 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* docs(#3025): MCP tool schema as a context-budget concern
Adds documentation covering the largest GSD cost lever that GSD
itself does not own: MCP tool schema injection. Every enabled MCP
server adds its schema to every turn (often 20k+ tokens for
heavyweight servers like browser/playwright, mac-tools, etc.),
which can dwarf whatever `model_profile` tuning saves.
Two doc surfaces (per the bundle-docs-with-code skill depth gradient):
1. get-shit-done/references/context-budget.md
- New "MCP Tool Schema Cost (Harness Concern)" section.
- Explains schemas-per-turn cost framing.
- Names enabledMcpjsonServers / disabledMcpjsonServers and
.claude/settings.json explicitly.
- Pre-phase audit checklist: browser/playwright, platform-specific,
cross-project/stale, duplicate/shadow.
- Explicit "GSD does not manage MCP enablement — harness concern"
statement so users don't hunt for a GSD setting.
- Links to Anthropic Claude Code MCP docs as canonical reference.
- Notes compounding interaction with model_profile (additive levers).
2. docs/USER-GUIDE.md
- New task-oriented "Trim MCP servers to reduce per-turn cost"
section above "Using Non-Claude Runtimes".
- Same checklist condensed.
- Cross-link to context-budget.md for the full reference.
Tests:
- tests/feat-3025-mcp-token-budget-docs.test.cjs (12 cases) parses
both docs into typed semantic-flag records and asserts behavioral
invariants (mentions key, includes audit, names harness, etc.)
rather than substring-matching prose. Adheres to CONTRIBUTING.md
no-source-grep — section can be reworded freely as long as the
required semantics survive.
- Markdownlint pre-flight tests (MD040 fence language, MD056 table
column count) per the bundle-docs-with-code skill so CR can't
ratchet on prose nitpicks across multiple review rounds.
Verification:
- 12/12 pass on regression test
- 6857/6857 full suite (12 net new)
- lint-no-source-grep clean (377 test files)
Companion to #3023 (per-phase-type model map) and #3024 (dynamic
routing). Together they cover the three biggest cost levers users
ask about; this issue covers the one GSD does not own.
Closes#3025
* docs(#3025): batch 3 CR fixes — pr id, relative link, named flag
CodeRabbit on PR #3032 (3 minor — 2 inline + 1 nitpick), all in one
push per the bundle-docs-with-code skill (avoid per-round nitpick
ratchet):
1. Inline (Minor) — .changeset/mcp-token-budget-docs.md:3
`pr: TBD` → `pr: 3032` so changeset tooling can link the entry.
2. Inline (Minor) — docs/USER-GUIDE.md:1101
Used a hardcoded `https://github.com/.../blob/main/...` URL for the
cross-link to `context-budget.md`. Rest of USER-GUIDE.md uses
relative links. Switched to `../get-shit-done/references/context-
budget.md#mcp-tool-schema-cost-harness-concern` so feature-branch
work shows the right content and rename-resilience is preserved.
3. Nitpick — tests/feat-3025-mcp-token-budget-docs.test.cjs:234
The cross-link assertion used an inline `/context-budget/i.test(...)`
while every other invariant in the file lived as a named flag in
`parseMcpBudgetSection`. Per CONTRIBUTING.md no-source-grep, added
`crossLinksContextBudget` to the parser and asserted on
`parsed.crossLinksContextBudget` so the cross-link rule sits next
to its siblings.
Verification:
- 12/12 pass on regression test (no count change; refactor only)
- No source code changes, only docs + tests
* test(#3025): strip inline markdown before phrase-match (CR nitpick)
CodeRabbit caught that the `explainsHarnessNotGsd` primary regex
branch couldn't match "GSD does **not** manage" in
context-budget.md because the markdown bold markers (`**`) sit
between contiguous words. The test passed today only via the
fallback `harness (concern|setting|controlled)` branch — the
primary branch was effectively dead code.
Fix: strip inline markdown emphasis (`**`, `*`, `~~`) and inline-
code backticks before any phrase-matching in `parseMcpBudgetSection`.
All seven flag computations now run against the stripped text so
markdown formatting can't silently invalidate any invariant.
Underscores are intentionally NOT stripped — `model_profile` and
other snake_case identifiers must survive intact for the
mentionsModelProfileInteraction check to find them.
Verification: 12/12 still pass; primary branches now fire on
real markdown content rather than relying on fallbacks.
* test(#3025): guard markdownlint tests against null section (CR nitpick)
CodeRabbit caught that the MD040 and MD056 markdownlint pre-flight
tests called `section.match(...)` and `section.split('\n')`
directly on the value returned by `extractSection`, which returns
null when no matching header is found. If the MCP section is ever
removed (regression), both tests would throw `TypeError: Cannot
read properties of null` instead of producing a clean assertion
failure naming the actual problem.
The semantic tests above are protected because parseMcpBudgetSection
short-circuits to a typed-falsy record on null input. The
markdownlint tests bypassed that guard since they need raw section
text, not parsed flags. Added `assert.ok(section, ...)` preconditions
to both so a missing section produces a meaningful failure message.
No content changes; defensive programming only.
Verification: 12/12 still pass.
* feat(#3024): dynamic routing with failure-tier escalation
Adds a `dynamic_routing` block to .planning/config.json that lets
the resolver start agents on a cheap tier and escalate one tier up
when the orchestrator detects a soft failure (verification
inconclusive, plan-check FLAG, etc.). Solves the "pay Opus rates as
insurance" anti-pattern by making escalation observed-quality-driven.
Architecture:
- AGENT_DEFAULT_TIERS map (light/standard/heavy) — every agent in
MODEL_PROFILES declares a default tier; tests assert coverage
so adding a new agent without updating the map fails CI.
- nextTier(currentTier) helper — light → standard → heavy → heavy
(heavy stays at heavy; can't go further).
- resolveModelForTier(cwd, agentType, attempt) — new resolver. The
orchestrator tracks the attempt counter and passes 0 for the
first spawn, 1+ on escalation. The resolver caps internally at
max_escalations so the orchestrator can blindly bump the counter.
- Schema validation: dynamic_routing.enabled / escalate_on_failure /
max_escalations / tier_models.<light|standard|heavy>. Unknown
tiers and unknown sub-keys rejected at config-set time.
- SDK schema mirror updated to keep CJS/SDK in lockstep (#2653).
Resolution precedence (highest → lowest):
1. model_overrides[<agent>] (full IDs accepted)
2. dynamic_routing.tier_models[<tier>] (NEW; escalation-aware)
3. models[<phase_type>] (#3023 phase-type map)
4. model_profile (per-agent column)
5. Runtime default
Backward compatibility: dynamic_routing is disabled by default
(enabled: false or block omitted). resolveModelForTier short-
circuits to resolveModelInternal in that case, so callers can
adopt unconditionally without breaking existing behavior.
This PR delivers the JS-layer infrastructure: schema + tier map +
resolver. Orchestrator adoption (workflow markdown updates that
detect soft failures and call resolveModelForTier with attempt+1)
is incremental follow-up — verifier / plan-checker / integration-
checker each adopt the protocol when ready.
Tests (23 cases, all structural-IR — no stdout grep):
- Schema invariants: AGENT_DEFAULT_TIERS coverage, VALID_AGENT_TIERS
exact match, every assignment uses a valid tier
- nextTier helper: light→standard→heavy→heavy, null on invalid input
- Disabled mode: no block + enabled:false both no-op (back-compat)
- Enabled mode: attempt=0 returns default tier model, attempt=1
escalates, beyond max_escalations caps, heavy agents stay heavy,
default max_escalations=1 when omitted
- Precedence: per-agent override beats dynamic_routing,
dynamic_routing beats phase-type models
- Validation: every settings key accepted, unknown tiers/sub-keys
rejected, bare `dynamic_routing` rejected as config-set target
Documentation:
- get-shit-done/references/model-profiles.md — full reference section
- docs/CONFIGURATION.md — full settings table + escalation flow
- docs/USER-GUIDE.md — task-oriented "Cheap-by-default" section
- docs/FEATURES.md — config row cross-link
Verification:
- 23/23 pass on regression test
- 6843/6843 full suite (23 net new from 6820)
- lint-no-source-grep clean (376 test files)
- SDK schema mirror keeps CJS/SDK in sync per #2653 parity test
Closes#3024
* fix(#3024): honor escalate_on_failure:false + 3 CR follow-ups
CodeRabbit on PR #3031 (4 findings — 1 Major + 2 Minor + 1 Nitpick):
1. **Major (inline)** — get-shit-done/bin/lib/core.cjs:1668
resolveModelForTier ignored dynamic_routing.escalate_on_failure.
When the user set it to false, escalation should be disabled, but
the resolver only checked attempt/max_escalations. An orchestrator
that always passes attempt+1 on retry would silently escalate
despite the user opting out.
Fix: gate effectiveAttempt on `dr.escalate_on_failure !== false`
so false short-circuits every attempt back to the default tier.
2. **Minor (inline)** — docs/CONFIGURATION.md:123-126
The dynamic_routing rows in the Core Settings table had 4 cells
instead of 5 (missing the Options column), breaking the table
structure. Added explicit Options values for enabled / escalate_on_failure
/ max_escalations rows.
3. **Minor (outside-diff)** — references/model-profiles.md:179-195
"Resolution Logic" sketch was pre-#3024 and didn't include
dynamic_routing in the precedence ladder. Updated to a 6-step
block with dynamic_routing at step 3 (between override and
phase-type).
4. **Nitpick** — tests/feat-3024-dynamic-routing.test.cjs:189+
Tests used `if (lightAgent) { ... }` guards that silent-pass
when AGENT_DEFAULT_TIERS drifts. Replaced all 5 conditional
skips with `assert.ok(lightAgent, '...')` preconditions so a
tier-mapping change surfaces as a test failure.
Plus: 2 new regression tests for the Major fix:
- escalate_on_failure:false caps every attempt at default tier
- escalate_on_failure:true (explicit) still escalates normally
Verification:
- 25/25 pass on regression test (23 prior + 2 escalate_on_failure)
- 6845/6845 full suite (2 net new)
- lint-no-source-grep clean
* docs(#3024): align precedence + add fence language tags (CR follow-up)
CodeRabbit (3 minor):
1. docs/CONFIGURATION.md:691 — "Per-Phase-Type Models → Resolution
precedence" was a 4-step block written pre-#3024; readers got
contradictory rules between the per-phase-type section and the
later dynamic_routing section. Updated to the same 5-step ladder
with dynamic_routing at step 2, and noted that dynamic_routing
is disabled by default so this section's behavior is unchanged
when the kill-switch is off.
2. docs/CONFIGURATION.md:770 — escalation-flow code fence missing
language tag (MD040). Added `text`.
3. references/model-profiles.md:184 — resolution-ladder code fence
missing language tag (MD040). Added `text`.
No code changes; docs only. Verification: regression test still 25/25.
* docs(#3024): clarify precedence prose — five layers, not four (CR nitpick)
CodeRabbit nitpick: the "Per-Phase-Type Models → Resolution
precedence" prose said "The four layers compose..." but the ladder
above lists five (including Runtime default). Also "dynamic_routing
escalates per-attempt above all of them" misreads as suggesting
dynamic_routing wins over model_overrides — actually overrides still
win at step 1.
Reworded top-down so the precedence direction is unambiguous:
- model_profile = base
- models = phase-level override
- dynamic_routing = per-attempt escalation
- model_overrides = per-agent exception (top)
- runtime default = fallback
No code changes; docs only.
* docs(#3024): note escalate_on_failure:false in escalation-flow diagram (CR)
CodeRabbit nitpick: the escalation-flow diagram in
docs/CONFIGURATION.md described the soft-failure → respawn →
tier_models[next_tier_up] path, but didn't surface the
`dynamic_routing.escalate_on_failure: false` kill-switch right next
to it. Users reading the flow diagram (which is the canonical place
to understand attempt behavior) wouldn't see that the kill-switch
overrides the soft-failure branch.
Added a one-paragraph note immediately after the flow listing,
before the tier-sequence example, so the kill-switch is visible
exactly where users decide whether escalation will happen.
No code changes; docs only.
* feat(#3023): per-phase-type model map in .planning/config.json
Adds a new `models` block to .planning/config.json with six phase-type
slots (planning / discuss / research / execution / verification /
completion). Lets users express coarse tuning ("Opus for planning,
Sonnet for the rest") without learning the agent taxonomy.
Resolution precedence (highest → lowest):
1. Per-agent `model_overrides[agent]` (full IDs; targeted exception)
2. Phase-type `models[phase_type]` (NEW; tier alias)
3. Profile table (`model_profile`) (per-agent column)
4. Runtime default
The three layers compose: `models` defaults a phase, `model_overrides`
carves an exception. Phase-type values are tier aliases (opus/sonnet/
haiku/inherit) so the runtime-resolution chain (#2517) stays correct
end-to-end without further branching.
Implementation:
- model-profiles.cjs: new AGENT_TO_PHASE_TYPE map + VALID_PHASE_TYPES
set. Each agent in MODEL_PROFILES gets one phase-type assignment;
tests assert coverage so adding a new agent without updating the
table fails CI.
- core.cjs (resolveModelInternal): inserts phase-type tier lookup
between per-agent override and profile-derived tier. Skips runtime
resolution when the resolved tier is 'inherit' (was previously gated
only on profile === 'inherit'; phase-type can now produce inherit
independently).
- core.cjs (loadConfig): pass `parsed.models` through both code paths
so resolveModelInternal can read it.
- config-schema.cjs + sdk/src/query/config-schema.ts: dynamic-pattern
validator accepts only the six known phase-types; unknown slots
rejected at config-set time.
Backward compat: configs without `models` behave exactly as today.
Tests (15 cases, all structural-IR — no stdout grep):
- Schema: AGENT_TO_PHASE_TYPE coverage, VALID_PHASE_TYPES exact match
- Resolver: phase-type alone; per-agent override beats phase-type;
phase-type beats profile; issue's full example; "inherit"; empty
block is no-op; no block is no-op
- Validation: each of the 6 slots accepted; unknown slot rejected;
bare `models` (no slot) rejected
Verification:
- 15/15 pass on new regression test
- 6808/6808 full suite (5 net new), 0 fail
- lint-no-source-grep clean across 375 test files
Closes#3023
* docs(#3023): document `models` per-phase-type config in user-facing docs
Adds `models` block coverage to the three user-facing docs that ship
with each release:
1. docs/CONFIGURATION.md
- New "Per-Phase-Type Models" section between "Per-Agent Overrides"
and "Non-Claude Runtimes" with:
* full example mixing models + model_overrides
* phase-type → agent mapping table
* resolution-precedence pseudocode
* accepted values (tier alias only)
* "When to use which" decision matrix
* validation behavior + example error
- Added `"models": {}` to the Full Schema snippet
- Added a row for `models.<phase_type>` to the config keys table
(next to model_profile_overrides for adjacency)
2. docs/FEATURES.md
- Added a row for models.<phase_type> in the Configurable Settings
table (right under model_profile)
- Cross-link to CONFIGURATION.md for the full surface
3. docs/USER-GUIDE.md
- New task-oriented "Tuning model cost by phase" section above
"Using Non-Claude Runtimes" — leads with the concrete config
and shows the override pattern (one-shot phase + targeted exception)
- Cross-link to CONFIGURATION.md
Verification:
- 29/29 pass on config-schema-docs-parity + docs-update + new feature
test (parity-check passes, so the config-schema entry I added in the
feature commit is now matched by a docs row)
- 6808/6808 full suite pass
- lint-no-source-grep clean
Doc style follows the same pattern used by the existing model_profile,
model_overrides, and model_profile_overrides sections — example-led,
table-backed, cross-referenced. Each doc surfaces the feature at the
right depth (reference / settings table / task guide).
* fix(#3023): mirror phase-type tier in resolveReasoningEffortInternal (CR Major)
CodeRabbit caught a real Codex correctness bug + 3 minor docs/test issues:
1. **Major (outside-diff)** — resolveReasoningEffortInternal in core.cjs
derived its tier exclusively from the profile table, ignoring the
models.<phase_type> override added in #3023. Failure mode on Codex:
Config: model_profile=balanced, models.execution=opus, agent=gsd-executor
resolveModelInternal: tier=opus → gpt-5.4
resolveReasoningEffortInternal: tier=sonnet → reasoning_effort=medium
↑
WRONG — should be xhigh
(opus tier on Codex)
The runtime received a mismatched (model, effort) pair. Mirrored the
phase-type lookup from resolveModelInternal so both functions derive
from the same tier source. 'inherit' phase-type returns null effort
(no runtime entry maps to 'inherit'; let runtime decide).
2. Minor — .changeset/per-phase-type-models.md `pr: TBD` → `pr: 3030`.
3. Minor (outside-diff) — model-profiles.md "Resolution Logic" section
omitted the new phase-type tier. Updated the 4-step block to a 5-step
block including `models[phase_type]` between override and profile,
plus a paragraph noting that `model` and `reasoning_effort` derive
from the same tier source.
4. Nitpick — added 2 typo-safety tests:
- models.research = "haiku3" (typo) → falls through to profile
- models.research = "openai/gpt-5" (full ID) → falls through to profile
Plus 5 new reasoning_effort tests covering the Major fix:
- exported correctly
- phase-type override flips both model AND effort to same tier
- inherit phase-type returns null effort
- per-agent override still bypasses phase-type for effort
- claude runtime ignores models.* (no effort propagation)
Verification:
- 24/24 pass on regression test (15 original + 2 typo-safety + 5 effort + 2 outside-diff related)
- 6815/6815 full suite (7 net new from 6808)
- lint-no-source-grep clean
The reasoning_effort tests are written semantically (phase-type override
must produce the SAME effort as a profile-only opus config) rather than
hard-coding tier-specific effort strings, so changes to the runtime tier
map don't break them.
* fix(#3023): phase-type override beats profile=inherit (CR Major round 2)
CodeRabbit caught another precedence inversion: when
{ model_profile: 'inherit', models: { execution: 'opus' } }
both resolvers short-circuited on `profile === 'inherit'` BEFORE the
phase-type override could be honored. Result: model returned 'inherit'
and reasoning_effort returned null — both contradicting the documented
precedence where models[phase_type] wins over model_profile.
Fix in resolveModelInternal:
- Compute tier from phase-type FIRST. If phase-type is a valid alias,
it wins. Otherwise, fall back to profile-derived tier OR 'inherit'
(when profile === 'inherit').
- Gate the runtime-resolution branch on `tier !== 'inherit'` (was
`profile !== 'inherit'`) so phase-type=opus can flip runtime mapping
on even when profile=inherit.
- Gate the inherit-return on `tier === 'inherit'` (was
`profile === 'inherit'`).
Fix in resolveReasoningEffortInternal:
- Remove the `if (profile === 'inherit') return null;` early-return.
- Compute tier from phase-type first, fall back to profile. If
phase-type is explicitly 'inherit' OR the resolved tier is 'inherit',
return null (no runtime entry maps to inherit).
Tests added (5 new):
- model: phase-type wins over profile=inherit (with explicit opus, with
haiku for one phase + planner-without-slot still inheriting)
- model: profile=inherit + no models block → all agents inherit (no
regression on existing inherit semantics)
- model: profile=inherit + models block but agent has no slot → that
agent inherits, agents with slots get phase-type tier
- effort: phase-type opus + profile=inherit → produces opus-tier
effort, NOT null (the original bug)
Verification:
- 27/27 pass on regression test (24 prior + 3 model + 1 effort)
- 6820/6820 full suite (5 net new)
- lint-no-source-grep clean
The effort test reads the expected value by running a profile-only opus
config and comparing — semantic check, not hard-coded effort string. So
runtime tier map changes don't break the test.
* fix(#3020): probe user shell PATH at install-time, not just process.env.PATH
The installer's "✓ GSD SDK ready" message was a false positive whenever
the install subprocess's process.env.PATH contained the gsd-sdk shim
but the user's later interactive shells did not. Three known sources
of mismatch on POSIX:
- ~/.local/bin: install subprocess inherits npm/npx-injected PATH;
user's login shell may not add ~/.local/bin if .profile/.bashrc/
.zshrc don't.
- nvm/fnm/volta: node version managers shim PATH per-shell, so
`npm prefix -g` from inside the install subprocess can resolve to
a different bin dir than the user's interactive shell sees.
- npm-prefix tooling: some installers inject extra PATH entries that
vanish in fresh sessions.
Result reported on #3011 by @x0rk and @stefanoginella: install prints
✓, but every workflow invocation later fails with
"bash: gsd-sdk: command not found".
Fix:
- isGsdSdkOnPath(pathString?) — now accepts an explicit PATH string.
Zero-arg form preserves existing behavior (reads process.env.PATH).
Pure walk, no spawn. Lets callers verify against any PATH source.
- getUserShellPath() — new helper. Probes the user's login shell via
`$SHELL -lc 'printf %s "$PATH"'` (POSIX). 2-second timeout so a
misconfigured rc file can't hang the install. Returns null on
Windows (cross-shell PATH probing requires a different strategy
per Git Bash / PowerShell / cmd.exe — tracked separately) or when
the probe fails; callers fall back to process.env.PATH in that case.
- installSdkIfNeeded() — after the existing isGsdSdkOnPath() check
passes, also verify the shim is reachable from getUserShellPath()
on POSIX. If install-PATH and user-shell-PATH disagree, downgrade
to the actionable ⚠ diagnostic from PR #3014 (which has the shim
location, shell-specific PATH-update commands, and an npx fallback
note). Routing affected users into PR #3014's diagnostic is the
point — not silently green-then-red.
Tests:
- bug-3020-install-shell-path-probe.test.cjs (10 tests, structural):
- isGsdSdkOnPath accepts an explicit PATH (true/false on fixture
PATH dirs with/without an executable shim)
- zero-arg form returns a boolean
- empty string PATH → false
- getUserShellPath returns string-or-null
- returns null on Windows
- returns null when $SHELL unset on POSIX
- cross-shell mismatch detection: install-PATH and user-PATH that
differ produce different isGsdSdkOnPath results — the invariant
the install-time check now exploits
- All assertions on structural records, not console output. Adheres
to typed-IR / CONTRIBUTING.md "Prohibited: Raw Text Matching".
Verification:
- 10/10 pass on new regression test
- 6768/6768 pass on full suite (5 net-new tests)
- lint-no-source-grep clean
Windows cross-shell coverage (gsd-sdk.cmd resolves under PowerShell
but not Git Bash without a no-extension sibling) is tracked separately
— this PR is the POSIX-side fix and the Windows scaffolding (the
optional pathString arg on isGsdSdkOnPath) that a Windows fix can
build on.
Closes#3020
* fix(#3020): type-guard pathString, last-line PATH parse (CR)
CodeRabbit on PR #3028 (4 findings — 3 actionable + 1 nitpick):
1. .changeset/install-shell-path-probe.md (2 findings):
- `pr: TBD` → `pr: 3028`
- Doc said `echo $PATH` but impl uses `printf %s "$PATH"` (chosen
to avoid shell-dependent echo behavior, e.g. interpreting `-n`).
Aligned changeset prose with implementation.
2. bin/install.js:9176 — isGsdSdkOnPath(pathString) used
`pathString !== undefined` to gate the explicit-PATH branch, but
getUserShellPath() can return null and `null.split()` throws.
Tightened to `typeof pathString === 'string'` so null / number /
object inputs fall back to process.env.PATH. Added 2 regression
tests covering the null and non-string cases.
3. bin/install.js:9232 — getUserShellPath trimmed entire stdout. A
misconfigured rc file that prints a banner / motd / log line
BEFORE the printf would pollute the result and incorrectly flip
the cross-shell check to false. Take the LAST non-empty line
(PATH itself is single-line) so noise can't hijack the probe.
4. Nitpick: the changeset PR placeholder — covered by (1).
Verification: 12/12 pass on regression test (10 original + 2 new
type-guard tests), 6770/6770 full suite, lint clean.
* docs(#3020): JSDoc references printf %s "$PATH", not echo $PATH (CR)
CodeRabbit caught two stale JSDoc references that still said
`$SHELL -lc 'echo $PATH'` while the implementation uses
`$SHELL -lc 'printf %s "$PATH"'`. echo is undesirable here because:
- POSIX echo's behavior with `-n` / backslash escapes varies across
shells (bash builtin vs /bin/echo vs zsh) and can introduce
trailing-newline pollution that the per-line trim now papers over.
- printf is portable and emits exactly the bytes given.
Synced both stale doc strings:
- bin/install.js:9211 (getUserShellPath JSDoc)
- tests/bug-3020-install-shell-path-probe.test.cjs:27 (header)
No behavior change — implementation already uses printf.
* fix(#3018): codex adapter must stop and ask, not silently default decisions
@jon-hendry: running `\$gsd-discuss-phase 81` in Codex Default mode
proceeded toward writing CONTEXT.md / DISCUSSION-LOG.md / checkpoint
artifacts without surfacing the discussion questions to the user. The
generated Codex skill adapter explicitly told it to do that:
Execute mode fallback:
- When `request_user_input` is rejected (Execute mode), present a
plain-text numbered list and pick a reasonable default.
That instruction is wrong for any workflow whose contract is to
discuss with the user (most prominently `$gsd-discuss-phase`). The
fallback now requires the agent to:
1. STOP. Present the questions as a plain-text numbered list, then
wait for the user's reply.
2. Only proceed without a user answer when one of these is true:
(a) invocation included --auto / --all,
(b) user explicitly approved a default for this question, or
(c) workflow's documented contract permits autonomous defaults.
3. Do NOT write CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, or checkpoint
files until the user has answered or one of (a)-(c) above applies.
Tests:
- bug-3018-codex-discuss-fallback.test.cjs (5 tests, structural-IR):
parses the generated header into sections via regex, asserts on
the Execute-mode-fallback section's content (must contain stop/
wait + plain-text directives, must NOT contain "pick a reasonable
default", must name a permission path, must forbid artifact
writing). No raw text snapshot — the assertions describe the
behavioral invariant, so prose can be reworded without test churn.
- codex-config.test.cjs:128 still passes — section still mentions
"Execute mode" as required.
Verification:
- 5/5 pass on new regression test
- 116/116 pass on bug-3018 + codex-config combined
- 6763/6763 pass on full suite
- lint-no-source-grep clean
Closes#3018
* test(#3018): parse fallback into typed semantic-flag record (CR)
CodeRabbit nitpick on PR #3027: the regression tests grepped the
generated header prose with regex, which is brittle and tests wording
rather than semantics. Per CONTRIBUTING.md "no-source-grep" standard.
Refactored to a structural-IR shape:
- New `parseExecuteModeFallback(section)` walks the section text once
and returns a typed record:
{
ok, sectionLength,
instructsStop, // STOP/HALT/WAIT directive
presentsPlainTextQuestions, // plain-text / numbered list
namesPermissionPath, // --auto / --all / explicit approval
forbidsWritingArtifactsBeforeAnswer, // write-ban + named artifact class
silentlyPicksDefaults, // anti-pattern guard (must be false)
}
- Each positive invariant gets its own test asserting on the parsed
boolean, so a failure points at the exact invariant that broke.
- A final test does a single assert.deepStrictEqual against the full
expected contract — gives a structured diff when any flag flips.
- The artifact-write ban now requires BOTH a "do not write" intent
AND a named artifact class (was a single broad regex), so generic
"do not write" prose elsewhere in the section can't satisfy it.
Verification: 8/8 pass; lint-no-source-grep clean.
* fix(#3019): query --help reaches handler instead of short-circuiting to top-level usage
The query argv parser in sdk/src/cli.ts harvested -h/--help as a global
flag and main() short-circuited dispatch when args.help was true. Net
effect: every `gsd-sdk query <anything> --help` printed top-level USAGE
instead of contextual subcommand help. There was no path for users to
discover what arguments a query subcommand accepts — they had to trigger
"required" errors by trial and error.
Two-layer fix:
1. sdk/src/cli.ts (parseCliArgsQueryPermissive)
- Push -h / --help onto queryArgv instead of consuming them silently,
so the registered handler / gsd-tools.cjs fallback gets to interpret
the flag and render contextual help.
- Only honor the global help flag when there is NO real subcommand to
dispatch to (i.e. queryArgv contains only help flags). Preserves
`gsd-sdk query --help` → top-level USAGE while letting
`gsd-sdk query phase add --help` reach the handler.
2. get-shit-done/bin/gsd-tools.cjs
- Render top-level usage on --help / -h / -? / --usage instead of
erroring with "Unknown flag". The discovery hint in the usage text
points users at the working method (run without args → error names
required arguments) and references #3019 for tracking subcommand-
level help printers.
- --version remains rejected (no discovery use-case).
#1818 anti-hallucination invariant preserved: the destructive command
NEVER executes when --help is present. The new shape returns success:true
+ usage on stdout instead of the old success:false + error on stderr —
both satisfy "destructive command did not run", and the new shape also
restores discoverability.
Tests:
- sdk/src/cli.test.ts: 4 new vitest cases covering #3019 — query argv
parser keeps --help with subcommand, parses -h short flag, preserves
bare `query --help` top-level behavior, preserves --help position when
intermixed with other query flags.
- tests/bug-3019-help-passthrough.test.cjs: 5 node:test cases on the
fallback — bare gsd-tools (no args) errors with usage; --help renders
usage on stdout exit 0; -h same; subcommand --help renders usage; usage
hint mentions discovery method (without prose substring matching —
parses into typed sections).
- tests/bug-1818-unknown-flags.test.cjs: rewritten to assert the new
invariant ("destructive command did not run" + "usage was rendered")
instead of the old shape ("--help is rejected with non-zero exit").
Each destructive test seeds a sentinel artifact (phase dir, slug
output) and asserts it survives.
Verification:
- 47/47 vitest pass on sdk/src/cli.test.ts
- 5/5 pass on tests/bug-3019-help-passthrough.test.cjs
- 8/8 pass on tests/bug-1818-unknown-flags.test.cjs (rewritten)
- 6763/6763 pass on full node:test suite
- lint-no-source-grep clean (0 violations)
Closes#3019
* fix(#3019): SDK fallback forwards plain-text help, broader usage list (CR)
CodeRabbit on PR #3026 (4 findings — 1 Major outside-diff, 2 inline,
1 nitpick):
1. **Major outside-diff** — sdk/src/cli.ts:442-454. The fallback path
that delegates to gsd-tools.cjs called parseCliQueryJsonOutput
(JSON.parse) on stdout. Now that gsd-tools renders plain-text usage
on --help, JSON.parse threw "Unexpected token 'U'". Wrapped the
parse in try/catch — on parse failure, forward the plain stdout
verbatim so subcommand help reaches the user. Regression test:
tests/bug-3019-help-passthrough.test.cjs spawns the built SDK and
asserts `gsd-sdk query phase --help` exits 0, stdout contains the
gsd-tools usage, and stderr does NOT contain a JSON-parse error.
2. .changeset/help-passthrough.md:3 — `pr: TBD` → `pr: 3026`.
3. gsd-tools.cjs:346 (TOP_LEVEL_USAGE):
- Removed self-referencing `#3019` link (immediately stale after
this PR merges).
- Expanded Commands list from 17 → all 47 dispatcher cases:
agent-skills, audit-open, audit-uat, check-commit, commit, …
phase, phases, roadmap, milestone, validate, progress, intel,
graphify, learnings, etc. — the bulk of the surface that was
previously unreachable via --help discovery.
4. Nitpick: `isUsageOutput` was duplicated in bug-1818 and
bug-3019-help-passthrough tests. Moved to tests/helpers.cjs with
structural-comment, removed both duplicates.
Verification: 47/47 vitest pass, 14/14 regression tests pass,
6764/6764 full suite, lint clean.
* test(#3019): use t.skip() instead of bare return when SDK not built (CR)
CodeRabbit follow-up on PR #3026:
The integration test guarded against missing sdk/dist/cli.js with a
bare `return;` — node:test counts that as a passing test (0 assertions
exercised, 0 failures). On a CI checkout that hasn't run the SDK build,
the #3026 regression test silently green-lit and no signal ever
surfaced that the integration check was skipped.
Switched to `t.skip(...)` via the test context parameter so the
omission shows up in the test report. The unit-level fix
(sdk/src/cli.ts) is still covered by vitest, so the skip only affects
the end-to-end spawn-built-SDK check.
Verification: 6/6 pass when SDK is built; 5 pass + 1 skip when not.
* fix(#3017): codex SessionStart hook uses absolute node, not bare 'node'
PR #3002fixed#2979 for settings.json-based managed JS hooks (Claude
Code, Gemini, Antigravity) by routing through buildHookCommand() →
resolveNodeRunner(), emitting the absolute Node binary path so hooks
resolve under GUI/minimal-PATH runtimes (/usr/bin:/bin:/usr/sbin:/sbin)
where nvm/Homebrew/Volta-installed node is not on PATH.
The Codex install path bypassed both helpers — line 7935 of bin/install.js
wrote `command = "node ${path}"` directly into config.toml. So Codex
SessionStart hook still failed with exit 127 ("node: command not found")
under the same minimal-PATH conditions PR #3002 was meant to close.
Fix:
- Add buildCodexHookBlock(targetDir, { absoluteRunner, eol }) — a pure
helper that emits the toml hook block with the absolute runner. Returns
null when absoluteRunner is null so the caller skips registration with
a warning instead of writing a broken bare-node hook.
- Add rewriteLegacyCodexHookBlock(content, absoluteRunner) — mirror of
rewriteLegacyManagedNodeHookCommands for the toml surface, so
reinstall migrates a 1.39.x bare-node config.toml to the absolute form.
Uses basename equality (CODEX_MANAGED_HOOK_BASENAMES set) so user-
authored bare-node hooks are left alone.
- Replace the inline string-concat at line 7935 with a call to the new
helper, threaded with the detected line ending so CRLF files stay CRLF.
- On the codex reinstall path, call rewriteLegacyCodexHookBlock first so
existing bare-node entries get migrated before the new entry is added.
Tests:
- bug-3017-codex-hook-absolute-node.test.cjs (9 tests, all typed-IR):
- buildCodexHookBlock emits absolute runner, parses to expected fields
- returns null on missing runner (caller skips)
- integrates with resolveNodeRunner() in the live process
- rewriteLegacyCodexHookBlock migrates managed bare-node entries
- leaves user-authored bare-node hooks alone (basename allowlist)
- leaves entries with absolute runner unchanged (idempotent)
- returns content unchanged when absoluteRunner is null
- codex-config.test.cjs e2e expectation updated to match new shape:
parsed.hooks.SessionStart[0].hooks[0].command now equals
'"<process.execPath>" "<hookPath>"' instead of 'node <hookPath>'.
Verification:
- 9/9 pass on the new regression test
- 179/179 pass across all codex-touching test files
- 6767/6767 pass on full suite, lint-no-source-grep clean
- Adheres to typed-IR / CONTRIBUTING.md "Prohibited: Raw Text Matching":
parseCodexHookBlock returns a typed record; assertions are on
structured fields (runner, hookPath, type, hasMarker), not stdout regex.
Closes#3017
* test(#3017): tighten runner assertions to exact process.execPath (CR)
CodeRabbit on PR #3022 (3 findings, 2 actionable + 1 nitpick):
1. .changeset/codex-bare-node-fix.md:3 — replace `pr: TBD` with
`pr: 3022` so changeset metadata is traceable.
2. tests/bug-3017-codex-hook-absolute-node.test.cjs:81-146 — the test
asserted `parsed.runner !== 'node'` and `parsed.runner.includes('/node')`,
which would false-positive on any absolute path containing '/node'
(e.g. /Users/x/notnode/foo). Tightened to compare against the EXACT
absolute path supplied by the caller (after stripping toml + JSON
escape layers via a new unescapeRunner() helper). The live-process
integration test now compares against process.execPath exactly. The
rewriteLegacyCodexHookBlock test also uses exact-equality.
3. Nitpick (skipped): use repository's TOML parser for parsing instead
of bespoke regex. The hand-rolled parser is small, scoped, and
fully tested by these structural assertions; pulling in a TOML lib
for tests would create a circular dependency on the SUT (the
installer's own parser). Leaving as-is.
Verification: 9/9 pass on regression test, 6767/6767 full suite, lint clean.
* fix(#3010): post-install message and docs use /gsd-update --reapply
PR #2824 consolidated 86 skills into ~58, removing the standalone
/gsd-reapply-patches command and folding it into a flag on /gsd-update
(/gsd-update --reapply). The 1.39.1 hotfix (#2954) updated help.md
but missed three other surfaces that still recommended the dead form:
1. bin/install.js reportLocalPatches() — runtime emitter shown after
every install with backed-up patches. All branches updated:
- claude/opencode/kilo/copilot: /gsd-update --reapply
- gemini: /gsd:update --reapply
- codex: $gsd-update --reapply
- cursor: gsd-update --reapply (mention the skill name)
2. get-shit-done/workflows/update.md — Step 4 prose and the
check_local_patches block both referenced /gsd-reapply-patches.
Replaced with /gsd-update --reapply (with backticks around the
command per CR feedback for copy/paste UX).
3. Localized docs (en/ja-JP/ko-KR/zh-CN) — 14 files across
ARCHITECTURE.md / COMMANDS.md / FEATURES.md / INVENTORY.md /
USER-GUIDE.md / manual-update.md still listed the removed command.
Tests:
- bug-3010-reapply-patches-references.test.cjs (4 tests): scans
bin/install.js's reportLocalPatches body, every workflow file, and
every doc (excluding CHANGELOG history and help.md's deprecation
notice) for the removed command form, and verifies each runtime
branch emits the consolidated form via captured console output.
- tests/copilot-install.test.cjs:1081-1115 — stale assertions that
hard-coded the removed string updated to assert /gsd-update --reapply.
Verification: 115/115 pass across both files.
Co-authored-by: Patrick Clery <patrick@patrickclery.com>
Closes#3010
* test(#3010): broaden dead-command scan + tighten runtime exact-match
CodeRabbit follow-up findings on #3012:
1. Workflow + docs scans only matched "/gsd-reapply-patches", missing
the gemini ("/gsd:reapply-patches") and codex ("$gsd-reapply-patches")
spellings. A regression that re-introduced either form in localized
docs would have passed silently. Extracted a DEAD_COMMAND_PATTERNS
array + findDeadCommands() helper used by both scans, so all three
removed forms are checked uniformly. Match output also reports which
spellings hit, for faster diagnosis.
2. reportLocalPatches runtime test asserted output.includes('update --reapply'),
which is too loose — a malformed prefix like '/gsd:update --reapply' on
the claude branch would have passed. Replaced with an exact
{runtime → expected token} map covering all 7 branches:
claude/opencode/kilo/copilot → /gsd-update --reapply
gemini → /gsd:update --reapply
codex → $gsd-update --reapply
cursor → gsd-update --reapply
Negative assertion also runs DEAD_COMMAND_PATTERNS against output for
every runtime, so dead forms can't slip in regardless of branch.
Verification: 4/4 pass on bug-3010-reapply-patches-references.test.cjs.
* test(#3010): add prefix-absence guard for cursor runtime (CR follow-up)
CodeRabbit (Minor): the cursor expected token "gsd-update --reapply" is
a substring of every prefixed form ("/gsd-update --reapply" for claude/
opencode/kilo/copilot, "\$gsd-update --reapply" for codex). The positive
output.includes(expectedToken) check therefore can't distinguish correct
cursor output from a regression where the installer emits a prefixed
form for cursor — both pass the substring check.
Add an explicit prefix-absence assertion for cursor that fails if any
of /, \$, or : appears immediately before "gsd-update --reapply" in
output. The gemini form ("/gsd:update --reapply") doesn't share the
substring (gsd:update vs gsd-update) so it's already caught by the
positive includes failing on cursor's expected bare token.
Verification: 4/4 pass.
---------
Co-authored-by: Patrick Clery <patrick@patrickclery.com>
* fix(#3011): actionable SDK-not-on-PATH diagnostic with shim location and shell-specific commands
The previous diagnostic was a generic 'GSD SDK files are present but
gsd-sdk is not on your PATH' message with no concrete path or
shell-specific PATH-export command. Windows users reported that they
couldn't find where the shim was written and didn't know how to add
it to PATH for each shell (PowerShell vs cmd.exe vs Git Bash vs WSL
all read PATH from different sources).
New formatSdkPathDiagnostic({ shimDir, platform, runDir }) helper
returns a typed IR:
- shimLocationLine: explicit 'Shim written to: <path>'
- actionLines: platform-specific PATH-export commands
- Windows: 3 lines (PowerShell, cmd.exe, Git Bash with backslash->/
translation for bash compatibility)
- POSIX: 1 line (export PATH=...)
- npxNoteLines: 'you're running via npx ... npm install -g instead'
when runDir is under an _npx cache segment (where the shim may be
written to a temp dir that won't persist for the user's interactive
shell)
- isNpx, isWin32: structured booleans for assertions
Renderer in install.js just emits each line. Tests assert on the
typed IR fields directly (no source-grep, no console-output parsing).
Tests: 12 cases across 5 suites covering Windows shell flavors
(PowerShell preserves backslashes, Git Bash translates to forward),
POSIX exports, null-shimDir fallback to npm install -g advice, npx
detection on both path-separator conventions, and IR shape contract.
Closes#3011
* fix(#3011): cmd.exe guidance uses powershell -Command, not setx
CodeRabbit flagged the cmd.exe action line as a Major Windows
correctness bug:
setx PATH "${shimDir}; %PATH%"
Two failure modes:
1. setx silently truncates the registry value above 1024 chars,
permanently storing the truncated PATH and breaking applications
until restored from the registry backup or fixed manually.
2. %PATH% expands to its current literal value at the moment setx
runs, and the result is written as REG_SZ instead of REG_EXPAND_SZ.
Lazy references like %SystemRoot% are baked in as literals, so
future changes to those variables stop propagating.
Replace with the same SetEnvironmentVariable call already used for
the PowerShell line, invoked through `powershell -Command` so cmd.exe
users get a safe command without us recommending two different APIs.
* fix(#3011): escape shimDir for PowerShell, bash, and POSIX export
CodeRabbit (Minor): a Windows username with a single quote (e.g.
"C:\Users\O'Neil\AppData\Roaming\npm") would interpolate raw into the
suggested commands, producing unparseable shell input the user can't
fix without understanding the bug.
Each shell context needs a different escape:
- PowerShell single-quoted strings: '' is the literal-quote escape.
Apply to both the PowerShell line and the cmd.exe line (which
delegates to PowerShell).
- Git Bash, where the path lives inside an outer single-quoted echo:
'\'' (close-quote, escaped-quote, reopen-quote) embeds a literal
single quote. The slash-conversion (\\ → /) still applies first.
- POSIX export (Linux/macOS) inside double quotes: escape \, $, ",
and backtick so the path is copied verbatim. $PATH lives outside
the escape and still expands at paste time.
Regression test: bug-3011-sdk-path-diagnostic.test.cjs locks in the
expected escape sequence for all three shell flavors.
* test(#2974): migrate 8 test files to typed-IR assertions
Replaces raw stdout/stderr substring matching with structured-field
assertions per CONTRIBUTING.md "Prohibited: Raw Text Matching on Test
Outputs". Adds shared infrastructure for typed error emission so this
pattern is the easy path going forward.
Shared infrastructure:
- core.cjs: ERROR_REASON frozen enum + setJsonErrorMode/getJsonErrorMode
- gsd-tools.cjs: --json-errors CLI flag, parsed before subcommand dispatch
- config.cjs: typed reasons at all 7 error sites
- graphify.cjs: GRAPHIFY_REASON enum + reason/timeout_ms in execGraphify result
- bin/install.js: pure buildSdkFailFastReport() IR builder + renderer
- hooks/gsd-session-state.sh, gsd-phase-boundary.sh: emit Claude Code
hookSpecificOutput JSON envelope with typed state_present/config_mode/
planning_modified/file_path fields (no-op when hooks.community is off)
Test migrations (all pass, 171 tests across the 8 files):
- bug-2649-sdk-fail-fast: assert on ir.reason / ir.context / ir.fix_command
- bug-2687-config-read-warning-parity: assert.equal stderr === ''
- bug-2796-arg-parsing-regression: assert on result.json.updated/.phase
- bug-2838-summary-rescue: parse rescue footer, assert mtime invariant
- bug-2943-config-get-context-window: parse JSON, assert ERROR_REASON.CONFIG_KEY_NOT_FOUND
- graphify: assert reason === GRAPHIFY_REASON.ENOENT/TIMEOUT
- hooks-opt-in: parse hookSpecificOutput, assert typed fields
- security-scan: reclassified as source-text-is-the-product (scan label
output and CI workflow YAML ARE the deployed contract)
Verification: lint-no-source-grep clean (0 violations), full suite
6741/6741 pass.
Closes#2974
* test(#2974): address CR feedback — typed code field, robust idempotency
Two CodeRabbit findings on #3016 addressed:
1. tests/hooks-opt-in.test.cjs:355 (Minor, inline) —
parsed.reason.includes('Conventional Commits') was still substring
matching after the typed-IR migration. Fixed at the source: the
gsd-validate-commit hook now emits a typed `code` field
('CONVENTIONAL_COMMITS_VIOLATION', 'COMMIT_SUBJECT_TOO_LONG')
alongside the human-readable `reason`. Test asserts strictEqual
on the code; the prose copy is no longer part of the test contract.
2. tests/bug-2838-summary-rescue-gitignored-planning.test.cjs:224-250
(Outside-diff) — mtimeMs alone can stay unchanged on coarse-grained
filesystems (HFS+, FAT) when two rewrites land within the same
timestamp tick, falsely passing the idempotency assertion.
Replaced with a full snapshot (mtimeMs, ctimeMs, size, ino, sha256
of contents) compared via assert.deepStrictEqual — the hash
catches any rewrite the timestamp would miss.
Verification: 30/30 pass on the two affected files; lint-no-source-grep
clean (0 violations across 368 test files).