Fixes#3164
Validators and find-phase hardcoded phasesDir = .planning/phases/, so projects
using the milestone-archive layout (.planning/milestones/v*-phases/) had an empty
diskPhases set, triggering W006 for every active phase and find-phase returning
found:false.
Add collectDiskPhases(planBase) helper that scans both flat layout and all
.planning/milestones/v*-phases/ subdirs. Wire it into cmdValidateConsistency,
cmdValidateHealth (both the Check 4 validPhases set and Check 8 diskPhases),
and refactor cmdFindPhase to iterate candidate search dirs so it also searches
milestone-archive dirs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes#3162
`resolve_model_ids` is a documented top-level config key (CONFIGURATION.md)
read by core.cjs and session-runner.ts, but was missing from the CJS and SDK
VALID_CONFIG_KEYS allowlists — causing config-set to reject it with
"Unknown config key".
`workflow._auto_chain_active` is internal runtime state intentionally excluded
from VALID_CONFIG_KEYS by #2530, but plan-phase, execute-phase, discuss-phase,
transition, and new-project workflows all write it via `config-set`. Without
a valid write path these calls emit spurious errors (silenced with `|| true`
but noisy in logs). A new RUNTIME_STATE_KEYS set in config-schema.cjs holds
keys that isValidConfigKey() accepts without exposing them as user-settable
options — preserving the #2530 intent while fixing the runtime error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(graphify): TDD-red design contract for #3170 commit-staleness signal
Captures the proposed extension to graphifyStatus() as 8 failing
assertions across 3 groups (git-aware, non-git, back-compat). Suite is
describe.skip()'d so npm test stays green on the branch — removing
.skip is the green-light moment when the enhancement is approved and
implementation lands.
Verified against safishamsi/graphify v0.7.0 release notes: the field
on graph.json is built_at_commit (full git HEAD), not commit_hash as
originally guessed in #3170. Tests assert against the verified name.
Design highlights captured in the file's docstring:
- Tri-state commit_stale (true/false/null) — null means "we don't
know" (pre-v0.7 graph or no git), distinct from false ("known fresh")
- Argument-injection fence /^[0-9a-f]{4,40}$/i validates built_at_commit
before it reaches `git` as an argv element
- Existing graphifyStatus() fields (node_count, edge_count, stale,
age_hours, etc.) are unchanged — back-compat fenced
Per the issue's enhancement template: no PR will be opened until the
issue is labeled `approved-enhancement`.
Refs #3170
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(graphify): surface commit-based staleness from graphify v0.7+ built_at_commit
Closes#3170
graphify v0.7+ embeds built_at_commit (full git HEAD) into graph.json at
write time. GSD's existing graphifyStatus() ignored it; staleness was
mtime-only, which is a poor proxy for "does this graph reflect the
current code." A CI-built graph rebuilt minutes ago against an old
checkout reads as FRESH on mtime but is materially stale.
graphifyStatus() now returns four additional fields on the success path:
built_at_commit short hash from graph.built_at_commit, or null
current_commit short hash of git HEAD, or null when no git
commits_behind git rev-list --count <built>..HEAD, or null
commit_stale true | false | null
Tri-state on commit_stale is load-bearing. null means "we don't know"
(pre-v0.7 graph, non-git cwd, unreachable commit) — semantically
distinct from false ("known fresh"). Agents reading null should fall
back to mtime; reading false can confidently skip a rebuild.
Security: built_at_commit is on-disk and user-influenceable. Without
validation, a hostile value (e.g. "--upload-pack=evil") would reach git
as an argv element and be interpreted as an option. The
/^[0-9a-f]{4,40}$/i fence rejects anything else as absent. spawnSync's
array args (no shell) is defense in depth, not the boundary.
Skill (commands/gsd/graphify.md) Step 2b renders one conditional line:
Source commit: abc1234 (5 commits behind HEAD)
Source commit: abc1234 (current)
Source commit: abc1234 (freshness unknown)
Pre-v0.7 graphs omit the line entirely — no confusing "Source commit:
unknown" rendered.
Also documents `graphify hook install` in docs/CONFIGURATION.md for
multi-dev teams who would otherwise hit graph.json merge conflicts on
parallel rebuilds (sub-enhancement 2 from #3170).
TDD red→green: tests/enh-3170-graphify-commit-staleness.test.cjs
(8 assertions across git-aware, non-git, back-compat) was committed
describe.skip()'d in c567f23d when the issue was filed; this commit
removes .skip and lands the implementation that makes them green.
Full suite 7503/7503 passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(#3127): make state.begin-phase idempotent on mid-flight phases
Root cause: cmdStateBeginPhase() unconditionally overwrote execution-
progress fields regardless of current phase status. When execute-phase
called it on a phase already mid-flight (--wave N resume), it regressed:
- Current Plan to 1 (from e.g. 3)
- Last Activity Description to 'context gathered; ready for plan-phase'
- Plan: N of M body line to 'Plan: 1 of M'
- last_updated timestamp to an older value
- progress.percent could decrease
Fix: read Status field before writing. If phase is already executing
(Status: Executing Phase N), skip execution-progress fields and only
update fields safe on resume:
- Last Activity date (always safe)
- Resume-specific 'execution resumed (wave continue)' activity line
First-time execution (Status != Executing Phase N) writes all fields
as before -- no regression on the normal path.
Regression test: 4 real unit tests using synthetic STATE.md files:
- mid-flight phase does not reset Current Plan (was the bug)
- mid-flight phase does not overwrite stopped_at narrative
- fresh phase sets Current Plan to 1 (normal path, no regression)
- both paths update Last Activity date (safe field)
Suite: 6990/6990. Closes#3127.
* fix(lint+state): allow-test-rule, escapeRegex phaseNumber in idempotency guard
* fix(#3128): extend roadmap.cjs plan-count to match {N}-PLAN-{NN}-{slug}.md
Root cause: same regex flaw as #2893 (fixed in phase.cjs by #2896).
The manager-dashboard countPhasePlansAndSummaries() in roadmap.cjs was
not updated alongside the phase.cjs fix. Files like 5-PLAN-01-setup.md
end in -setup.md, not -PLAN.md, so plan_count returned 0.
Symptom: init manager returned plan_count=0 / disk_status=discussed for
fully-planned phases, triggering redundant background planner agents that
correctly detected existing plans and declined -- wasted runs.
Fix: apply the same looksLikePlanFile pattern from phase.cjs with
PLAN-OUTLINE and pre-bounce exclusions to countPhasePlansAndSummaries.
Regression test: tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs
Suite: 6985/6985. Closes#3128.
* fix(lint): allow-test-rule for roadmap isPlanFile structural contract test
* 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(#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.
* 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).
* fix(#2973): /gsd-profile-user writes dev-preferences.md to skills/ not legacy commands/gsd/
v1.39.0's install summary claimed the legacy ~/.claude/commands/gsd/
directory had been removed in favor of skills-only architecture, but
the cmdGenerateDevPreferences writer at profile-output.cjs:781 still
defaulted to the legacy path. Every /gsd-profile-user --refresh
deterministically re-created the legacy directory.
Missed in PR #1540's migration because dev-preferences is a
runtime-generated user artifact, not a GSD-shipped command file.
Fix:
- Writer default: ~/.claude/skills/gsd-dev-preferences/SKILL.md
- profile-user.md Display message + artifact list reference new path
- New migrateLegacyDevPreferencesToSkill(targetDir, saved) installer
helper. Called at all 5 skills-aware install branches. Copies
preserved legacy dev-preferences.md into skills/gsd-dev-preferences/
SKILL.md, but ONLY if no SKILL.md already exists -- never clobbers
user-customized skill content.
Tests: bug-2973-profile-user-skills-path.test.cjs runs the writer in
a subprocess (core.cjs:output uses fs.writeSync(1, ...) which bypasses
in-process stubbing), asserts the writer's command_path field is the
skills location, the file is on disk at that path, the legacy path is
NOT created. Tests for migration helper assert it writes when no skill
exists and skips when one does.
Closes#2973
* chore(#2973): add changeset fragment for PR #3003
* fix(#2973): rephrase comment to avoid cline-install leaked-path lint
The new comment at line 780 of profile-output.cjs literally contained
the string '~/.claude/commands/gsd/' which the cline-install
leaked-path regression test (tests/cline-install.test.cjs:175)
correctly flagged.
Cline transforms .claude/skills/ -> .cline/skills/ in installed .cjs
files but does not transform .claude/commands/. The new comment talks
about the legacy 'commands/gsd' subdirectory without the ~/.claude/
prefix, so the lint passes. The path semantics are unchanged -- the
runtime construction at line 787 still uses path.join(os.homedir(),
'.claude', 'skills', ...) which the lint regex does not match.
* test(#2973): add timeout to spawnSync to prevent CI hangs (CR feedback)
CodeRabbit on PR #3003: without a timeout, a regression that hangs the
writer or dispatcher would block CI indefinitely. Added a 30s timeout
(generous for what should complete in <1s) and an explicit signal
assertion so a timeout trip surfaces as a clear test failure with
context rather than a hung worker.
* test(#2973): add allow-test-rule annotation for legitimate product-text parsing
The new var-binding lint from #2982/#2985 caught readFileSync(...).match()
and readFileSync(...).includes() calls in this test. Both are legitimate
structural assertions against the product workflow markdown, not source-grep:
- match() extracts the path from a structured Display: "..." line and
asserts on the typed path value (same pattern as bug-2470's installer
scanForLeakedPaths regex test).
- includes() asserts the absence of a legacy path literal.
profile-user.md IS the shipped workflow artifact, and its Display: line
IS what the user sees. Per the existing test-rigor convention, this is
the source-text-is-the-product justification category.
Annotated with allow-test-rule citing that category.
* chore(#3003): drop direct CHANGELOG.md edit; release entry now lives in .changeset/
The changeset-fragment workflow (#2975) renders fragments into
CHANGELOG.md at release time. Direct edits to [Unreleased] on
each PR caused merge conflicts on every concurrent PR. This commit
restores CHANGELOG.md to match origin/main; the release entry for
this fix is preserved in the .changeset/*.md fragment(s) on this
branch, which the release workflow consolidates.
* fix(#2973): preserve user-owned gsd-dev-preferences skill across wipe (CR)
CodeRabbit on PR #3003 caught a real bug: copyCommandsAsClaudeSkills()
wipes ALL gsd-* skill directories at the top of every install, then
reinstalls from the package source. Since gsd-dev-preferences is
user-generated (written by /gsd-profile-user --refresh) and NOT
shipped by the npm package, the wipe deletes the user's customized
SKILL.md with nothing to restore from.
Fix: USER_OWNED_SKILLS allow-list in copyCommandsAsClaudeSkills.
Snapshot files under skills/gsd-dev-preferences/ before the wipe,
restore after. Same preserve/restore pattern as PR #1924.
Tests: bug-2973 grows by 2 cases:
- user-customized SKILL.md survives the wipe
- non-user-owned gsd-* skills are still wiped (preservation is opt-in)
* fix(#2994): move verify-reapply-patches.cjs to get-shit-done/bin/ so installer ships it
scripts/verify-reapply-patches.cjs (added in #2972 to close the
verified-yes-without-checking gap from #2969) shipped in the npm tarball
but never reached user installs: bin/install.js copies get-shit-done/
recursively but does not copy the top-level scripts/ directory.
Effect: every fresh install hit `Cannot find module …/scripts/verify-reapply-patches.cjs`
on Step 5 of /gsd-reapply-patches. The whole point of moving
verification out of LLM-driven prose into a deterministic script is
undone if the script does not resolve at runtime.
Fix: move the script to get-shit-done/bin/verify-reapply-patches.cjs
(same pattern as gsd-tools.cjs and other runtime bin scripts that the
installer ships) and update reapply-patches.md Step 5 to invoke
${GSD_HOME}/get-shit-done/bin/verify-reapply-patches.cjs.
Tests:
- bug-2969 SCRIPT path updated to the new location
- New bug-2994-verify-reapply-patches-installed-path.test.cjs parses
reapply-patches.md into structured invocation records and asserts
every node ${GSD_HOME}/... reference lives under get-shit-done/
(the installed tree). Catches future regressions where someone moves
a runtime-needed script back to scripts/.
Closes#2994
* chore(#2994): add changeset fragment for PR #3000
* chore(#2994): add changeset fragment for PR #3000
* docs(#2994): update verifier-script-location comment to reflect new path (CR)
CodeRabbit on PR #3000: the parenthetical at line 278 still said the
script ships under scripts/, but this PR moved it to get-shit-done/bin/.
Updated the prose to reference the new location and the installer
target path.
* chore(#3000): drop direct CHANGELOG.md edit; release entry now lives in .changeset/
The changeset-fragment workflow (#2975) renders fragments into
CHANGELOG.md at release time. Direct edits to [Unreleased] on
each PR caused merge conflicts on every concurrent PR. This commit
restores CHANGELOG.md to match origin/main; the release entry for
this fix is preserved in the .changeset/*.md fragment(s) on this
branch, which the release workflow consolidates.
* fix(#2992): deterministic latest-version check — package name is a constant, not LLM choice
The /gsd-update workflow's check_latest_version step was prescribed in
LLM-driven prose: "run `npm view get-shit-done-cc version`". The
executing model could and did shortcut the prescription and invent
npm queries against name-shaped guesses — `@get-shit-done/cli`,
`get-shit-done-cli`, `gsd` — all of which 404 or, worse, return an
unrelated typosquat (the 2016 `get-shit-done` timer package). Same
architectural anti-pattern as #2969 (Hunk Verification Gate where
the LLM filled `verified: yes` without checking).
Implementation built TDD per #2992:
get-shit-done/bin/check-latest-version.cjs
- PACKAGE_NAME = 'get-shit-done-cc' as a module constant; not
parameterised, not exposed for override.
- checkLatestVersion({ spawn? }) returns
{ ok: bool, version?: string, reason: CHECK_REASON.X, detail? }
via a frozen enum: OK / FAIL_NPM_FAILED / FAIL_INVALID_OUTPUT.
- --json mode emits the structured record on stdout for the
workflow to parse via jq.
- Windows-aware: uses { shell: process.platform === 'win32' }
since npm is npm.cmd on Windows (same lesson as #2962).
- Stored under get-shit-done/bin/ (not top-level scripts/) because
that path IS in the user's installed config dir; top-level
scripts/ ships in the npm tarball but is not copied into
~/.claude/ at install time.
tests/bug-2992-check-latest-version.test.cjs
- 7 tests, all assertions on the typed CHECK_REASON enum + the
structured record. Injectable spawn function so no real npm
process is invoked. Covers OK, npm-non-zero, invalid-output,
empty-output, pre-release semver, PACKAGE_NAME constant lock,
enum-shape lock.
get-shit-done/workflows/update.md
- check_latest_version step rewritten to call the script via
`node "${GSD_HOME}/get-shit-done/bin/check-latest-version.cjs"
--json` and parse the structured response with jq. Explicit
"Do NOT run `npm view` or `npm search` directly" guidance
cites #2992 so future contributors understand why.
Closes#2992
* fix(#2992): trailing slash on GSD_HOME default to satisfy bare-path lint
The bug-2470 regression test scans update.md for bare `$HOME/.claude`
references (no trailing slash). The PR added one in the new
check_latest_version step. Fix: trailing slash on the default value
(`${GSD_HOME:-$HOME/.claude/}`). Bash POSIX collapses the resulting
double slash; the lint pattern's negative lookahead is now satisfied.
* fix(#2992): emit GSD_DIR from get_installed_version, use it in check_latest_version
Addresses CodeRabbit feedback: the previous `${GSD_HOME:-$HOME/.claude/}`
fallback hardcoded the Claude runtime path, which silently breaks for
non-Claude runtimes (gemini, codex, opencode, kilo).
Fix:
- get_installed_version now emits a 4th line with the resolved config
dir ($LOCAL_DIR or $GLOBAL_DIR), captured by callers as GSD_DIR.
- check_latest_version uses $GSD_DIR/get-shit-done/bin/check-latest-version.cjs.
Empty GSD_DIR (UNKNOWN scope) skips the version check and falls
through to fresh-install path.
This keeps the package name deterministic (#2992) AND respects the
detected runtime, instead of assuming Claude.
* chore(#2992): add changeset fragment for PR #2993
* chore(#2992): add changeset fragment for PR #2993
* fix(#2992): consolidate LATEST_RESULT parsing inside the GSD_DIR guard
CodeRabbit on PR #2993: the previous structure separated the GSD_DIR
guard from the jq parsing, so when GSD_DIR was empty the parsing block
ran against an unset LATEST_RESULT and produced misleading 'couldn't
check for updates' diagnostics instead of clean 'no_install_detected'.
Move all field assignments inside the conditional so the skip path
seeds LATEST_OK=false, LATEST_VERSION='', LATEST_REASON='no_install_detected',
and LATEST_STATUS=0 atomically.
* fix(#2992): emit GSD_DIR in early-return; add code-block lang and spawnSync timeout (CR)
CodeRabbit on PR #2993 caught three issues:
1. (Major) The early-return path in get_installed_version (PREFERRED_CONFIG_DIR
fast path) only echoed 3 lines, but PR #2993 changed the contract to 4
(GSD_DIR is now line 4). Downstream check_latest_version misread valid
installs as UNKNOWN. Added `echo "$PREFERRED_CONFIG_DIR"` before exit 0.
2. (Minor) Markdown MD040: fenced code block at line 310 was missing a
language identifier. Added ```text.
3. (Quick win) spawnSync('npm view ...') had no timeout, so a hung network
could block /gsd-update indefinitely. Added 15s timeout; on timeout
spawnSync returns with signal !== null and the existing failure path
emits FAIL_NPM_FAILED.
* fix(#3008): kill cross-process race in install-minimal:307 mid-copy test
Old shape compared listTmpStageDirs() snapshots before/after the
mid-copy throw. Under scripts/run-tests.cjs --test-concurrency=4,
tests/install-minimal-all-runtimes.test.cjs runs in a parallel
subprocess and also creates gsd-minimal-skills-* dirs in shared
os.tmpdir(). The parallel process's create/remove activity between
this test's two snapshots caused deterministic failure when timing
aligned -- presented as 'flaky' but is a real race.
CI failure data (PR #2993 run 25238555786):
expected (before): ['gsd-minimal-skills-km1O1O']
actual (after): []
Both processes behaved correctly in isolation. The test was wrong:
it observed a shared filesystem state across processes.
Fix: stub fs.mkdtempSync inside this test to record THIS call's
stage dir path. After the throw, assert fs.existsSync(stagedDir)
=== false. Direct observation of the function's own behavior; no
global tmpdir scan; no parallel-process interference.
Closes#3008
* fix(#2992): distinguish timeout from npm failure; guard empty LATEST_RESULT (CR)
CodeRabbit on PR #2993 (post-fix-up review) caught two improvements:
1. (Low value) check-latest-version.cjs:55-61 — when spawnSync times
out, r.status is null and r.signal is set (e.g. 'SIGTERM'), but
r.stderr is empty. Without the signal-first branch, both timeouts
and genuine npm failures shaped as 'npm exited non-zero' in detail,
making logs ambiguous. Added explicit signal-first branch:
'npm timed out (signal: SIGTERM)'.
2. (Quick win) update.md:284-315 — when node is missing or the script
doesn't exist, LATEST_RESULT is empty. Piping empty to jq parses
without error but leaves LATEST_OK / LATEST_REASON as empty
strings, producing the user-visible diagnostic
'Couldn\'t check for updates (reason: , exit: N)' with a blank
reason. Added an explicit guard that sets LATEST_REASON to
'script_not_found_or_node_unavailable' when LATEST_RESULT is empty,
so operators see a meaningful failure message.
Tests: bug-2992 grows by 2 cases (timeout signal detail + empty
stderr fallback).
* fix(#2997): mask SECRET_CONFIG_KEYS in SDK config-set/get and init responses
The CJS→TS port at sdk/src/query/config-mutation.ts:240,243 and
config-query.ts:122,128,132 dropped the masking layer that secrets.cjs
spec defines for brave_search/firecrawl/exa_search. Result: the SDK
echoed plaintext API keys into machine-readable JSON output (stdout,
transcripts, CI logs).
Adjacent leak in init.ts:673-675 / init.cjs:728-730: the init bundle
passed config.brave_search through raw, leaking the API key whenever
the user had stored one.
Fix:
- New sdk/src/query/secrets.ts ports SECRET_CONFIG_KEYS, isSecretKey,
maskSecret, maskIfSecret. Exact CJS parity (verified by 17 tests
in secrets.test.ts that import secrets.cjs and compare).
- config-set masks value + previousValue in response; on-disk plaintext
intact (key stays usable).
- config-get masks read response. --default flows through unmasked
(user's own input, not stored secret).
- init.ts/init.cjs mask string values only; booleans (availability
flags) pass through unchanged so the typed contract is preserved.
Tests: 17 in secrets.test.ts (including CJS parity), 5 in
config-mutation.test.ts (#2997 block — covers on-disk-preserved,
previousValue masking, short-value, unset, non-secret pass-through),
4 in config-query.test.ts.
Closes#2997
* chore(#2997): add changeset fragment for PR #2999
* chore(#2997): add changeset fragment for PR #2999
* chore(#2999): drop direct CHANGELOG.md edit; release entry now lives in .changeset/
The changeset-fragment workflow (#2975) renders fragments into
CHANGELOG.md at release time. Direct edits to [Unreleased] on
each PR caused merge conflicts on every concurrent PR. This commit
restores CHANGELOG.md to match origin/main; the release entry for
this fix is preserved in the .changeset/*.md fragment(s) on this
branch, which the release workflow consolidates.
* fix(workflows): assert HEAD on per-agent branch before worktree commits
Worktree-mode setup could leave HEAD attached to a protected branch (master),
causing agent commits to land there. The previous response was a destructive
self-recovery via 'git update-ref refs/heads/master <sha>', which silently
rewinds the protected branch and destroys concurrent commits in multi-active
scenarios (parallel agents, user committing while agent runs).
- Reorder <worktree_branch_check> in execute-phase.md and quick.md to assert
HEAD via 'git symbolic-ref' BEFORE any 'git reset --hard'. HALT with a
blocker if HEAD is on main/master/develop/trunk/release/* or detached.
- Add a per-commit HEAD assertion (step 0) to gsd-executor.md
<task_commit_protocol>; HEAD attachment can drift after 'git checkout <sha>'.
- Forbid 'git update-ref refs/heads/<protected>' in
<destructive_git_prohibition>; surface the blocker rather than self-heal.
- Remove '--no-verify' as the worktree-mode default in execute-phase.md,
execute-plan.md, quick.md, and references/git-integration.md. Hooks now
run on every executor commit; opt out only via workflow.worktree_skip_hooks.
- Add regression test that parses the worktree_branch_check blocks structurally
and asserts the symbolic-ref check precedes the reset --hard, no workflow
performs update-ref on a protected ref, and --no-verify is no longer the
default in any parallel-execution prompt.
* fix(#2924): address CodeRabbit review findings on worktree HEAD PR
- Add positive worktree-agent-* allow-list to <task_commit_protocol> step 0
in gsd-executor.md and to <worktree_branch_check> in execute-phase.md and
quick.md. The deny-list (main|master|develop|trunk|release/*) silently
allowed feature/* and other arbitrary branches outside the agent namespace.
- Register workflow.worktree_skip_hooks in both config schemas
(sdk/src/query/config-schema.ts and get-shit-done/bin/lib/config-schema.cjs)
and document it in docs/CONFIGURATION.md so config-set accepts it.
- Fix stash lifecycle in execute-phase.md post-wave hook validation: stash
under a named ref and pop after the hook run; warn on pop failure.
- Pre-dispatch PLAN.md commit in quick.md: gate on git diff --cached --quiet
for idempotency and exit 1 with a clear error on commit failure (both the
--no-verify and the normal branches) — no more swallowing real errors.
- Test fixes (tests/bug-2924-worktree-head-attachment.test.cjs):
- Parse the protected-branch alternation structurally and require
main, master, develop, trunk, release/.* (release/* was previously
skipped by the \\b...\\b regex).
- Use fs.readdirSync(dir, { recursive: true }) so workflows in nested
subdirectories are also asserted against the update-ref ban.
- Add allow-list assertions for execute-phase.md, quick.md, and
gsd-executor.md to lock in the new positive namespace check.
* test(#2924): assert sub-section end marker exists before slicing
* test(#2924): use section boundary instead of fixed window for parallel-agents slice
* fix(config-get): return schema default for context_window when absent (#2943)
cmdConfigGet in bin/lib/config.cjs now consults a SCHEMA_DEFAULTS map before
emitting "Key not found", so context_window (and any future schema-defaulted
keys) return their default value (exit 0) when not set in config.json.
Also updates the stale subagent-timeout.test.cjs assertion that expected the
old broken behavior (exit 1 / "Key not found") to match the corrected behavior.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: use distinct sentinel to prove --default wins over schema default (#2943)
* docs: update CHANGELOG.md for #2943 fix
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed
root that the installer wipes on update. GSD_MANAGED_DIRS in gsd-tools.cjs
was missing 'skills', so user-added skill directories (e.g.
skills/custom-skill/SKILL.md) were never walked and silently destroyed
during /gsd-update.
- Add 'skills' to GSD_MANAGED_DIRS so the directory is walked
- Add tests/bug-2942-detect-custom-skills.test.cjs with 5 targeted tests
- Update tests/update-custom-backup.test.cjs: replace the now-incorrect
"skills/ must NOT be scanned" assertion (written pre-#2790) with a test
that verifies custom skills ARE detected and GSD-owned skills are not
falsely flagged
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs in the audit-open dispatch case in bin/gsd-tools.cjs:
1. Bare output(...) calls (only core.output is in scope) threw
ReferenceError: output is not defined on every invocation,
blocking the first step of /gsd-complete-milestone.
2. Even after switching to core.output(formattedReport, raw), the
human-readable branch JSON-stringified the formatted text because
core.output only bypasses JSON encoding when called as
core.output(null, true, rawValue).
Fix:
- --json path: core.output(result, raw) — pass the object,
let core.output JSON-stringify (don't pre-stringify).
- text path: core.output(null, true, formatAuditReport(result))
— use the rawValue form to emit verbatim section dividers and
item lists.
Adds tests/bug-2911-audit-open-output-shape.test.cjs which parses
both modes structurally — line-by-line for text mode (asserting the
report headers exist as standalone lines, not as escaped \n inside a
JSON quoted string), and JSON.parse + key-by-key shape assertions for
--json mode (matching the contract returned by auditOpenArtifacts).
Adds Hermes Agent as a supported installation target. Users can run
\`npx get-shit-done-cc --hermes\` to install all 86 GSD commands as
skills under \`~/.hermes/skills/gsd-*/SKILL.md\`, following the same
open skill standard as Claude Code 2.1.88+, Qwen Code, Antigravity,
Trae, Augment, and Codebuddy.
Hermes Agent is an open-source AI agent framework by Nous Research
(NousResearch/hermes-agent, MIT). Its skill loader accepts the Claude
skill format as-is: frontmatter parsed with PyYAML SafeLoader (unknown
keys like \`allowed-tools\` / \`argument-hint\` ignored), body XML tags
(\`<objective>\`, \`<execution_context>\`, \`<process>\`) passed directly
to the model. Compatibility proven end-to-end with all 86 GSD skills
loading cleanly, \`skill_view()\` returning full bodies, and
\`build_skills_system_prompt()\` emitting them into the agent system
prompt — zero Hermes code changes required.
Changes:
- \`bin/install.js\`: --hermes flag, getDirName/getGlobalDir/getConfigDirFromHome
support, HERMES_HOME env var (native to Hermes — used for profile
mode / Docker deploys), install/uninstall pipelines, interactive
picker option 10 (alphabetical: between Gemini and Kilo), .hermes
path replacements in copyCommandsAsClaudeSkills and
copyWithPathReplacement, legacy commands/gsd cleanup, CLAUDE.md ->
HERMES.md and "Claude Code" -> "Hermes Agent" content rewrites in
skills/agents/hooks, runtime-appropriate finish message.
- \`get-shit-done/bin/lib/core.cjs\`: add hermes to KNOWN_RUNTIMES;
add RUNTIME_PROFILE_MAP.hermes with OpenRouter-slug defaults
(Hermes is provider-agnostic; these defaults resolve across
OpenRouter, native Anthropic, and Copilot via Hermes' aggregator-
aware resolver, and are overridable per-tier via
model_profile_overrides.hermes.{opus,sonnet,haiku}).
- \`README.md\`: Hermes Agent in tagline, runtime list, verification
command, install/uninstall examples, \`--hermes\` flag reference.
- \`tests/hermes-install.test.cjs\`: new, 14 tests covering directory
mapping, HERMES_HOME env var precedence, install/uninstall
lifecycle, user-skill preservation, engine cleanup.
- \`tests/hermes-skills-migration.test.cjs\`: new, 11 tests covering
frontmatter conversion, path replacement (~/.claude/ ->
\$HERMES_HOME/skills/), CLAUDE.md -> HERMES.md, "Claude Code" ->
"Hermes Agent", stale skill cleanup, SKILL.md format validation.
- \`tests/multi-runtime-select.test.cjs\`: updated for new option
numbering (hermes=10, kilo=11, opencode=12, qwen=13, trae=14,
windsurf=15, all=16).
- \`tests/kilo-install.test.cjs\`: updated assertions for Kilo having
moved from option 10 to option 11.
Closes#2841
Implementation notes:
- Zero custom code paths: Hermes reuses copyCommandsAsClaudeSkills()
identical to Qwen Code / Antigravity pattern.
- Path replacement: ~/.claude/, \$HOME/.claude/, ./.claude/ ->
.hermes equivalents in skill/agent/hook content.
- Config precedence: --config-dir > HERMES_HOME > ~/.hermes (matches
how Hermes itself resolves its home directory).
- Legacy cleanup: removes commands/gsd/ if present from a prior
install, preserving dev-preferences.md (same as Qwen).
- No external dependencies added.
Testing: 5841 / 5841 tests pass (0 failures, 0 regressions)
- 14 new tests in hermes-install.test.cjs
- 11 new tests in hermes-skills-migration.test.cjs
- multi-runtime-select.test.cjs renumbered + 1 new test (single choice for hermes)
* fix: parse non-REQ IDs in gap-analysis and ignore table headers
* fix: parse requirement IDs from first traceability column only
---------
Co-authored-by: Tom Boucher <thomas.boucher@sas.com>
* fix(#2893): surface non-canonical plan filenames instead of silently returning zero plans
Reporter saw `plan_count: 0` from `/gsd:execute-phase` even though five
plan files existed on disk. Investigation showed the planner had written
files like `01-PLAN-01-foundation.md`, while `phase-plan-index`'s strict
filter (`f.endsWith('-PLAN.md') || f === 'PLAN.md'`) rejected them
silently — collapsing two distinct states into the same `plans: []`
return:
- directory truly has no plans (legit empty)
- directory has plans but the filter rejected them (user/agent error)
The canonical contract is documented in three places:
- `agents/gsd-planner.md` write_phase_prompt step (lines 1063-1080)
- `commands/gsd/plan-phase.md`
- `references/universal-anti-patterns.md` (rule 26)
It mandates `{padded_phase}-{NN}-PLAN.md` and explicitly forbids
`PLAN-NN.md` / `01-PLAN-01.md` / `plan-NN.md` etc. The strict filter is
correct per that contract. The bug is that the executor never tells the
user when the contract was violated — they just see `plan_count: 0`
with no signal.
Fix: add a diagnostic helper `describeNonCanonicalPlans()` that scans
the phase directory for files matching `*PLAN*.md` (the diagnostic net)
that the canonical filter rejected, excluding legit derivatives like
`*-PLAN-OUTLINE.md` and `*-PLAN.pre-bounce.md`. When offenders exist,
return a `warning` field naming each one and citing the canonical
pattern so the user knows what to rename to.
Wired into the three filter sites:
- `phase-plan-index` (the executor's main entry point)
- `phases list --type plans`
- `find-phase`
The strict filter itself is unchanged — existing canonical plans behave
identically. This is purely a diagnostic that converts silent-empty
into loud-with-actionable-error.
Tests:
- `phase-plan-index returns warning for reporter's exact filename
pattern (`01-PLAN-01-foundation.md`)`
- `truly empty dir does not emit a warning`
- `canonical plans + outline + pre-bounce files do not emit a warning`
Closes#2893
* test(#2893): add parity tests for find-phase and phases list --type plans warnings
CodeRabbit's only finding on the prior commit: I wired the warning into
three filter sites (`phase-plan-index`, `find-phase`,
`phases list --type plans`) but only `phase-plan-index` had test
coverage for the warning shape. The other two paths could silently
diverge during future refactors — exactly the silent-drift class of bug
this fix exists to prevent.
Add four parity tests mirroring the existing two:
- find-phase: non-canonical filenames produce a warning naming each
offender + citing the canonical pattern.
- find-phase: canonical plan + derivative files (PLAN-OUTLINE,
pre-bounce) produce no warning.
- phases list --type plans: same non-canonical case, but assert the
warning is prefixed with `${dir}: ` (this path aggregates across
phase directories so each offender is tagged with its dir).
- phases list --type plans: canonical case, no warning.
`node --test tests/phase.test.cjs`: 98/98 pass (was 94, +4 new).
* feat(#2792): namespace meta-skills retargeted at the post-#2790 surface
This branch is now based on #2790's HEAD (the consolidation PR) instead
of main, and every routing table targets the consolidated surface so a
user routed by a namespace meta-skill never lands at a deleted /
folded sub-skill.
Cross-PR inconsistencies the original PR #2825 carried (vs #2790):
- ns-ideate routed to gsd-note / gsd-add-todo / gsd-add-backlog /
gsd-plant-seed → all folded into gsd-capture by #2790. Now routes
to gsd-capture (the parent picks the mode from the user's intent).
- ns-context routed to gsd-scan and gsd-intel → folded into
gsd-map-codebase --fast / --query by #2790. Now routes to those
flag forms.
- ns-manage routed all workspace intent to gsd-list-workspaces (a
list-only entry) → CR also flagged the over-narrow target. #2790
folds into gsd-workspace; routing now points there.
- ns-workflow routed to gsd-research-phase → deleted outright by
#2790. Removed.
- ns-project routed to gsd-plan-milestone-gaps → deleted outright by
#2790. Removed.
- None of the namespaces previously surfaced #2790's new consolidated
skills (gsd-capture, gsd-phase, gsd-config, gsd-workspace,
gsd-progress). All five are now reachable through the routers.
- extract_learnings → extract-learnings (canonicalized by #2858).
Defect fixes within the namespace skills:
- Hyphen-form `name:` (gsd-workflow, …) per the canonical naming
contract — the colon-form addressed CR's drift complaint.
- `Skill` added to allowed-tools on every router. The body instructs
"Invoke the matched skill directly using the Skill tool" — without
Skill in the permission list the meta-skill cannot route at all.
New regression guard in tests/enh-2792-namespace-skills.test.cjs: every
gsd-* token in any namespace router's table column resolves to a
surviving commands/gsd/*.md file (or to a known consolidated parent for
flag-form targets like gsd-map-codebase --fast). This single test would
have caught every dead-end route the original PR shipped with.
Skill-count cap in tests/enh-2790-skill-consolidation.test.cjs now
filters out ns-*.md from its <= 63 cap. Namespace routers are
descriptor-only entries, not part of the consolidation surface that cap
is policing — they have their own contract in
tests/enh-2792-namespace-skills.test.cjs.
INVENTORY.md gains a "Namespace Meta-Skills" section with the 6 router
rows; INVENTORY-MANIFEST.json gains 6 entries; the headline count moves
59 → 65 to match.
Out of scope for this rebase: the gsd-health --context flag (PR #2825
advertised the contract but didn't implement it). That's a separate
feature concern and is left untouched here.
5908/5908 on `npm test`.
* feat(#2792): implement gsd-health --context utilization guard
The original PR #2825 advertised a `--context` flag on gsd-health with a
60%/70% utilization threshold table but never implemented the workflow
logic — CR caught it as a contract leak, the rebase deferred it. This
commit closes the gap with TDD red/green/refactor.
Math layer (pure):
- get-shit-done/bin/lib/context-utilization.cjs
classifyContextUtilization(tokensUsed, contextWindow) →
{ percent, state }
State boundaries use the exact ratio:
< 60% healthy / 60–70% warning / ≥ 70% critical (fracture point)
Display percent rounded for humans. Throws TypeError on non-integer
or out-of-range inputs.
- STATES = Object.freeze({ HEALTHY, WARNING, CRITICAL }) exported
so callers reference the names by symbol, not by literal string.
SDK CLI integration:
- get-shit-done/bin/gsd-tools.cjs
`validate context --tokens-used N --context-window M [--json]`
routes to the classifier, owns the recommendation copy (the
classifier intentionally does not — keeps the renderer free to
evolve without touching the math layer or its tests), and uses
core.output's rawValue path for the sync-flush guarantee.
- sdk/src/query/validate.ts + sdk/src/query/index.ts
TypeScript validateContext handler registered at 'validate.context'
and 'validate context'. Mirrors the CJS classifier inline (15 lines
of arithmetic; not worth a shared cross-language module).
User-facing wiring:
- commands/gsd/health.md frontmatter advertises --context, body
documents the three-state threshold table.
- get-shit-done/workflows/health.md adds a `context_check` step
that's reached only when --context is set. Step calls
`gsd-sdk query validate.context` with self-reported tokensUsed and
contextWindow, prints the SDK output verbatim, and ends. Includes
a TEXT_MODE plain-text fallback for non-Claude runtimes per #2012.
Tests:
- tests/context-utilization.test.cjs (17 tests) — pure-function
contract: state thresholds at every boundary, percent rounding,
input validation, return-shape (no recommendation field — that's
the renderer's job).
- tests/validate-context.test.cjs (9 tests) — SDK CLI plumbing:
arg parsing errors, JSON vs human rendering, recommendation copy
pinned per state.
- tests/enh-2792-namespace-skills.test.cjs (4 new tests) — markdown
contract: --context advertised in argument-hint, threshold table
in command body, context_check step exists in workflow, step
invokes gsd-sdk query validate.context with both flags.
Inventory bookkeeping:
- docs/INVENTORY.md "CLI Modules" 31 → 32; new row for
context-utilization.cjs.
- docs/INVENTORY-MANIFEST.json mirror.
5939/5939 on `npm test`.
* fix(#2836): audit-open quick SUMMARY filename + UAT terminal-status drift
Fixes two convention drifts in bin/lib/audit.cjs that produced false-positive
"open" items at every milestone close:
1. scanQuickTasks: looked for bare `SUMMARY.md`, but workflows/quick.md
mandates `${quick_id}-SUMMARY.md`. Now matches either filename so quick
tasks created via the documented workflow are recognized.
2. scanUatGaps: only treated `status: complete` as terminal, but
workflows/execute-phase.md uses `status: resolved` post-gap-closure.
Now treats both `complete` and `resolved` as terminal, with `result:
all_pass` as a fallback when status is absent.
Also reconciles workflows/help.md one-liner that referenced bare
`SUMMARY.md` so docs match the authoritative quick.md workflow.
Adds tests/bug-2836-audit-open-summary-uat-drift.test.cjs with 6
structural regression tests covering both fixes plus no-regression cases.
* refactor(#2836): hoist TERMINAL_UAT_STATUSES outside scanUatGaps loop
Address CodeRabbit nitpick: the Set was being recreated on each UAT file
iteration. Hoist to module scope so it is constructed once.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds Hermes Agent as a supported installation target. Users can run
\`npx get-shit-done-cc --hermes\` to install all 86 GSD commands as
skills under \`~/.hermes/skills/gsd-*/SKILL.md\`, following the same
open skill standard as Claude Code 2.1.88+, Qwen Code, Antigravity,
Trae, Augment, and Codebuddy.
Hermes Agent is an open-source AI agent framework by Nous Research
(NousResearch/hermes-agent, MIT). Its skill loader accepts the Claude
skill format as-is: frontmatter parsed with PyYAML SafeLoader (unknown
keys like \`allowed-tools\` / \`argument-hint\` ignored), body XML tags
(\`<objective>\`, \`<execution_context>\`, \`<process>\`) passed directly
to the model. Compatibility proven end-to-end with all 86 GSD skills
loading cleanly, \`skill_view()\` returning full bodies, and
\`build_skills_system_prompt()\` emitting them into the agent system
prompt — zero Hermes code changes required.
Changes:
- \`bin/install.js\`: --hermes flag, getDirName/getGlobalDir/getConfigDirFromHome
support, HERMES_HOME env var (native to Hermes — used for profile
mode / Docker deploys), install/uninstall pipelines, interactive
picker option 10 (alphabetical: between Gemini and Kilo), .hermes
path replacements in copyCommandsAsClaudeSkills and
copyWithPathReplacement, legacy commands/gsd cleanup, CLAUDE.md ->
HERMES.md and "Claude Code" -> "Hermes Agent" content rewrites in
skills/agents/hooks, runtime-appropriate finish message.
- \`get-shit-done/bin/lib/core.cjs\`: add hermes to KNOWN_RUNTIMES;
add RUNTIME_PROFILE_MAP.hermes with OpenRouter-slug defaults
(Hermes is provider-agnostic; these defaults resolve across
OpenRouter, native Anthropic, and Copilot via Hermes' aggregator-
aware resolver, and are overridable per-tier via
model_profile_overrides.hermes.{opus,sonnet,haiku}).
- \`README.md\`: Hermes Agent in tagline, runtime list, verification
command, install/uninstall examples, \`--hermes\` flag reference.
- \`tests/hermes-install.test.cjs\`: new, 14 tests covering directory
mapping, HERMES_HOME env var precedence, install/uninstall
lifecycle, user-skill preservation, engine cleanup.
- \`tests/hermes-skills-migration.test.cjs\`: new, 11 tests covering
frontmatter conversion, path replacement (~/.claude/ ->
\$HERMES_HOME/skills/), CLAUDE.md -> HERMES.md, "Claude Code" ->
"Hermes Agent", stale skill cleanup, SKILL.md format validation.
- \`tests/multi-runtime-select.test.cjs\`: updated for new option
numbering (hermes=10, kilo=11, opencode=12, qwen=13, trae=14,
windsurf=15, all=16).
- \`tests/kilo-install.test.cjs\`: updated assertions for Kilo having
moved from option 10 to option 11.
Closes#2841
Implementation notes:
- Zero custom code paths: Hermes reuses copyCommandsAsClaudeSkills()
identical to Qwen Code / Antigravity pattern.
- Path replacement: ~/.claude/, \$HOME/.claude/, ./.claude/ ->
.hermes equivalents in skill/agent/hook content.
- Config precedence: --config-dir > HERMES_HOME > ~/.hermes (matches
how Hermes itself resolves its home directory).
- Legacy cleanup: removes commands/gsd/ if present from a prior
install, preserving dev-preferences.md (same as Qwen).
- No external dependencies added.
Testing: 5841 / 5841 tests pass (0 failures, 0 regressions)
- 14 new tests in hermes-install.test.cjs
- 11 new tests in hermes-skills-migration.test.cjs
- multi-runtime-select.test.cjs renumbered + 1 new test (single choice for hermes)
* fix(#2787): track fenced code blocks in extractCurrentMilestone
The milestone-end search used a multiline regex against the raw
restContent string. Lines inside fenced code blocks (``` or ~~~)
that matched the milestone-heading pattern (e.g. `# note v1.0`)
prematurely set sectionEnd, hiding all phases after the block from
roadmap analyze, roadmap get-phase, and every downstream command.
Replace the regex match with a line-by-line scan that tracks fence
state. Lines inside an open fence are skipped regardless of content.
Adds three regression tests covering backtick fences, tilde fences,
and the roadmap get-phase code path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(#2787): track fence delimiter instead of toggling bare boolean
Replace the inFence boolean with fenceChar/fenceLen tracking so that
indented fences (up to 3 leading spaces) and mixed-delimiter content
(~~~ inside a backtick fence) are parsed correctly. A closing fence
is only recognised when it uses the same character as the opening
delimiter and has at least the same run length, matching the CommonMark
spec.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(#2787): require fence-only closing line — reject info-string lines as closers
A closing fence delimiter must contain only optional trailing whitespace.
A line like \`\`\`js inside an open fence has an info string and must not
close it. The previous regex /^\s{0,3}([`~]{3,})/ matched the opening of any
such line, so the closing check could toggle fenceChar off on an info-string
line and expose subsequent heading-like content to the milestone-end detector.
Fix: capture the trailing portion of every fence-candidate line and only clear
fenceChar when trailing matches /^\s*$/ (per CommonMark §4.5).
Adds a regression test covering the ```text / ```js nesting scenario.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(#2801): add ingest-docs handler to gsd-tools init dispatch
The `/gsd-ingest-docs` workflow was broken because `workflows/ingest-docs.md`
called `gsd-sdk query init.ingest-docs` but the installed binary is `gsd-tools`,
and `gsd-tools init` had no `ingest-docs` case in its dispatch switch.
- Added `cmdInitIngestDocs` function to `init.cjs` and exported it; returns
`project_exists`, `planning_exists`, `has_git`, `project_path`, `commit_docs`
- Added `case 'ingest-docs'` to the `init` switch in `gsd-tools.cjs`
- Updated `workflows/ingest-docs.md` to call `gsd-tools init ingest-docs`
(line 55) and `gsd-tools commit` (line 292) instead of `gsd-sdk query ...`
- Regression test: `tests/bug-2801-ingest-docs-handler.test.cjs`
Closes#2801
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(#2801): address CodeRabbit — commit_docs assertion, broader gsd-sdk detection, bash fence
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(#2769): tolerate Requirements header with colon inside bold delimiters
extractReqIds in sdk/src/query/init.ts and the legacy init.cjs port only
matched `**Requirements**:` (colon outside bold), so phases declared with
the equally-valid markdown form `**Requirements:**` (colon inside bold,
which is what the project's own templates emit) returned phase_req_ids:
null for both `init plan-phase` and `init execute-phase`.
The mirror-image bug in `phase complete`'s REQUIREMENTS.md traceability
sweep at get-shit-done/bin/lib/phase.cjs:871 only matched the inside-bold
form, silently skipping the REQ-ID checkbox flips for any roadmap that
used the outside-bold form. Both parsers now share the same canonical
regex that accepts all three rendered-identical variants:
**Requirements:** (colon inside bold)
**Requirements**: (colon outside bold)
**Requirements** : (space before outside colon)
Tests:
- tests/init.test.cjs — parameterized over the three header variants for
both init plan-phase and init execute-phase (6 new behavioral cases).
- sdk/src/query/init.test.ts — describe.each over the same variants
exercising initPlanPhase through the SDK.
- tests/bug-2769-requirements-header-variants.test.cjs — phase complete
flips REQ-001 in REQUIREMENTS.md across all three header variants.
Closes#2769
* refactor(#2769): centralize REQUIREMENTS_HEADER_RE constant per CodeRabbit
* fix(#2770): coerce non-string truths to preserve cross-cutting constraints
`cmdRoadmapAnnotateDependencies` skipped non-string truth entries via
`if (typeof t !== 'string') continue`. That avoided the TypeError reported
in #2770 but silently dropped legitimate constraints — numeric YAML scalars
(`- 3`) and kv-shaped truths from parseMustHavesBlock's continuation-kv
path (#2757) — from the cross-cutting analysis, leaving ROADMAP.md
under-annotated.
Replace the skip-guard with a `coerceTruthToString` helper that:
* passes strings through
* `String()`-coerces numbers, booleans, bigints
* extracts a string field (title, text, name, rule, path, provides) from
object-shaped items
Composes cleanly with #2757 (objects from kv continuation lines now
contribute their title rather than being dropped) and the existing
`splitInlineArray` quote-aware parser.
Tests: tests/bug-2770-annotate-deps-int-coerce.test.cjs
- numeric scalar truth shared across plans surfaces as constraint
- kv-shaped truth surfaces via title field
- bare-int depends_on regression guards on extractFrontmatter
Full suite: 5678 pass, 0 fail.
Closes#2770
* test(#2770): use array join() for multi-line fixtures per CONTRIBUTING
* refactor(#2770): cache trim() and avoid no-op truthCounts.set in aggregation
* refactor(state): drop unused args param and lift currentPhase in cmdStateCompletePhase
Two cleanup items surfaced by CodeRabbit review of PR #2759:
1. cmdStateCompletePhase(cwd, args, raw) — args is never read inside the
function. All sibling state subcommands use the leaner (cwd, raw) shape.
Remove the unused parameter and update the dispatch call in gsd-tools.cjs.
2. output() at line 1754 called fs.readFileSync(statePath) after
readModifyWriteStateMd had already released the lock, re-extracting
Current Phase via an extra fs read. The closure already computed
currentPhase at line 1704; lifting resolvedPhase into outer scope and
capturing it in the callback eliminates the post-lock read and closes the
small race window.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(#2761): apply CodeRabbit nitpicks with regression tests
Two CodeRabbit nitpicks from PR #2761 review, each landed with a
regression test so a future refactor can't unwind them.
1. tests/dispatcher.test.cjs — pin the enumerated subcommand list:
the 'state unknown subcommand errors' test now also asserts that
the dispatcher's error string includes 'complete-phase'. Without
this, a future reformat of the available-subcommands enumeration
could silently drop entries and the existing
'Unknown state subcommand' substring check would still pass.
2. get-shit-done/bin/lib/state.cjs — tighten the Phase fallback in
cmdStateCompletePhase: when STATE.md is missing the canonical
'**Current Phase:**' field and the only phase signal is the
decorated body line under '## Current Position' (e.g.
'Phase: 01 (Foo) — EXECUTING'), the previous fallback returned
the entire decorated string, producing messy downstream output:
Status: Phase 01 (Foo) — EXECUTING complete
Phase: 01 (Foo) — EXECUTING — COMPLETE
The fallback now strips everything past the leading
numeric/decimal token via /^\\s*([\\w.-]+)/ so degraded inputs
produce clean output identical to the canonical path.
3. tests/state.test.cjs — two new tests in a dedicated describe block:
- decorated Phase line writes clean Phase identifier
- canonical Current Phase wins over Current Position decoration
Both run real `gsd state complete-phase` against synthetic
STATE.md fixtures and assert on the rendered Status field.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>