Compare commits

...

54 Commits

Author SHA1 Message Date
Tom Boucher
b1a670e662 fix(#2697): replace retired /gsd: prefix with /gsd- in all user-facing text (#2699)
All workflow, command, reference, template, and tool-output files that
surfaced /gsd:<cmd> as a user-typed slash command have been updated to
use /gsd-<cmd>, matching the Claude Code skill directory name.

Closes #2697

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 10:59:33 -04:00
Tom Boucher
7c6f8005f3 test: destroy 9 config-schema.cjs/core.cjs source-grep tests, replace with behavioral config-set (#2696)
* test: destroy 9 config-schema.cjs/core.cjs source-grep tests, add behavioral config-set tests (#2691, #2693)

Replace source-grep theater with config-set behavioral tests:
- execute-phase-wave: config-set workflow.use_worktrees replaces VALID_CONFIG_KEYS grep
- inline-plan-threshold: delete redundant source-grep (behavioral test at L36 already covered it)
- plan-bounce: config-set for plan_bounce / plan_bounce_script / plan_bounce_passes replaces 3 key-presence greps
- code-review: config-set for code_review / code_review_depth replaces 2 greps; removes CONFIG_PATH constant
- thinking-partner: config-set features.thinking_partner replaces two greps (config-schema.cjs AND core.cjs)

Behavioral tests survive refactors (no path constants, no file reads). The config-schema.cjs →
core.cjs migration commit 990c3e64 happened because these tests groped source paths.

Add allow-test-rule: source-text-is-the-product annotations to legitimate product-content tests:
autonomous-allowed-tools, agent-frontmatter, agent-skills-awareness, bug-2334, bug-2346,
execute-phase-wave (MD reads), plan-bounce (workflow reads). Annotations explain WHY text
inspection is the right level of testing for AI instruction files.

Closes #2691
Closes #2693

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: address CodeRabbit findings on #2696

- agent-frontmatter.test.cjs: move allow-test-rule annotation from block comment
  to standalone // line comment so rule scanners can detect it
- thinking-partner.test.cjs: strengthen config-set test with config-get read-back
  assertion to verify the value was persisted, not just accepted (exit 0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: tighten thinking_partner config assertion per CodeRabbit (#2696)

Replace config-get output substring check (includes('true') false-positive
risk) with a direct JSON read of .planning/config.json, asserting the
exact persisted value via strictEqual. This also validates the config file
was created, catching silent key-acceptance without persistence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 10:50:54 -04:00
Tom Boucher
cd05725576 fix(#2661): unconditional plan-checkbox sync in execute-plan (#2682)
* fix(#2661): unconditional plan-checkbox sync in execute-plan

Checkpoint A in execute-plan.md was wrapped in a "Skip in parallel mode"
guard that also short-circuited the parallelization-without-worktrees
case. With `parallelization: true, use_worktrees: false`, only
Checkpoint C (phase.complete) then remained, and any interruption
between the final SUMMARY write and phase complete left ROADMAP.md
plan checkboxes stale.

Remove the guard: `roadmap update-plan-progress` is idempotent and
atomically serialized via readModifyWriteRoadmapMd's lockfile, so
concurrent invocations from parallel plans converge safely.

Checkpoint B (worktree-merge post-step) and Checkpoint C
(phase.complete) become redundant after A is unconditional; their
removal is deferred to a follow-up per the RCA.

Closes #2661

* fix(#2661): gate ROADMAP sync on use_worktrees=false to preserve single-writer contract

Adversarial review of PR #2682 found that unconditionally removing the
IS_WORKTREE guard violates the single-writer contract for shared
ROADMAP.md established by commit dcb50396 (PR #1486). The lockfile only
serializes within a single working tree; separate worktrees have
separate ROADMAP.md files that diverge.

Restore the worktree guard but document its intent explicitly: the
in-handler sync runs only when use_worktrees=false (the actual #2661
reproducer). Worktree mode relies on the orchestrator's post-merge
update at execute-phase.md lines 815-834, which is the documented
single-writer for shared tracking files.

Update tests to assert both branches of the gate:
- use_worktrees: false mode runs the sync (the #2661 case)
- use_worktrees: true mode does NOT run the in-handler sync
- handler-level idempotence and lockfile contention tests retained,
  scope clarified to within-tree concurrency only
2026-04-24 20:27:59 -04:00
Tom Boucher
c811792967 fix(#2660): capture prose after labeled bold in extractOneLinerFromBody (#2679)
* fix(#2660): capture prose after label in extractOneLinerFromBody

The regex `\*\*([^*]+)\*\*` matched the first bold span, so for the new
SUMMARY template `**One-liner:** Real prose here.` it captured the label
`One-liner:` instead of the prose. MILESTONES.md then wrote bullets like
`- One-liner:` with no content.

Handle both template forms:
- Labeled:  `**One-liner:** prose`  → prose
- Bare:     `**prose**`             → prose (legacy)

Empty prose after a label returns null so no bogus bullets are emitted.

Note: existing MILESTONES.md entries generated under the bug are not
regenerated here — that is a follow-up.

Closes #2660

* fix(#2660): normalize CRLF before one-liner extraction

Windows-authored SUMMARY files use CRLF line endings; the LF-only regex
in extractOneLinerFromBody would fail to match. Normalize \r\n and \r
to \n before stripping frontmatter and matching the one-liner pattern.

Adds test case (h) covering CRLF input.
2026-04-24 20:22:29 -04:00
Tom Boucher
34b39f0a37 test(#2659): regression guard against bare output() in audit-open handler (#2680)
* fix(#2659): qualify bare output() calls in audit-open handler

The audit-open dispatch case in bin/gsd-tools.cjs previously called bare
output() on both --json and text branches, which crashed with
ReferenceError: output is not defined. The core module is imported as
`const core`, so every other case uses core.output(). HEAD already
qualifies the calls correctly; this commit adds a regression test that
invokes `audit-open` and `audit-open --json` through runGsdTools and
asserts a clean exit plus non-empty stdout (and an explicit check that
the failure mode is not ReferenceError). The test fails on any revision
where either call reverts to bare output().

Closes #2659

* test(#2659): assert valid JSON output in --json mode

CodeRabbit nit: tighten --json regression coverage by parsing stdout
and asserting the result is a JSON object/array, not just non-empty.
2026-04-24 20:22:17 -04:00
Tom Boucher
b1278f6fc3 fix(#2674): align initProgress with initManager ROADMAP [x] precedence (#2681)
initProgress computed phase status purely from disk (PLAN/SUMMARY counts),
consulting the ROADMAP `- [x] Phase N` checkbox only for phases with no
directory. initManager, by contrast, applied an explicit override: a
ROADMAP `[x]` forces status to `complete` regardless of disk state.

Result: a phase with a stub directory (no SUMMARY.md) and a ticked
ROADMAP checkbox reported `complete` from /gsd-manager and `pending`
from /gsd-progress — same data, different answer.

Apply ROADMAP-[x]-wins as the unified policy inside initProgress, mirroring
initManager's override. A user who typed `- [x] Phase 3` has made an
explicit assertion; a leftover stub dir is the weaker signal.

Adds sdk/src/query/init-progress-precedence.test.ts covering six cases
(stub dir + [x], full dir + [x], full dir + [ ], stub dir + [ ],
ROADMAP-only + [x], and completed_count parity). Pre-fix: cases 1 and 6
failed. Post-fix: all six pass. No existing tests were modified.

Closes #2674
2026-04-24 20:20:11 -04:00
Tom Boucher
303fd26b45 fix(#2662): add state.add-roadmap-evolution SDK handler; insert-phase uses it (#2683)
/gsd-insert-phase step 4 instructed the agent to directly Edit/Write
.planning/STATE.md to append a Roadmap Evolution entry. Projects that
ship a protect-files.sh PreToolUse hook (a recommended hardening
pattern) blocked the raw write, silently leaving STATE.md out of sync
with ROADMAP.md.

Adds a dedicated SDK handler state.add-roadmap-evolution (plus space
alias) that:

  - Reads STATE.md through the shared readModifyWriteStateMd lockfile
    path (matches sibling mutation handlers — atomic against
    concurrent writers).
  - Locates ### Roadmap Evolution under ## Accumulated Context, or
    creates both sections as needed.
  - Dedupes on exact-line match so idempotent retries are no-ops
    ({ added: false, reason: "duplicate" }).
  - Validates --phase / --action presence and action membership,
    throwing GSDError(Validation) for bad input (no silent
    { ok: false } swallow).

Workflow change (insert-phase.md step 4):

  - Replaces the raw Edit/Write instructions for STATE.md with
    gsd-sdk query state.patch (for the next-phase pointer) and
    gsd-sdk query state.add-roadmap-evolution (for the evolution
    log).
  - Updates success criteria to check handler responses.
  - Drops "Write" from commands/gsd/insert-phase.md allowed-tools
    (no step in the workflow needs it any more).

Tests (vitest, sdk/src/query/state-mutation.test.ts): subsection
creation when missing; append-preserving-order when present;
duplicate -> reason=duplicate; idempotence over two calls; three
validation cases covering missing --phase, missing --action, and
invalid action.

This is the first SDK handler dedicated to STATE.md Roadmap
Evolution mutations. Other workflows with similar raw STATE.md
edits (/gsd-pause-work, /gsd-resume-work, /gsd-new-project,
/gsd-complete-milestone, /gsd-add-phase) remain on raw Edit/Write
and will need follow-up issues to migrate — out of scope for this
fix.

Closes #2662
2026-04-24 20:20:02 -04:00
Tom Boucher
7b470f2625 fix(#2633): ROADMAP.md is the authority for current-milestone phase counts (#2665)
* fix(#2633): use ROADMAP.md as authority for current-milestone phase counts

initMilestoneOp (SDK + CJS) derives phase_count and completed_phases from
the current milestone section of ROADMAP.md instead of counting on-disk
`.planning/phases/` directories. After `phases clear` at the start of a new
milestone the on-disk set is a subset of the roadmap, causing premature
`all_phases_complete: true`.

validateHealth W002 now unions ROADMAP.md phase declarations (all milestones
— current, shipped, backlog) with on-disk dirs when checking STATE.md phase
refs. Eliminates false positives for future-phase refs in the current
milestone and history-phase refs from shipped milestones.

Falls back to legacy on-disk counting when ROADMAP.md is missing or
unparseable so no-roadmap fixtures still work.

Adds vitest regressions for both handlers; all 66 SDK + 118 CJS tests pass.

* fix(#2633): preserve full phase tokens in W002 + completion lookup

CodeRabbit flagged that the parseInt-based normalization collapses distinct
phase IDs (3, 3A, 3.1) into the same integer bucket, masking real
STATE/ROADMAP mismatches and miscounting completions in milestones with
inserted/sub-phases.

Index disk dirs and validate STATE.md refs by canonical full phase token —
strip leading zeros from the integer head only, preserve [A-Z] suffix and
dotted segments, and accept just the leading-zero variant of the integer
prefix as a tolerated alias. 3A and 3 never share a bucket.

Also widens the disk and STATE.md regexes to accept [A-Z]? suffix tokens.
2026-04-24 18:11:12 -04:00
Tom Boucher
c8ae6b3b4f fix(#2636): surface gsd-sdk query failures and add workflow↔handler parity check (#2656)
* fix(#2636): surface gsd-sdk query failures and add workflow↔handler parity check

Root cause: workflows invoked `gsd-sdk query agent-skills <slug>` with a
trailing `2>/dev/null`, swallowing stderr and exit code. When the installed
`@gsd-build/sdk` npm was stale (pre-query), the call resolved to an empty
string and `agent_skills.<slug>` config was never injected into spawn
prompts — silently. The handler exists on main (sdk/src/query/skills.ts),
so this is a publish-drift + silent-fallback bug, not a missing handler.

Fix:
- Remove bare `2>/dev/null` from every `gsd-sdk query agent-skills …`
  invocation in workflows so SDK failures surface to stderr.
- Apply the same rule to other no-fallback calls (audit-open, write-profile,
  generate-* profile handlers, frontmatter.get in commands). Best-effort
  cleanup calls (config-set workflow._auto_chain_active false) keep
  exit-code forgiveness via `|| true` but no longer suppress stderr.

Parity tests:
- New: tests/bug-2636-gsd-sdk-query-silent-swallow.test.cjs — fails if any
  `gsd-sdk query agent-skills … 2>/dev/null` is reintroduced.
- Existing: tests/gsd-sdk-query-registry-integration.test.cjs already
  asserts every workflow noun resolves to a registered handler; confirmed
  passing post-change.

Note: npm republish of @gsd-build/sdk is a separate release concern and is
not included in this PR.

* fix(#2636): address review — restore broken markdown fences and shell syntax

The previous commit's mass removal of '2>/dev/null' suffixes also
collapsed adjacent closing code fences and 'fi' tokens onto the
command line, producing malformed markdown blocks and 'truefi' /
'true   fi' shell syntax errors in the workflows.

Repaired sites:
- commands/gsd/quick.md, thread.md (frontmatter.get fences)
- workflows/complete-milestone.md (audit-open fence)
- workflows/profile-user.md (write-profile + generate-* fences)
- workflows/verify-work.md (audit-open --json fence)
- workflows/execute-phase.md (truefi -> true / fi)
- workflows/plan-phase.md, discuss-phase-assumptions.md,
  discuss-phase/modes/chain.md (true   fi -> true / fi)

All 5450 tests pass.
2026-04-24 18:10:45 -04:00
Tom Boucher
7ed05c8811 fix(#2645): emit [[agents]] array-of-tables in Codex config.toml (#2664)
* fix(#2645): emit [[agents]] array-of-tables in Codex config.toml

Codex ≥0.116 rejects `[agents.<name>]` map tables with `invalid type:
map, expected a sequence`. Switch generateCodexConfigBlock to emit
`[[agents]]` array-of-tables with an explicit `name` field per entry.

Strip + merge paths now self-heal on reinstall — both the legacy
`[agents.gsd-*]` map shape (pre-#2645 configs) and the new
`[[agents]]` with `name = "gsd-*"` shape are recognized and replaced,
while user-authored `[[agents]]` entries are preserved.

Fixes #2645

* fix(#2645): use TOML-aware parser to strip managed [[agents]] sections

CodeRabbit flagged that the prior regex-based stripper for [[agents]]
array-of-tables only matched headers at column 0 and stopped at any line
beginning with `[`. An indented [[agents]] header would not terminate the
preceding match, so a managed `gsd-*` block could absorb a following
user-authored agent and silently delete it.

Replace the ad-hoc regex with the existing TOML-aware section parser
(getTomlTableSections + removeContentRanges) so section boundaries are
authoritative regardless of indentation. Same logic applies to legacy
[agents.gsd-*] map sections.

Add a comprehensive mixed-shape test covering multiple GSD entries (both
legacy map and new array-of-tables, double- and single-quoted names)
interleaved with multiple user-authored agents in both shapes — verifies
all GSD entries are stripped and every user entry is preserved.
2026-04-24 18:09:01 -04:00
Tom Boucher
0f8f7537da fix(#2652): layer ~/.gsd/defaults.json over built-ins in SDK loadConfig (#2663)
* fix(#2652): layer ~/.gsd/defaults.json over built-ins in SDK loadConfig

SDK loadConfig only merged built-in CONFIG_DEFAULTS, so pre-project init
queries (e.g. resolveModel in Codex installs) ignored user-level knobs like
resolve_model_ids: "omit" and emitted Claude model aliases from MODEL_PROFILES.

Port the user-defaults layer from get-shit-done/bin/lib/config.cjs:65 to the
TS loader. CJS parity: user defaults only apply when no .planning/config.json
exists (buildNewProjectConfig already bakes them in at /gsd:new-project time).

Fixes #2652

* fix(#2652): isolate GSD_HOME in test, refresh loadConfig JSDoc (CodeRabbit)
2026-04-24 18:08:07 -04:00
Tom Boucher
709f0382bf fix(#2639): route Codex TOML emit through full Claude→Codex neutralization pipeline (#2657)
installCodexConfig() applied a narrow path-only regex pass before
generateCodexAgentToml(), skipping the convertClaudeToCodexMarkdown() +
neutralizeAgentReferences(..., 'AGENTS.md') pipeline used on the .md emit
path. Result: emitted Codex agent TOMLs carried stale Claude-specific
references (CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/,
.claudeignore, bare "Claude" agent-name mentions).

Route the TOML path through convertClaudeToCodexMarkdown and extend that
pipeline to cover bare .claude/<subdir>/ references and .claudeignore
(both previously unhandled on the .md path too). The $HOME/.claude/
get-shit-done prefix substitution still runs first so the absolute Codex
install path is preserved before the generic .claude → .codex rewrite.

Regression test: tests/issue-2639-codex-toml-neutralization.test.cjs —
drives installCodexConfig against a fixture containing every flagged
marker and asserts the emitted TOML contains zero CLAUDE.md / .claude/
/ .claudeignore occurrences and that Claude Code / Claude Opus product
names survive.

Fixes #2639
2026-04-24 18:06:13 -04:00
Tom Boucher
a6e692f789 fix(#2646): honor ROADMAP [x] checkboxes when no phases/ directory exists (#2669)
initProgress (and its CJS twin) hardcoded `not_started` for ROADMAP-only
phases, so `completed_count` stayed at 0 even when the ROADMAP showed
`- [x] Phase N`. Extract ROADMAP checkbox states into a shared helper
and use `- [x]` as the completion signal when no phase directory is
present. Disk status continues to win when both exist.

Adds a regression test that reproduces the bug with no phases/ dir and
one `[x]` / one `[ ]` phase, asserting completed_count===1.

Fixes #2646
2026-04-24 18:05:41 -04:00
Tom Boucher
b67ab38098 fix(#2643): align skill frontmatter name with workflow gsd: emission (#2672)
Flat-skills installs write SKILL.md files under gsd-<cmd>/ dirs, but
Claude Code resolves skills by their frontmatter `name:`, not directory
name. PR #2595 normalized every `/gsd-<cmd>` to `/gsd:<cmd>` across
workflows — including inside `Skill(skill="...")` args — but the
installer still emitted `name: gsd-<cmd>`, so every Skill() call on a
flat-skills install resolved to nothing.

Fix: emit `name: gsd:<cmd>` (colon form) in
`convertClaudeCommandToClaudeSkill`. Keep the hyphen-form directory
name for Windows path safety.

Codex stays on hyphen form: its adapter invokes skills as `$gsd-<cmd>`
(shell-var syntax) and a colon would terminate the variable name.
`convertClaudeCommandToCodexSkill` uses `yamlQuote(skillName)` directly
and is untouched.

- Extract `skillFrontmatterName(dirName)` helper (exported for tests).
- Update claude-skills-migration and qwen-skills-migration assertions
  that encoded the old hyphen emission.
- Add `tests/bug-2643-skill-frontmatter-name.test.cjs` asserting every
  `Skill(skill="gsd:<cmd>")` reference in workflows resolves to an
  emitted frontmatter name.

Full suite: 5452/5452 passing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:05:40 -04:00
Tom Boucher
06463860e4 fix(#2638): write sub_repos to canonical planning.sub_repos (#2668)
loadConfig's multiRepo migration and filesystem-sync writers targeted the
top-level parsed.sub_repos, but KNOWN_TOP_LEVEL (the unknown-key validator's
allowlist) only recognizes planning.sub_repos (canonical per #2561). Each
migration/sync therefore persisted a key the next loadConfig call warned was
unknown.

Redirect both writers to parsed.planning.sub_repos, ensuring parsed.planning
is initialized first. Also self-heal legacy/buggy installs by stripping any
stale top-level sub_repos on load, preserving its value as the
planning.sub_repos seed if that slot is empty.

Tests cover: (a) canonical planning.sub_repos emits no warning, (b) multiRepo
migration writes to planning.sub_repos with no top-level residue,
(c) filesystem sync relocates to planning.sub_repos, (d) stale top-level
sub_repos from older buggy installs is stripped on load.

Closes #2638
2026-04-24 18:05:33 -04:00
Tom Boucher
259c1d07d3 fix(#2647): guard tarball ships sdk/dist so gsd-sdk query works (#2671)
v1.38.3 shipped without sdk/dist/ because the outer `files` whitelist
and `prepublishOnly` chain had drifted. The `gsd-sdk` bin shim then
fell through to a stale @gsd-build/sdk@0.1.0 (pre-`query`), breaking
every workflow that called `gsd-sdk query <noun>` on fresh installs.

Current package.json already restores `sdk/dist` + `build:sdk`
prepublish; this PR locks the fix in with:

- tests/bug-2647-outer-tarball-sdk-dist.test.cjs — asserts `files`
  includes `sdk/dist`, `prepublishOnly` invokes `build:sdk`, the
  shim resolves sdk/dist/cli.js, `npm pack --dry-run` lists
  sdk/dist/cli.js, and the built CLI exposes a `query` subcommand.
- scripts/verify-tarball-sdk-dist.sh — packs, extracts, installs
  prod deps, and runs `node sdk/dist/cli.js query --help` against
  the real tarball output.
- .github/workflows/release.yml — runs the verify script in both
  next and stable release jobs before `npm publish`.

Partial fix for #2649 (same root cause on the sibling sdk package).

Fixes #2647
2026-04-24 18:05:18 -04:00
Tom Boucher
387c8a1f9c fix(#2653): eliminate SDK↔CJS config-schema drift (#2670)
The SDK's config-set kept its own hand-maintained allowlist (28-key
drift vs. get-shit-done/bin/lib/config-schema.cjs), so documented
keys accepted by the CJS config-set — planning.sub_repos,
workflow.code_review_command, workflow.security_*, review.models.*,
model_profile_overrides.*, etc. — were rejected with
"Unknown config key" when routed through the SDK.

Changes:
- New sdk/src/query/config-schema.ts mirrors the CJS schema exactly
  (exact-match keys + dynamic regex sources).
- config-mutation.ts imports VALID_CONFIG_KEYS / DYNAMIC_KEY_PATTERNS
  from the shared module instead of rolling its own set and regex
  branches.
- Drop hand-coded agent_skills.* / features.* regex branches —
  now schema-driven so claude_md_assembly.blocks.*, review.models.*,
  and model_profile_overrides.<runtime>.<tier> are also accepted.
- Add tests/config-schema-sdk-parity.test.cjs (node:test) as the
  CI drift guard: asserts CJS VALID_CONFIG_KEYS set-equals the
  literal set parsed from config-schema.ts, and that every CJS
  dynamic pattern source has an identical counterpart in the SDK.
  Parallel to the CJS↔docs parity added in #2479.
- Vitest #2653 specs iterate every CJS key through the SDK
  validator, spot-check each dynamic pattern, and lock in
  planning.sub_repos.
- While here: add workflow.context_coverage_gate to the CJS schema
  (already in docs and SDK; CJS previously rejected it) and sync
  the missing curated typo-suggestions (review.model, sub_repos,
  plan_checker, workflow.review_command) into the SDK.

Fixes #2653.
2026-04-24 18:05:16 -04:00
Tom Boucher
e973ff4cb6 fix(#2630): reset STATE.md frontmatter atomically on milestone switch (#2666)
The /gsd:new-milestone workflow Step 5 rewrote STATE.md's Current Position
body but never touched the YAML frontmatter, so every downstream reader
(state.json, getMilestoneInfo, progress bars) kept reporting the stale
milestone until the first phase advance forced a resync. Asymmetric with
milestone.complete, which uses readModifyWriteStateMdFull.

Add a new `state milestone-switch` handler (both SDK and CJS) that atomically:
- Stomps frontmatter milestone/milestone_name with caller-supplied values
- Resets status to 'planning' and progress counters to zero
- Rewrites the ## Current Position section to the new-milestone template
- Preserves Accumulated Context (decisions, blockers, todos)

Wire the workflow Step 5 to invoke `state.milestone-switch` instead of the
manual body rewrite. Note the flag is `--milestone` not `--version`:
gsd-tools reserves `--version` as a globally-invalid help flag.

Red vitest in sdk/src/query/state-mutation.test.ts asserts the frontmatter
reset. Regression guard via node:test in tests/bug-2630-*.test.cjs runs
through gsd-tools end-to-end.

Fixes #2630

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:05:10 -04:00
Tom Boucher
8caa7d4c3a fix(#2649): installer fail-fast when sdk/dist missing in npx cache (#2667)
Root cause shared with #2647: a broken 1.38.3 tarball shipped without
sdk/dist/. The pre-#2441-decouple installer reacted by running
spawnSync('npm.cmd', ['install'], { cwd: sdkDir }) inside the npx cache
on Windows, where the cache is read-only, producing the misleading
"Failed to npm install in sdk/" error.

Defensive changes here (user-facing behavior only; packaging fix lives
in the sibling PR for #2647):

- Classify the install context (classifySdkInstall): detect npx cache
  paths, node_modules-based installs, and dev clones via path heuristics
  plus a side-effect-free write probe. Exported for test.
- Rewrite the dist-missing error to branch on context:
    tarball + npxCache -> "don't touch npx cache; npm i -g ...@latest"
    tarball (other)    -> upgrade path + clone-build escape hatch
    dev-clone          -> keep existing cd sdk && npm install && npm run build
- Preserve the invariant that the installer never shells out to
  npm install itself — users always drive that.
- Add tests/bug-2649-sdk-fail-fast.test.cjs covering the classifier and
  both failure messages, with spawnSync/execSync interceptors that
  assert no nested npm install is attempted.

Cross-ref: #2647 (packaging).

Fixes #2649

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:05:04 -04:00
forfrossen
a72bebb379 fix(workflows): agent-skills query keys must match subagent_type (follow-up to #2555) (#2616)
* fix(workflows): agent-skills query keys must match subagent_type

Eight workflow files called `gsd-sdk query agent-skills <KEY>` with
a key that did not match any `subagent_type` Task() spawns in the
same workflow (or any existing `agents/<KEY>.md`):

- research-phase.md:45 — gsd-researcher    → gsd-phase-researcher
- plan-phase.md:36     — gsd-researcher    → gsd-phase-researcher
- plan-phase.md:38     — gsd-checker       → gsd-plan-checker
- quick.md:145         — gsd-checker       → gsd-plan-checker
- verify-work.md:36    — gsd-checker       → gsd-plan-checker
- new-milestone.md:207 — gsd-synthesizer   → gsd-research-synthesizer
- new-project.md:63    — gsd-synthesizer   → gsd-research-synthesizer
- ui-review.md:21      — gsd-ui-reviewer   → gsd-ui-auditor
- discuss-phase.md:114 — gsd-advisor       → gsd-advisor-researcher

Effect before this fix: users configuring `agent_skills.<correct-type>`
in .planning/config.json got no injection on these paths because the
workflow asked the SDK for a different (non-existent) key. The SDK
correctly returned "" for the unknown key, which then interpolated as
an empty string into the Task() prompt. Silent no-op.

The discuss-phase advisor case is a subtle variant — the spawn site
uses `subagent_type="general-purpose"` and loads the agent role via
`Read(~/.claude/agents/gsd-advisor-researcher.md)`. The injection key
must follow the agent identity (gsd-advisor-researcher), not the
technical spawn type.

This is a follow-up to #2555 — the SDK-side fix in that PR (#2587)
only becomes fully effective once the call sites use the right keys.

Adds `sdk/src/workflow-agent-skills-consistency.test.ts` as a
contract test: every `agent-skills <slug>` invocation in
`get-shit-done/workflows/**/*.md` must reference an existing
`agents/<slug>.md`. Fails loudly on future key typos.

Closes #2615

* test: harden workflow agent-skills regex per review feedback

Review (#2616): CodeRabbit flagged the `agent-skills <slug>` pattern
as too permissive (can match prose mentions of the string) and the
per-line scan as brittle (misses commands wrapped across lines).

- Require full `gsd-sdk query agent-skills` prefix before capture
  + `\b` around the pattern so prose references no longer match.
- Scan each file's full content (not line-by-line) so `\s+` can span
  newlines; resolve 1-based line number from match index.
- Add JSDoc on helpers and on QUERY_KEY_PATTERN.

Verified: RED against base (`f30da83`) produces the same 9 violations
as before; GREEN on fixed tree.

---------

Co-authored-by: forfrossen <forfrossensvart@gmail.com>
2026-04-23 12:40:56 -04:00
Tom Boucher
31569c8cc8 ci: explicit rebase check + fail-fast SDK typecheck in install-smoke (#2631)
* ci: explicit rebase check + fail-fast SDK typecheck in install-smoke

Stale-base regression guard. Root cause: GitHub's `refs/pull/N/merge`
is cached against the PR's recorded merge-base, not current main. When
main advances after a PR is opened, the cache stays stale and CI runs
against the pre-advance tree. PRs hit this whenever a type error lands
on main and gets patched shortly after (e.g. #2611 + #2622) — stale
branches replay the broken intermediate state and report confusing
downstream failures for hours.

Observed failure mode: install-smoke's "Assert gsd-sdk resolves on PATH"
step fires with "installSdkIfNeeded() regression" even when the real
cause is `npm run build` failing in sdk/ due to a TypeScript cast
mismatch already fixed on main.

Fix:
- Explicit `git merge origin/main` step in both `install-smoke.yml` and
  `test.yml`. If the merge conflicts, emit a clear "rebase onto main"
  diagnostic and fail early, rather than let conflicts produce unrelated
  downstream errors.
- Dedicated `npm run build:sdk` typecheck step in install-smoke with a
  remediation hint ("rebase onto main — the error may already be fixed
  on trunk"). Fails fast with the actual tsc output instead of masking
  it behind a PATH assertion.
- Drop the `|| true` on `get-shit-done-cc --claude --local` so installer
  failures surface at the install step with install.js's own error
  message, not at the downstream PATH assertion where the message
  misleadingly blames "shim regression".
- `fetch-depth: 0` on checkout so the merge-base check has history.

* ci: address CodeRabbit — add rebase check to smoke-unpacked, fix fetch flag

Two findings from CodeRabbit's review on #2631:

1. `smoke-unpacked` job was missing the same rebase check applied to the
   `smoke` job. It ran on the cached `refs/pull/N/merge` and could hit
   the same stale-base failure mode the PR was designed to prevent. Added
   the identical rebase-check step.

2. `git fetch origin main --depth=0` is an invalid flag — git rejects it
   with "depth 0 is not a positive number". The intent was "fetch with
   full depth", but the right way is just `git fetch origin main` (no
   --depth). Removed the invalid flag and the `||` fallback that was
   papering over the error.
2026-04-23 12:40:16 -04:00
Tom Boucher
eba0c99698 fix(#2623): resolve parent .planning root for sub_repos workspaces in SDK query dispatch (#2629)
* fix(#2623): resolve parent .planning root for sub_repos workspaces in SDK query dispatch

When `gsd-sdk query` is invoked from inside a `sub_repos`-listed child repo,
`projectDir` defaulted to `process.cwd()` which pointed at the child repo,
not the parent workspace that owns `.planning/`. Handlers then directly
checked `${projectDir}/.planning` and reported `project_exists: false`.

The legacy `gsd-tools.cjs` CLI does not have this gap — it calls
`findProjectRoot(cwd)` from `bin/lib/core.cjs`, which walks up from the
starting directory checking each ancestor's `.planning/config.json` for a
`sub_repos` entry that lists the starting directory's top-level segment.

This change ports that walk-up as a new `findProjectRoot` helper in
`sdk/src/query/helpers.ts` and applies it once in `cli.ts:main()` before
dispatching `query`, `run`, `init`, or `auto`. Resolution is idempotent:
if `projectDir` already owns `.planning/` (including an explicit
`--project-dir` pointing at the workspace root), the helper returns it
unchanged. The walk is capped at 10 parent levels and never crosses
`$HOME`. All filesystem errors are swallowed.

Regression coverage:
- `helpers.test.ts` — 8 unit tests covering own-`.planning` guard (#1362),
  sub_repos match, nested-path match, `planning.sub_repos` shape,
  heuristic fallback, unparseable config, legacy `multiRepo: true`.
- `sub-repos-root.integration.test.ts` — end-to-end baseline (reproduces
  the bug without the walk-up) and fixed behavior (walk-up + dispatch of
  `init.new-milestone` reports `project_exists: true` with the parent
  workspace as `project_root`).

sdk vitest: 1511 pass / 24 fail (all 24 failures pre-existing on main,
baseline is 26 failing — `comm -23` against baseline produces zero new
failures). CJS: 5410 pass / 0 fail.

Closes #2623

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2623): remove stray .planing typo from integration test setup

Address CodeRabbit nitpick: the mkdir('.planing') call on line 23 was
dead code from a typo, with errors silently swallowed via .catch(() => {}).
The test already creates '.planning' correctly on the next line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:58:23 -04:00
Tom Boucher
5a8a6fb511 fix(#2256): pass per-agent model overrides through Codex/OpenCode transport (#2628)
The Codex and OpenCode install paths read `model_overrides` only from
`~/.gsd/defaults.json` (global). A per-project override set in
`.planning/config.json` — the reporter's exact setup for
`gsd-codebase-mapper` — was silently dropped, so the child agent inherited
the runtime's default model regardless of `model_overrides`.

Neither runtime has an inline `model` parameter on its spawn API
(Codex `spawn_agent(agent_type, message)`, OpenCode `task(description,
prompt, subagent_type, task_id, command)`), so the per-agent model must
reach the child via the static config GSD writes at install time. That
config was being populated from the wrong source.

Fix: add `readGsdEffectiveModelOverrides(targetDir)` which merges
`~/.gsd/defaults.json` with per-project `.planning/config.json`, with
per-project keys winning on conflict. Both install sites now call it and
walk up from the install root to locate `.planning/` — matching the
precedence `readGsdRuntimeProfileResolver` already uses for #2517.

Also update the Codex Task()->spawn_agent mapping block so it no longer
says "omit" without context: it now documents that per-agent overrides
are embedded in the agent TOML and notes the restriction that Codex
only permits `spawn_agent` when the user explicitly requested sub-agents
(do the work inline otherwise).

Regression tests (`tests/bug-2256-model-overrides-transport.test.cjs`)
cover: global-only, project-only, project-wins-on-conflict, walking up
from a nested `targetDir`, Codex TOML `model =` emission, and OpenCode
frontmatter `model:` emission.

Closes #2256

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:58:06 -04:00
Tom Boucher
bdba40cc3d fix(#2618): thread --ws through query dispatch and sync root STATE.md on workstream.set (#2627)
* fix(#2618): thread --ws through query dispatch for state and init handlers

Gap 1 of #2618: the query dispatcher already accepts a workstream via
registry.dispatch(cmd, args, projectDir, ws), but several handlers drop it
before reaching planningPaths() / getMilestoneInfo() / findPhase() — so
stateJson and the init.* handlers return root-scoped results even when --ws
is provided.

Changes:

- sdk/src/query/state.ts: forward workstream into getMilestoneInfo() and
  extractCurrentMilestone() so buildStateFrontmatter resolves milestone data
  from the workstream ROADMAP/STATE instead of the root mirror.
- sdk/src/query/init.ts: thread workstream through initExecutePhase,
  initPlanPhase, initPhaseOp, and getPhaseInfoWithFallback (which fans out
  to findPhase() and roadmapGetPhase()). Also switch hardcoded
  join(projectDir, '.planning') to relPlanningPath(workstream) so returned
  state_path/roadmap_path/config_path reflect the workstream layout.

Regression test: stateJson with --ws workstream reads STATE.md from
.planning/workstreams/<name>/ when workstream is provided.

Closes #2618 (gap 1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2618): sync root .planning/STATE.md mirror on workstream.set

Gap 2 of #2618: setActiveWorkstream only flips the active-workstream
pointer file; the root .planning/STATE.md mirror stays stale. Downstream
consumers (statusline, gsd-sdk query progress, any tool that reads the
root STATE.md) continue to see the previous workstream's state.

After setActiveWorkstream(), copy .planning/workstreams/<name>/STATE.md
verbatim to .planning/STATE.md via writeFileSync. The workstream STATE.md
is authoritative; the root file is a pass-through mirror. Missing source
STATE.md is a no-op rather than an error — a freshly created workstream
with no STATE.md yet should still activate cleanly.

The response now includes `mirror_synced: boolean` so callers can
observe whether the root mirror was updated.

Regression test: workstreamSet root STATE.md mirror sync — switches
from a stale root mirror to a workstream STATE.md with different
frontmatter and asserts the root file now matches.

Closes #2618 (gap 2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:54:34 -04:00
Tom Boucher
df0ab0c0c9 fix(#2410): emit wave + plan checkpoint heartbeats to prevent stream idle timeout (#2626)
/gsd:manager's background execute-phase Task fails with
"Stream idle timeout - partial response received" on multi-plan phases
(Claude Code + Opus 4.7 at ~200K+ cache_read) because the long subagent
never emits tokens fast enough between large tool_results — the SSE layer
times out mid-assistant-turn and the harness retries hit the same TTFT
wall after prompt cache TTL expires.

Root cause: no orchestrator-level activity at wave/plan boundaries.

Fix (maintainer-approved A+B):
- A (wave boundary): execute-phase.md now emits a `[checkpoint]`
  heartbeat before each wave spawns and after each wave completes.
- B (plan boundary): also emit `[checkpoint]` before each Task()
  dispatch and after each executor returns (complete/failed/checkpoint).
  Heartbeats are literal assistant-text lines (no tool call) with a
  monotonic `{P}/{Q} plans done` counter so partial-transcript recovery
  tools can grep progress even when a run dies mid-phase.

Docs: COMMANDS.md /gsd-manager section documents the marker format.
Tests: tests/bug-2410-stream-checkpoint-heartbeats.test.cjs (12 cases)
asserts the heartbeats exist at every boundary and in the right workflow
step. Full suite: 5422 node:test cases pass. Pre-existing vitest
failures on main are unrelated to this change.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:54:11 -04:00
Tom Boucher
807db75d55 fix(#2620): detect HOME-relative PATH entries before suggesting absolute export (#2625)
* fix(#2620): detect HOME-relative PATH entries before suggesting absolute export

When the installer reported `gsd-sdk` not on PATH and suggested
appending an absolute `export PATH="/home/user/.npm-global/bin:$PATH"`
line to the user's rc file, a user who had the equivalent
`export PATH="$HOME/.npm-global/bin:$PATH"` already in their shell
profile would get a duplicate entry — the installer only compared the
absolute form.

Add `homePathCoveredByRc(globalBin, homeDir, rcFileNames?)` to
`bin/install.js` and export it for test-mode callers. The helper scans
`~/.zshrc`, `~/.bashrc`, `~/.bash_profile`, `~/.profile`, grepping each
file for `export PATH=` / bare `PATH=` lines and substituting the
common HOME forms (\$HOME, \${HOME}, leading ~/) with the real home
directory before comparing each resolved PATH segment against
globalBin. Trailing slashes are normalised so `.npm-global/bin/`
matches `.npm-global/bin`. Missing / unreadable / malformed rc files
are swallowed — the caller falls back to the existing absolute
suggestion.

Tests cover $HOME, \${HOME}, and ~/ forms, absolute match,
trailing-slash match, commented-out lines, missing rc files, and
unreadable rc files (directory where a file is expected).

Closes #2620

* fix(#2620): skip relative PATH segments in homePathCoveredByRc

CodeRabbit flagged that the helper unconditionally resolved every
non-$-containing segment against homeAbs via path.resolve(homeAbs, …),
which silently turns a bare relative segment like `bin` or
`node_modules/.bin` into `$HOME/bin` / `$HOME/node_modules/.bin`. That
is wrong: bare PATH segments depend on the shell's cwd at lookup time,
not on $HOME — so the helper was returning true for rc files that do
not actually cover globalBin.

Guard the compare with path.isAbsolute(expanded) after HOME expansion.
Only segments that are absolute on their own (or that became absolute
via $HOME / \${HOME} / ~ substitution) are compared against targetAbs.
Relative segments are skipped.

Add two regression tests covering a bare `bin` segment and a nested
`node_modules/.bin` segment; both previously returned true when home
happened to contain a matching subdirectory and now correctly return
false.

Closes #2620 (CodeRabbit follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2620): wire homePathCoveredByRc into installer suggestion path

CodeRabbit flagged that homePathCoveredByRc was added in the previous
commit but never called from the installer, so the user-facing PATH
warning stayed unchanged — users with `export PATH="$HOME/.npm-global/bin:$PATH"`
in their rc would still get a duplicate absolute-path suggestion.

Add `maybeSuggestPathExport(globalBin, homeDir)` that:
- skips silently when globalBin is already on process.env.PATH;
- prints a "try reopening your shell" diagnostic when homePathCoveredByRc
  returns true (the directory IS on PATH via an rc entry — just not in
  the current shell);
- otherwise falls through to the absolute-path
  `echo 'export PATH="…:$PATH"' >> ~/.zshrc` suggestion.

Call it from installSdkIfNeeded after the sdk/dist check succeeds,
resolving globalBin via `npm prefix -g` (plus `/bin` on POSIX). Swallow
any exec failure so the installer keeps working when npm is weird.

Export maybeSuggestPathExport for tests. Add three new regression tests
(installer-flow coverage per CodeRabbit nitpick):
- rc covers globalBin via $HOME form → no absolute suggestion emitted
- rc covers only an unrelated directory → absolute suggestion emitted
- globalBin already on process.env.PATH → no output at all

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:53:51 -04:00
Tom Boucher
74da61fb4a fix(#2619): prevent extractCurrentMilestone from truncating on phase-vX.Y headings (#2624)
* fix(#2619): prevent extractCurrentMilestone from truncating on phase-vX.Y headings

extractCurrentMilestone sliced ROADMAP.md to the current milestone by
looking for the next milestone heading with a greedy regex:

    ^#{1,N}\s+(?:.*v\d+\.\d+||📋|🚧)

Any heading that mentioned a version literal matched — including phase
headings like "### Phase 12: v1.0 Tech-Debt Closure". When the current
milestone was at the same heading level as the phases (### 🚧 v1.1 …),
the slice terminated at the first such phase, hiding every phase that
followed from phase.insert, validate.health W007, and other SDK commands.

Fix: add a `(?!Phase\s+\S)` negative lookahead so phase headings can
never be treated as milestone boundaries. Phase headings always start
with the literal `Phase `, so this is a clean exclusion.

Applied to:
- get-shit-done/bin/lib/core.cjs (extractCurrentMilestone)
- sdk/src/query/roadmap.ts (extractCurrentMilestone + extractNextMilestoneSection)

Regression tests:
- tests/roadmap-phase-fallback.test.cjs: extractCurrentMilestone does not
  truncate on phase heading containing vX.Y (#2619)
- sdk/src/query/roadmap.test.ts: extractCurrentMilestone bug-2619: does
  not truncate at a phase heading containing vX.Y

Closes #2619

* fix(#2619): make milestone-boundary Phase lookahead case-insensitive

CodeRabbit follow-up on #2619: the negative lookahead `(?!Phase\s+\S)`
in the SDK milestone-boundary regex was case-sensitive, so headings like
`### PHASE 12: v1.0 Tech-Debt` or `### phase 12: …` still truncated the
milestone slice. Add the `i` flag (now `gmi`).

The sibling CJS regex in get-shit-done/bin/lib/core.cjs already uses the
`mi` flag, so it is already case-insensitive; added a regression test to
lock that in.

- sdk/src/query/roadmap.ts: change flags from `gm` → `gmi`
- sdk/src/query/roadmap.test.ts: add PHASE/phase regression test
- tests/roadmap-phase-fallback.test.cjs: add PHASE/phase regression test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:53:20 -04:00
Jeremy McSpadden
0a049149e1 fix(sdk): decouple from build-from-source install, close #2441 #2453 (#2457)
* fix(sdk): decouple SDK from build-from-source install path, close #2441 and #2453

Ship sdk/dist prebuilt in the tarball and replace the npm-install-g
sub-install with a parent-package bin shim (bin/gsd-sdk.js). npm chmods
bin entries from a packed tarball correctly, eliminating the mode-644
failure (#2453) and the full class of NPM_CONFIG_PREFIX/ignore-scripts/
corepack/air-gapped failure modes that caused #2439 and #2441.

Changes:
- sdk/package.json: prepublishOnly runs `rm -rf dist && tsc && chmod +x
  dist/cli.js` (stale-build guard + execute-bit fix at publish time)
- package.json: add "gsd-sdk": "bin/gsd-sdk.js" bin entry; add sdk/dist
  to files so the prebuilt CLI ships in the tarball
- bin/gsd-sdk.js: new back-compat shim — resolves sdk/dist/cli.js relative
  to the package root and delegates via `node`, so all existing PATH call
  sites (slash commands, agents, hooks) continue to work unchanged (S1 shim)
- bin/install.js: replace installSdkIfNeeded() build-from-source + global-
  install dance with a dist-verify + chmod-in-place guard; delete
  resolveGsdSdk(), detectShellRc(), emitSdkFatal() helpers now unused
- .github/workflows/install-smoke.yml: add smoke-unpacked job that strips
  execute bit from sdk/dist/cli.js before install to reproduce the exact
  #2453 failure mode
- tests/bug-2441-sdk-decouple.test.cjs: new regression tests asserting all
  invariants (no npm install -g from sdk/, shim exists, sdk/dist in files,
  prepublishOnly has rm -rf + chmod)
- tests/bugs-1656-1657.test.cjs: update stale assertions that required
  build-from-source behavior (now asserts new prebuilt-dist invariants)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(release): bump to 1.38.2, wire release.yml to build SDK dist

- Bump version 1.38.1 -> 1.38.2 for the #2441/#2453 fix shipped in 0f6903d.
- Add `build:sdk` script (`cd sdk && npm ci && npm run build`).
- `prepublishOnly` now runs hooks + SDK builds as a safety net.
- release.yml (rc + finalize): build SDK dist before `npm publish` so the
  published tarball always ships fresh `sdk/dist/` (kept gitignored).
- CHANGELOG: document 1.38.2 entry and `--sdk` flag semantics change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: build SDK dist before tests and smoke jobs

sdk/dist/ is gitignored (built fresh at publish time via release.yml),
but both the test suite and install-smoke jobs run `bin/install.js`
or `npm pack` against the checked-out tree where dist doesn't exist yet.

- test.yml: `npm run build:sdk` before `npm run test:coverage`, so tests
  that spawn `bin/install.js` don't hit `installSdkIfNeeded()`'s fatal
  missing-dist check.
- install-smoke.yml (both smoke and smoke-unpacked): build SDK before
  pack/chmod so the published tarball contains dist and the unpacked
  install has a file to strip exec-bit from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sdk): lift SDK runtime deps to parent so tarball install can resolve them

The SDK's runtime deps (ws, @anthropic-ai/claude-agent-sdk) live in
sdk/package.json, but sdk/node_modules is NOT shipped in the parent
tarball — only sdk/dist, sdk/src, sdk/prompts, and sdk/package.json are.
When a user runs `npm install -g get-shit-done-cc`, npm installs the
parent's node_modules but never runs `npm install` inside the nested
sdk/ directory.

Result: `node sdk/dist/cli.js` fails with ERR_MODULE_NOT_FOUND for 'ws'.
The smoke tarball job caught this; the unpacked variant masked it
because `npm install -g <dir>` copies the entire workspace including
sdk/node_modules (left over from `npm run build:sdk`).

Fix: declare the same deps in the parent package.json so they land in
<pkg>/node_modules, which Node's resolution walks up to from
<pkg>/sdk/dist/cli.js. Keep them declared in sdk/package.json too so
the SDK remains a self-contained package for standalone dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(lockfile): regenerate package-lock.json cleanly

The previous `npm install` run left the lockfile internally inconsistent
(resolved esbuild@0.27.7 referenced but not fully written), causing
`npm ci` to fail in CI with "Missing from lock file" errors.

Clean regen via rm + npm install fixes all three failed jobs
(test, smoke, smoke-unpacked), which were all hitting the same
`npm ci` sync check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(deps): remove unused esbuild + vitest from root devDependencies

Both were declared but never imported anywhere in the root package
(confirmed via grep of bin/, scripts/, tests/). They lived in sdk/
already, which is the only place they're actually used.

The transitive tree they pulled in (vitest → vite → esbuild 0.28 →
@esbuild/openharmony-arm64) was the root of the CI npm ci failures:
the openharmony platform package's `optional: true` flag was not being
applied correctly by npm 10 on Linux runners, causing EBADPLATFORM.

After removal: 800+ transitive packages → 155. Lockfile regenerated
cleanly. All 4170 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sdk): pretest:coverage builds sdk; tighten shim test assertions

Add "pretest:coverage": "npm run build:sdk" so npm run test:coverage
works in clean checkouts where sdk/dist/ hasn't been built yet.

Tighten the two loose shim assertions in bug-2441-sdk-decouple.test.cjs:
- forwards-to test now asserts path.resolve() is called with the
  'sdk','dist','cli.js' path segments, not just substring presence
- node-invocation test now asserts spawnSync(process.execPath, [...])
  pattern, ruling out matches in comments or the shebang line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address PR review — pretest:coverage + tighten shim tests

Review feedback from trek-e on PR 2457:

1. pretest:coverage + pretest hooks now run `npm run build:sdk` so
   `npm run test[:coverage]` in a clean checkout produces the required
   sdk/dist/ artifacts before running the installer-dependent tests.
   CI already does this explicitly; local contributors benefit.

2. Shim tests in bug-2441-sdk-decouple.test.cjs tightened from loose
   substring matches (which would pass on comments/shebangs alone) to
   regex assertions on the actual path.resolve call, spawnSync with
   process.execPath, process.argv.slice(2), and process.exit pattern.
   These now provide real regression protection for #2453-class bugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: correct CHANGELOG entry and add [1.38.2] reference link

Two issues in the 1.38.2 CHANGELOG entry:
- installSdkIfNeeded() was described as deleted but it still exists in
  bin/install.js (repurposed to verify sdk/dist/cli.js and fix execute bit).
  Corrected the description to say 'repurposes' rather than 'deletes'.
- The reference-link block at the bottom of the file was missing a [1.38.2]
  compare URL and [Unreleased] still pointed to v1.37.1...HEAD. Added the
  [1.38.2] link and updated [Unreleased] to compare/v1.38.2...HEAD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): double-cast WorkflowConfig to Record for strict tsc build

TypeScript error on main (introduced in #2611) blocks `npm run build`
in sdk/, which now runs as part of this PR's tarball build path. Apply
the double-cast via `unknown` as the compiler suggests.

Same fix as #2622; can be dropped if that lands first.

* test: remove bug-2598 test obsoleted by SDK decoupling

The bug-2598 test guards the Windows CVE-2024-27980 fix in the old
build-from-source path (npm spawnSync with shell:true + formatSpawnFailure
diagnostics). This PR removes that entire code path — installSdkIfNeeded
no longer spawns npm, it just verifies the prebuilt sdk/dist/cli.js
shipped in the tarball.

The test asserts `installSdkIfNeeded.toString()` contains a
formatSpawnFailure helper. After decoupling, no such helper exists
(nothing to format — there's no spawn). Keeping the test would assert
invariants of the rejected architecture.

The original #2598 defect (silent failure of npm spawn on Windows) is
structurally impossible in the shim path: bin/gsd-sdk.js invokes
`node sdk/dist/cli.js` directly via child_process.spawn with an
explicit argv array. No .cmd wrapper, no shell delegation.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-04-23 08:36:03 -04:00
Tom Boucher
a56707a07b fix(#2613): preserve STATE.md frontmatter on write path (option 2) (#2622)
* fix(#2613): preserve STATE.md frontmatter on write path (option 2)

`readModifyWriteStateMd` strips frontmatter before invoking the modifier,
so `syncStateFrontmatter` received body-only content and `existingFm`
was always `{}`. The preservation branch never fired, and every mutation
re-derived `status` (to `'unknown'` when body had no `Status:` line) and
`progress.*` (to 0/0 when the shipped milestone's phase directories were
archived), silently overwriting authoritative frontmatter values.

Option 2 — write-side analogue of #2495 READ fix: `buildStateFrontmatter`
reads the current STATE.md frontmatter from disk as a preservation
backstop. Status preserved when derived is `'unknown'` and existing is
non-unknown. Progress preserved when disk scan returns all zeros AND
existing has non-zero counts. Legitimate body-driven status changes and
non-zero disk counts still win.

Milestone/milestone_name already preserved via `getMilestoneInfo`'s
#2495 fix — regression test added to lock that in.

Adds 5 regression tests covering status preservation, progress
preservation, milestone preservation, legitimate status updates, and
disk-scan-wins-when-non-zero.

Closes #2613

* fix(sdk): double-cast WorkflowConfig to Record in loadGateConfig

TypeScript error on main (introduced in #2611) blocks the install-smoke
CI job: `WorkflowConfig` has no string index signature, so the direct
cast to `Record<string, unknown>` fails type-check. The SDK build fails,
`installSdkIfNeeded()` cannot install `gsd-sdk` from source, and the
smoke job reports a false-positive installer regression.

  src/query/check-decision-coverage.ts(236,16): error TS2352:
  Conversion of type 'WorkflowConfig' to type 'Record<string, unknown>'
  may be a mistake because neither type sufficiently overlaps with the
  other.

Apply the double-cast via `unknown` as the compiler suggests. Behavior
is unchanged — this was already a cast.
2026-04-23 08:22:42 -04:00
Tom Boucher
f30da8326a feat: add gates ensuring discuss-phase decisions are translated to plans and verified (closes #2492) (#2611)
* feat(#2492): add gates ensuring discuss-phase decisions are translated and verified

Two gates close the loop between CONTEXT.md `<decisions>` and downstream
work, fixing #2492:

- Plan-phase **translation gate** (BLOCKING). After requirements
  coverage, refuses to mark a phase planned when a trackable decision
  is not cited (by id `D-NN` or by 6+-word phrase) in any plan's
  `must_haves`, `truths`, or body. Failure message names each missed
  decision with id, category, text, and remediation paths.

- Verify-phase **validation gate** (NON-BLOCKING). Searches plans,
  SUMMARY.md, files modified, and recent commit subjects for each
  trackable decision. Misses are written to VERIFICATION.md as a
  warning section but do not change verification status. Asymmetry is
  deliberate — fuzzy-match miss should not fail an otherwise green
  phase.

Shared helper `parseDecisions()` lives in `sdk/src/query/decisions.ts`
so #2493 can consume the same parser.

Decisions opt out of both gates via `### Claude's Discretion` heading
or `[informational]` / `[folded]` / `[deferred]` tags.

Both gates skip silently when `workflow.context_coverage_gate=false`
(default `true`).

Closes #2492

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2492): make plan-phase decision gate actually block (review F1, F8, F9, F10, F15)

- F1: replace `${context_path}` with `${CONTEXT_PATH}` in the plan-phase
  gate snippet so the BLOCKING gate receives a non-empty path. The
  variable was defined in Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" ...)`)
  and the gate snippet referenced the lowercase form, leaving the gate to
  run with an empty path argument and silently skip.
- F15: wrap the SDK call with `jq -e '.data.passed == true' || exit 1` so
  failure halts the workflow instead of being printed and ignored. The
  verify-phase counterpart deliberately keeps no exit-1 (non-blocking by
  design) and now carries an inline note documenting the asymmetry.
- F10: tag the JSON example fence as `json` and the options-list fence as
  `text` (MD040).
- F8/F9: anchor the heading-presence test regexes to `^## 13[a-z]?\\.` so
  prose substrings like "Requirements Coverage Gate" mentioned in body
  text cannot satisfy the assertion. Added two new regression tests
  (variable-name match, exit-1 guard) so a future revert is caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2492): tighten decision-coverage gates against false positives and config drift (review F3,F4,F5,F6,F7,F16,F18,F19)

- F3: forward `workstream` arg through both gate handlers so workstream-scoped
  `workflow.context_coverage_gate=false` actually skips. Added negative test
  that creates a workstream config disabling the gate while the root config
  has it enabled and asserts the workstream call is skipped.
- F4: restrict the plan-phase haystack to designated sections — front-matter
  `must_haves` / `truths` / `objective` plus body sections under headings
  matching `must_haves|truths|tasks|objective`. HTML comments and fenced
  code blocks are stripped before extraction so a commented-out citation or
  a literal example never counts as coverage. Verify-phase keeps the broader
  artifact-wide haystack by design (non-blocking).
- F5: reject decisions with fewer than 6 normalized words from soft-matching
  (previously only rejected when the resulting phrase was under 12 chars
  AFTER slicing — too lenient). Short decisions now require an explicit
  `D-NN` citation, with regression tests for the boundary.
- F6: walk every `*-SUMMARY.md` independently and use `matchAll` with the
  `/g` flag so multiple `files_modified:` blocks across multiple summaries
  are all aggregated. Previously only the first block in the concatenated
  string was parsed, silently dropping later plans' files.
- F7: validate every `files_modified` path stays inside `projectDir` after
  resolution (rejects absolute paths, `../` traversal). Cap each file read
  at 256 KB. Skipped paths emit a stderr warning naming the entry.
- F16: validate `workflow.context_coverage_gate` is boolean in
  `loadGateConfig`; warn loudly on numeric or other-shaped values and
  default to ON. Mirrors the schema-vs-loadConfig validation gap from
  #2609.
- F18: bump verify-phase `git log -n` cap from 50 to 200 so longer-running
  phases are not undercounted. Documented as a precision-vs-recall tradeoff
  appropriate for a non-blocking gate.
- F19: tighten `QueryResult` / `QueryHandler` to be parameterized
  (`<T = unknown>`). Drops the `as unknown as Record<string, unknown>`
  casts in the gate handlers and surfaces shape mismatches at compile time
  for callers that pass a typed `data` value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2492): harden decisions parser and verify-phase glob (review F11,F12,F13,F14,F17,F20)

- F11: strip fenced code blocks from CONTEXT.md before searching for
  `<decisions>` so an example block inside ``` ``` is not mis-parsed.
- F12: accept tab-indented continuation lines (previously required a leading
  space) so decisions split with `\t` continue cleanly.
- F13: parse EVERY `<decisions>` block in the file via `matchAll`, not just
  the first. CONTEXT.md may legitimately carry more than one block.
- F14: `decisions.parse` handler now resolves a relative path against
  `projectDir` — symmetric with the gate handlers — and still accepts
  absolute paths.
- F17: replace `ls "${PHASE_DIR}"/*-CONTEXT.md | head -1` in verify-phase.md
  with a glob loop (ShellCheck SC2012 fix). Also avoids spawning an extra
  subprocess and survives filenames with whitespace.
- F20: extend the unicode quote-stripping in the discretion-heading match
  to cover U+2018/2019/201A/201B and the U+201C-F double-quote variants
  plus backtick, so any rendering of "Claude's Discretion" collapses to
  the same key.

Each fix has a regression test in `decisions.test.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:26:53 -04:00
Tom Boucher
1a3d953767 feat: add unified post-planning gap checker (closes #2493) (#2610)
* feat: add unified post-planning gap checker (closes #2493)

Adds a unified post-planning gap checker as Step 13e of plan-phase.md.
After all plans are generated and committed, scans REQUIREMENTS.md and
CONTEXT.md <decisions> against every PLAN.md in the phase directory and
emits a single Source | Item | Status table.

Why
- The existing Requirements Coverage Gate (§13) blocks/re-plans on REQ
  gaps but emits two separate per-source signals. Issue #2493 asks for
  one unified report after planning so that requirements AND
  discuss-phase decisions slipping through are surfaced in one place
  before execution starts.

What
- New workflow.post_planning_gaps boolean config key, default true,
  added to VALID_CONFIG_KEYS, CONFIG_DEFAULTS, hardcoded.workflow, and
  cmdConfigSet (boolean validation).
- New get-shit-done/bin/lib/decisions.cjs — shared parser for
  CONTEXT.md <decisions> blocks (D-NN entries). Designed for reuse by
  the related #2492 plan/verify decision gates.
- New get-shit-done/bin/lib/gap-checker.cjs — parses REQUIREMENTS.md
  (checkbox + traceability table forms), reads CONTEXT.md decisions,
  walks PHASE_DIR/*-PLAN.md, runs word-boundary coverage detection
  (REQ-1 must not match REQ-10), formats a sorted report.
- New gsd-tools gap-analysis CLI command wired through gsd-tools.cjs.
- workflows/plan-phase.md gains §13e between §13d (commit plans) and
  §14 (Present Final Status). Existing §13 gate preserved — §13e is
  additive and non-blocking.
- sdk/prompts/workflows/plan-phase.md gets an equivalent
  post_planning_gaps step for headless mode.
- Docs: CONFIGURATION.md, references/planning-config.md, INVENTORY.md,
  INVENTORY-MANIFEST.json all updated.

Tests
- tests/post-planning-gaps-2493.test.cjs: 30 test cases covering step
  insertion position, decisions parser, gap detector behavior
  (covered/not-covered, false-positive guard, missing-file
  resilience, malformed-input resilience, gate on/off, deterministic
  natural sort), and full config integration.
- Full suite: 5234 / 5234 pass.

Design decisions
- Numbered §13e (sub-step), not §14 — §14 already exists (Present
  Final Status); inserting before it preserves downstream auto-advance
  step numbers.
- Existing §13 gate kept, not replaced — §13 blocks/re-plans on
  REQ gaps; §13e is the unified post-hoc report. Per spec: "default
  behavior MUST be backward compatible."
- Word-boundary ID matching avoids REQ-1 matching REQ-10 and avoids
  brittle semantic/substring matching.
- Shared decisions.cjs parser so #2492 can reuse the same regex.
- Natural-sort keys (REQ-02 before REQ-10) for deterministic output.
- Boolean validation in cmdConfigSet rejects non-boolean values
  matches the precedent set by drift_threshold/drift_action.

Closes #2493

* fix(#2493): expose post_planning_gaps in loadConfig() + sync schema example

Address CodeRabbit review on PR #2610:

- core.cjs loadConfig(): return post_planning_gaps from both the
  config.json branch and the global ~/.gsd/defaults.json fallback so
  callers can rely on config.post_planning_gaps regardless of whether
  the key is present (comment 3127977404, Major).
- docs/CONFIGURATION.md: add workflow.post_planning_gaps to the Full
  Schema JSON example so copy/paste users see the new toggle alongside
  security_block_on (comment 3127977392, Minor).
- tests/post-planning-gaps-2493.test.cjs: regression coverage for
  loadConfig() — default true when key absent, honors explicit
  true/false from workflow.post_planning_gaps.
2026-04-22 23:03:59 -04:00
Tom Boucher
cc17886c51 feat: make model profiles runtime-aware for Codex/non-Claude runtimes (closes #2517) (#2609)
* feat: make model profiles runtime-aware for Codex/non-Claude runtimes (closes #2517)

Adds an optional top-level `runtime` config key plus a
`model_profile_overrides[runtime][tier]` map. When `runtime` is set,
profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs
(and reasoning_effort where supported) instead of bare Claude aliases.

Codex defaults from the spec:
  opus   -> gpt-5.4        reasoning_effort: xhigh
  sonnet -> gpt-5.3-codex  reasoning_effort: medium
  haiku  -> gpt-5.4-mini   reasoning_effort: medium

Claude defaults mirror MODEL_ALIAS_MAP. Unknown runtimes fall back to
the Claude-alias safe default rather than emit IDs the runtime cannot
accept. reasoning_effort is only emitted into Codex install paths;
never returned from resolveModelInternal and never written to Claude
agent frontmatter.

Backwards compatible: any user without `runtime` set sees identical
behavior — the new branch is gated on `config.runtime != null`.

Precedence (highest to lowest):
  1. per-agent model_overrides
  2. runtime-aware tier resolution (when `runtime` is set)
  3. resolve_model_ids: "omit"
  4. Claude-native default
  5. inherit (literal passthrough)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2517): address adversarial review of #2609 (findings 1-16)

Addresses all 16 findings from the adversarial review of PR #2609.
Each finding is enumerated below with its resolution.

CRITICAL
- F1: readGsdRuntimeProfileResolver(targetDir) now probes per-project
  .planning/config.json AND ~/.gsd/defaults.json with per-project winning,
  so the PR's headline claim ("set runtime in project config and Codex
  TOML emit picks it up") actually holds end-to-end.
- F2: resolveTierEntry field-merges user overrides with built-in defaults.
  The CONFIGURATION.md string-shorthand example
    `{ codex: { opus: "gpt-5-pro" } }`
  now keeps reasoning_effort from the built-in entry. Partial-object
  overrides like `{ opus: { reasoning_effort: 'low' } }` keep the
  built-in model. Both paths regression-tested.

MAJOR
- F3: resolveReasoningEffortInternal gates strictly on the
  RUNTIMES_WITH_REASONING_EFFORT allowlist regardless of override
  presence. Override + unknown-runtime no longer leaks reasoning_effort.
- F4: runtime:"claude" is now a no-op for resolution (it is the implicit
  default). It no longer hijacks resolve_model_ids:"omit". Existing
  tests for `runtime:"claude"` returning Claude IDs were rewritten to
  reflect the no-op semantics; new test asserts the omit case returns "".
- F5: _readGsdConfigFile in install.js writes a stderr warning on JSON
  parse failure instead of silently returning null. Read failure and
  parse failure are warned separately. Library require is hoisted to top
  of install.js so it is not co-mingled with config-read failure modes.
- F6: install.js requires for core.cjs / model-profiles.cjs are hoisted
  to the top of the file with __dirname-based absolute paths so global
  npm install works regardless of cwd. Test asserts both lib paths exist
  relative to install.js __dirname.
- F7: docs/CONFIGURATION.md `runtime` row no longer lists `opencode` as
  a valid runtime — install-path emission for non-Codex runtimes is
  explicitly out of scope per #2517 / #2612, and the doc now points at
  #2612 for the follow-on work. resolveModelInternal still accepts any
  runtime string (back-compat) and falls back safely for unknown values.
- F8: Tests now isolate HOME (and GSD_HOME) to a per-test tmpdir so the
  developer's real ~/.gsd/defaults.json cannot bleed into assertions.
  Same pattern CodeRabbit caught on PRs #2603 / #2604.
- F9: `runtime` and `model_profile_overrides` documented as flat-only
  in core.cjs comments — not routed through `get()` because they are
  top-level keys per docs/CONFIGURATION.md and introducing nested
  resolution for two new keys was not worth the edge-case surface.
- F10/F13: loadConfig now invokes _warnUnknownProfileOverrides on the
  raw parsed config so direct .planning/config.json edits surface
  unknown runtime values (e.g. typo `runtime: "codx"`) and unknown
  tier values (e.g. `model_profile_overrides.codex.banana`) at read
  time. Warnings only — preserves back-compat for runtimes added
  later. Per-process warning cache prevents log spam across repeated
  loadConfig calls.

MINOR / NIT
- F11: Removed dead `tier || 'sonnet'` defensive shortcut. The local
  is now `const alias = tier;` with a comment explaining why `tier`
  is guaranteed truthy at that point (every MODEL_PROFILES entry
  defines `balanced`, the fallback profile).
- F12: Extracted resolveTierEntry() in core.cjs as the single source
  of truth for runtime-aware tier resolution. core.cjs and bin/install.js
  both consume it — no duplicated lookup logic between the two files.
- F14: Added regression tests for findings #1, #2, #3, #4, #6, #10, #13
  in tests/issue-2517-runtime-aware-profiles.test.cjs. Each must-fix
  path has a corresponding test that fails against the pre-fix code
  and passes against the post-fix code.
- F15: docs/CONFIGURATION.md `model_profile` row cross-references
  #1713 / #1806 next to the `adaptive` enum value.
- F16: RUNTIME_PROFILE_MAP remains in core.cjs as the single source of
  truth; install.js imports it through the exported resolveTierEntry
  helper rather than carrying its own copy. Doc files (CONFIGURATION.md,
  USER-GUIDE.md, settings.md) intentionally still embed the IDs as text
  — code comment in core.cjs flags that those doc files must be updated
  whenever the constant changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:00:37 -04:00
Tom Boucher
41dc475c46 refactor(workflows): extract discuss-phase modes/templates/advisor for progressive disclosure (closes #2551) (#2607)
* refactor(workflows): extract discuss-phase modes/templates/advisor for progressive disclosure (closes #2551)

Splits 1,347-line workflows/discuss-phase.md into a 495-line dispatcher plus
per-mode files in workflows/discuss-phase/modes/ and templates in
workflows/discuss-phase/templates/. Mirrors the progressive-disclosure
pattern that #2361 enforced for agents.

- Per-mode files: power, all, auto, chain, text, batch, analyze, default, advisor
- Templates lazy-loaded at the step that produces the artifact (CONTEXT.md
  template at write_context, DISCUSSION-LOG.md template at git_commit,
  checkpoint.json schema when checkpointing)
- Advisor mode gated behind `[ -f $HOME/.claude/get-shit-done/USER-PROFILE.md ]`
  — inverse of #2174's --advisor flag (don't pay the cost when unused)
- scout_codebase phase-type→map selection table extracted to
  references/scout-codebase.md
- New tests/workflow-size-budget.test.cjs enforces tiered budgets across
  all workflows/*.md (XL=1700 / LARGE=1500 / DEFAULT=1000) plus the
  explicit <500 ceiling for discuss-phase.md per #2551
- Existing tests updated to read from the new file locations after the
  split (functional equivalence preserved — content moved, not removed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2607): align modes/auto.md check_existing with parent (Update it, not Skip)

CodeRabbit flagged drift between the parent step (which auto-selects "Update
it") and modes/auto.md (which documented "Skip"). The pre-refactor file had
both — line 182 said "Skip" in the overview, line 250 said "Update it" in the
actual step. The step is authoritative. Fix the new mode file to match.

Refs: PR #2607 review comment 3127783430

* test(#2607): harden discuss-phase regression tests after #2551 split

CodeRabbit identified four test smells where the split weakened coverage:

- workflow-size-budget: assertion was unreachable (entered if-block on match,
  then asserted occurrences === 0 — always failed). Now unconditional.
- bug-2549-2550-2552: bounded-read assertion checked concatenated source, so
  src.includes('3') was satisfied by unrelated content in scout-codebase.md
  (e.g., "3-5 most relevant files"). Now reads parent only with a stricter
  regex. Also asserts SCOUT_REF exists.
- chain-flag-plan-phase: filter(existsSync) silently skipped a missing
  modes/chain.md. Now fails loudly via explicit asserts.
- discuss-checkpoint: same silent-filter pattern across three sources. Now
  asserts each required path before reading.

Refs: PR #2607 review comments 3127783457, 3127783452, plus nitpicks for
chain-flag-plan-phase.test.cjs:21-24 and discuss-checkpoint.test.cjs:22-27

* docs(#2607): fix INVENTORY count, context.md placeholders, scout grep portability

- INVENTORY.md: subdirectory note said "50 top-level references" but the
  section header now says 51. Updated to 51.
- templates/context.md: footer hardcoded XX-name instead of declared
  placeholders [X]/[Name], which would leak sample text into generated
  CONTEXT.md files. Now uses the declared placeholders.
- references/scout-codebase.md: no-maps fallback used grep -rl with
  "\\|" alternation (GNU grep only — silent on BSD/macOS grep). Switched
  to grep -rlE with extended regex for portability.

Refs: PR #2607 review comments 3127783404, 3127783448, plus nitpick for
scout-codebase.md:32-40

* docs(#2607): label fenced examples + clarify overlay/advisor precedence

- analyze.md / text.md / default.md: add language tags (markdown/text) to
  fenced example blocks to silence markdownlint MD040 warnings flagged by
  CodeRabbit (one fence in analyze.md, two in text.md, five in default.md).
- discuss-phase.md: document overlay stacking rules in discuss_areas — fixed
  outer→inner order --analyze → --batch → --text, with a pointer to each
  overlay file for mode-specific precedence.
- advisor.md: add tie-breaker rules for NON_TECHNICAL_OWNER signals — explicit
  technical_background overrides inferred signals; otherwise OR-aggregate;
  contradictory explanation_depth values resolve by most-recent-wins.

Refs: PR #2607 review comments 3127783415, 3127783437, plus nitpicks for
default.md:24, discuss-phase.md:345-365, and advisor.md:51-56

* fix(#2607): extract codebase_drift_gate body to keep execute-phase under XL budget

PR #2605 added 80 lines to execute-phase.md (1622 -> 1702), pushing it over
the XL_BUDGET=1700 line cap enforced by tests/workflow-size-budget.test.cjs
(introduced by this PR). Per the test's own remediation hint and #2551's
progressive-disclosure pattern, extract the codebase_drift_gate step body to
get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md and leave
a brief pointer in the workflow. execute-phase.md is now 1633 lines.

Budget is NOT relaxed; the offending workflow is tightened.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:57:24 -04:00
Tom Boucher
220da8e487 feat: /gsd-settings-integrations — configure third-party search and review integrations (closes #2529) (#2604)
* feat(#2529): /gsd-settings-integrations — third-party integrations command

Adds /gsd-settings-integrations for configuring API keys, code-review CLI
routing, and agent-skill injection. Distinct from /gsd-settings (workflow
toggles) because these are connectivity, not pipeline shape.

Three sections:
- Search Integrations: brave_search / firecrawl / exa_search API keys,
  plus search_gitignored toggle.
- Code Review CLI Routing: review.models.{claude,codex,gemini,opencode}
  shell-command strings.
- Agent Skills Injection: agent_skills.<agent-type> free-text input,
  validated against [a-zA-Z0-9_-]+.

Security:
- New secrets.cjs module with ****<last-4> masking convention.
- cmdConfigSet now masks value/previousValue in CLI output for secret keys.
- Plaintext is written only to .planning/config.json; never echoed to
  stdout/stderr, never written to audit/log files by this flow.
- Slug validators reject path separators, whitespace, shell metacharacters.

Tests (tests/settings-integrations.test.cjs — 25 cases):
- Artifact presence / frontmatter.
- Field round-trips via gsd-tools config-set for all four search keys,
  review.models.<cli>, agent_skills.<agent-type>.
- Config-merge safety: unrelated keys preserved across writes.
- Masking: config-set output never contains plaintext sentinel.
- Logging containment: plaintext secret sentinel appears only in
  config.json under .planning/, nowhere else on disk.
- Negative: path-traversal, shell-metachar, and empty-slug rejected.
- /gsd:settings workflow mentions /gsd:settings-integrations.

Docs:
- docs/COMMANDS.md: new command entry with security note.
- docs/CONFIGURATION.md: integration settings section (keys, routing,
  skills injection) with masking documentation.
- docs/CLI-TOOLS.md: reviewer CLI routing and secret-handling sections.
- docs/INVENTORY.md + INVENTORY-MANIFEST.json regenerated.

Closes #2529

* fix(#2529): mask secrets in config-get; address CodeRabbit review

cmdConfigGet was emitting plaintext for brave_search/firecrawl/exa_search.
Apply the same isSecretKey/maskSecret treatment used by config-set so the
CLI surface never echoes raw API keys; plaintext still lives only in
config.json on disk.

Also addresses CodeRabbit review items in the same PR area:
- #3127146188: config-get plaintext leak (root fix above)
- #3127146211: rename test sentinels to concat-built markers so secret
  scanners stop flagging the test file. Behavior preserved.
- #3127146207: add explicit 'text' language to fenced code blocks (MD040).
- nitpick: unify masked-value wording in read_current legend
  ('****<last-4>' instead of '**** already set').
- nitpick: extend round-trip test to cover search_gitignored toggle.

New regression test 'config-get masks secrets and never echoes plaintext'
verifies the fix for all three secret keys.

* docs(#2529): bump INVENTORY counts post-rebase (commands 84→85, workflows 82→83)

* fix(test): bump CLI Modules count 27→28 after rebase onto main (CI #24811455435)

PR #2604 was rebased onto main before #2605 (drift.cjs) merged. The
pull_request CI runs against the merge ref (refs/pull/2604/merge),
which now contains 28 .cjs files in get-shit-done/bin/lib/, but
docs/INVENTORY.md headline still said "(27 shipped)".

inventory-counts.test.cjs failed with:
  AssertionError: docs/INVENTORY.md "CLI Modules (27 shipped)" disagrees
  with get-shit-done/bin/lib/ file count (28)

Rebased branch onto current origin/main (picks up drift.cjs row, which
was already added by #2605) and bumped the headline to 28.

Full suite: 5200/5200 pass.
2026-04-22 21:41:00 -04:00
Tom Boucher
c90081176d fix(#2598): pass shell: true to npm spawnSync on Windows (#2600)
* fix(#2598): pass shell: true to npm spawnSync on Windows

Since Node's CVE-2024-27980 fix (>= 18.20.2 / >= 20.12.2 / >= 21.7.3),
spawnSync refuses to launch .cmd/.bat files on Windows without
`shell: true`. installSdkIfNeeded picks npmCmd='npm.cmd' on win32 and
then calls spawnSync five times — every one returns
{ status: null, error: EINVAL } before npm ever runs. The installer
checks `status !== 0`, trips the failure path, and emits a bare
"Failed to `npm install` in sdk/." with zero diagnostic output because
`stdio: 'inherit'` never had a child to stream.

Every fresh install on Windows has failed at the SDK build step on any
supported Node version for the life of the post-CVE bin/install.js.

Introduce a local `spawnNpm(args, opts)` helper inside
installSdkIfNeeded that injects `shell: process.platform === 'win32'`
when the caller doesn't override it. Route all five npm invocations
through it: `npm install`, `npm run build`, `npm install -g .`, and
both `npm config get prefix` calls.

Adds a static regression test that parses installSdkIfNeeded and
asserts no bare `spawnSync(npmCmd, ...)` remains, a shell-aware
wrapper exists, and at least five invocations go through it.

Closes #2598

* fix(#2598): surface spawnSync diagnostics in SDK install fatal paths

Thread result.error / result.signal / result.status into emitSdkFatal for
the three npm failure branches (install, run build, install -g .) via a
formatSpawnFailure helper. The root cause of #2598 went silent precisely
because `{ status: null, error: EINVAL }` was reduced to a generic
"Failed to `npm install` in sdk/." with no diagnostic — stdio: 'inherit'
had no child process to stream and result.error was swallowed. Any future
regression in the same area (EINVAL, ENOENT, signal termination) now
prints its real cause in the red fatal banner.

Also strengthen the regression test so it cannot pass with only four
real npm call sites: the previous `spawnSync(npmCmd, ..., shell)` regex
double-counted the spawnNpm helper's own body when a helper existed.
Separate arrow-form vs function-form helper detection and exclude the
wrapper body from explicitShellNpm so the `>= 5` assertion reflects real
invocations only. Add a new test that asserts all three fatal branches
now reference formatSpawnFailure / result.error / signal / status.

Addresses CodeRabbit review comments on PR #2600:
- r3126987409 (bin/install.js): surface underlying spawnSync failure
- r3126987419 (test): explicitShellNpm overcounts by one via helper def
2026-04-22 21:23:44 -04:00
Tom Boucher
1a694fcac3 feat: auto-remap codebase after significant phase execution (closes #2003) (#2605)
* feat: auto-remap codebase after significant phase execution (#2003)

Adds a post-phase structural drift detector that compares the committed tree
against `.planning/codebase/STRUCTURE.md` and either warns or auto-remaps
the affected subtrees when drift exceeds a configurable threshold.

## Summary
- New `bin/lib/drift.cjs` — pure detector covering four drift categories:
  new directories outside mapped paths, new barrel exports at
  `(packages|apps)/*/src/index.*`, new migration files, and new route
  modules. Prioritizes the most-specific category per file.
- New `verify codebase-drift` CLI subcommand + SDK handler, registered as
  `gsd-sdk query verify.codebase-drift`.
- New `codebase_drift_gate` step in `execute-phase` between
  `schema_drift_gate` and `verify_phase_goal`. Non-blocking by contract —
  any error logs and the phase continues.
- Two new config keys: `workflow.drift_threshold` (int, default 3) and
  `workflow.drift_action` (`warn` | `auto-remap`, default `warn`), with
  enum/integer validation in `config-set`.
- `gsd-codebase-mapper` learns an optional `--paths <p1,p2,...>` scope hint
  for incremental remapping; agent/workflow docs updated.
- `last_mapped_commit` lives in YAML frontmatter on each
  `.planning/codebase/*.md` file; `readMappedCommit`/`writeMappedCommit`
  round-trip helpers ship in `drift.cjs`.

## Tests
- 55 new tests in `tests/drift-detection.test.cjs` covering:
  classification, threshold gating at 2/3/4 elements, warn vs. auto-remap
  routing, affected-path scoping, `--paths` sanitization (traversal,
  absolute, shell metacharacter rejection), frontmatter round-trip,
  defensive paths (missing STRUCTURE.md, malformed input, non-git repos),
  CLI JSON output, and documentation parity.
- Full suite: 5044 pass / 0 fail.

## Documentation
- `docs/CONFIGURATION.md` — rows for both new keys.
- `docs/ARCHITECTURE.md` — section on the post-execute drift gate.
- `docs/AGENTS.md` — `--paths` flag on `gsd-codebase-mapper`.
- `docs/USER-GUIDE.md` — user-facing behavior note + toggle commands.
- `docs/FEATURES.md` — new 27a section with REQ-DRIFT-01..06.
- `docs/INVENTORY.md` + `docs/INVENTORY-MANIFEST.json` — drift.cjs listed.
- `get-shit-done/workflows/execute-phase.md` — `codebase_drift_gate` step.
- `get-shit-done/workflows/map-codebase.md` — `parse_paths_flag` step.
- `agents/gsd-codebase-mapper.md` — `--paths` directive under parse_focus.

## Design decisions
- **Frontmatter over sidecar JSON** for `last_mapped_commit`: keeps the
  baseline attached to the file, survives git moves, survives per-doc
  regeneration, no extra file lifecycle.
- **Substring match against STRUCTURE.md** for `isPathMapped`: the map is
  free-form markdown, not a structured manifest; any mention of a path
  prefix counts as "mapped territory". Cheap, no parser, zero false
  negatives on reasonable maps.
- **Category priority migration > route > barrel > new_dir** so a file
  matching multiple rules counts exactly once at the most specific level.
- **Empty-tree SHA fallback** (`4b825dc6…`) when `last_mapped_commit` is
  absent — semantically correct (no baseline means everything is drift)
  and deterministic across repos.
- **Four layers of non-blocking** — detector try/catch, CLI try/catch, SDK
  handler try/catch, and workflow `|| echo` shell fallback. Any single
  layer failing still returns a valid skipped result.
- **SDK handler delegates to `gsd-tools.cjs`** rather than re-porting the
  detector to TypeScript, keeping drift logic in one canonical place.

Closes #2003

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(mapper): tag --paths fenced block as text (CodeRabbit MD040)

Comment 3127255172.

* docs(config): use /gsd- dash command syntax in drift_action row (CodeRabbit)

Comment 3127255180. Matches the convention used by every other command
reference in docs/CONFIGURATION.md.

* fix(execute-phase): initialize AGENT_SKILLS_MAPPER + tag fenced blocks

Two CodeRabbit findings on the auto-remap branch of the drift gate:

- 3127255186 (must-fix): the mapper Task prompt referenced
  ${AGENT_SKILLS_MAPPER} but only AGENT_SKILLS (for gsd-executor) is
  loaded at init_context (line 72). Without this fix the literal
  placeholder string would leak into the spawned mapper's prompt.
  Add an explicit gsd-sdk query agent-skills gsd-codebase-mapper step
  right before the Task spawn.
- 3127255183: tag the warn-message and Task() fenced code blocks as
  text to satisfy markdownlint MD040.

* docs(map-codebase): wire PATH_SCOPE_HINT through every mapper prompt

CodeRabbit (review id 4158286952, comment 3127255190) flagged that the
parse_paths_flag step defined incremental-remap semantics but did not
inject a normalized variable into the spawn_agents and sequential_mapping
mapper prompts, so incremental remap could silently regress to a
whole-repo scan.

- Define SCOPED_PATHS / PATH_SCOPE_HINT in parse_paths_flag.
- Inject ${PATH_SCOPE_HINT} into all four spawn_agents Task prompts.
- Document the same scope contract for sequential_mapping mode.

* fix(drift): writeMappedCommit tolerates missing target file

CodeRabbit (review id 4158286952, drift.cjs:349-355 nitpick) noted that
readMappedCommit returns null on ENOENT but writeMappedCommit threw — an
asymmetry that breaks first-time stamping of a freshly produced doc that
the caller has not yet written.

- Catch ENOENT on the read; treat absent file as empty content.
- Add a regression test that calls writeMappedCommit on a non-existent
  path and asserts the file is created with correct frontmatter.
  Test was authored to fail before the fix (ENOENT) and passes after.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:21:44 -04:00
Tom Boucher
9c0a153a5f feat: /gsd-settings-advanced — power-user config tuning command (closes #2528) (#2603)
* feat: /gsd-settings-advanced — power-user config tuning command (closes #2528)

Adds a second-tier interactive configuration command covering the power-user
knobs that don't belong in the common-case /gsd-settings prompt. Six sectioned
AskUserQuestion batches cover planning, execution, discussion, cross-AI, git,
and runtime settings (19 config keys total). Current values are pre-selected;
numeric fields reject non-numeric input; writes route through
gsd-sdk query config-set so unrelated keys are preserved.

- commands/gsd/settings-advanced.md — command entry
- get-shit-done/workflows/settings-advanced.md — six-section workflow
- get-shit-done/workflows/settings.md — advertise advanced command
- get-shit-done/bin/lib/config-schema.cjs — add context_window to VALID_CONFIG_KEYS
- docs/COMMANDS.md, docs/CONFIGURATION.md, docs/INVENTORY.md — docs + inventory
- tests/gsd-settings-advanced.test.cjs — 81 tests (files, frontmatter,
  field coverage, pre-selection, merge-preserves-siblings, VALID_CONFIG_KEYS
  membership, confirmation table, /gsd-settings cross-link, negative scenarios)

All 5073 tests pass; coverage 88.66% (>= 70% threshold).

* docs(settings-advanced): clarify per-field numeric bounds and label fenced blocks

Addresses CodeRabbit review on PR #2603:
- Numeric-input rule now states min is field-specific: plan_bounce_passes
  and max_discuss_passes require >= 1; other numeric fields accept >= 0.
  Resolves the inconsistency between the global rule and the field-level
  prompts (CodeRabbit comment 3127136557).
- Adds 'text' fence language to seven previously unlabeled code blocks in
  the workflow (six AskUserQuestion sections plus the confirmation banner)
  to satisfy markdownlint MD040 (CodeRabbit comment 3127136561).

* test(settings-advanced): tighten section assertion, fix misleading test name, add executable numeric-input coverage

Addresses CodeRabbit review on PR #2603:
- Required section list now asserts the full 'Runtime / Output' heading
  rather than the looser 'Runtime' substring (comment 3127136564).
- Renames the subagent_timeout coercion test to match the actual key
  under test (was titled 'context_window' but exercised
  workflow.subagent_timeout — comment 3127136573).
- Adds two executable behavioral tests at the config-set boundary
  (comment 3127136579):
  * Non-numeric input on a numeric key currently lands as a string —
    locks in that the workflow's AskUserQuestion re-prompt loop is the
    layer responsible for type rejection. If a future change adds CLI-side
    numeric validation, the assertion flips and the test surfaces it.
  * Numeric string on workflow.max_discuss_passes is coerced to Number —
    locks in the parser invariant for a second numeric key.
2026-04-22 20:50:15 -04:00
Tom Boucher
86c5863afb feat: add settings layers to /gsd-settings (Group A toggles) (closes #2527) (#2602)
* feat(#2527): add settings layers to /gsd:settings (Group A toggles)

Expand /gsd:settings from 14 to 22 settings, grouped into six visual
sections: Planning, Execution, Docs & Output, Features, Model & Pipeline,
Misc. Adds 8 new toggles:

  workflow.pattern_mapper, workflow.tdd_mode, workflow.code_review,
  workflow.code_review_depth (conditional on code_review=on),
  workflow.ui_review, commit_docs, intel.enabled, graphify.enabled

All 8 keys already existed in VALID_CONFIG_KEYS and docs/CONFIGURATION.md;
this wires them into the interactive flow, update_config write step,
~/.gsd/defaults.json persistence, and confirmation table.

Closes #2527

* test(#2527): tighten leaf-collision and rename mismatched negative test

Addresses CodeRabbit findings on PR #2602:

- comment 3127100796: leaf-only matching collapsed `intel.enabled` and
  `graphify.enabled` to a single `enabled` token, so one occurrence
  could satisfy both assertions. Replace with hasPathLike(), which
  requires each dotted segment to appear in order within a bounded
  window. Applied to both update_config and save_as_defaults blocks.

- comment 3127100798: the negative-test description claimed to verify
  invalid `code_review_depth` value rejection but actually exercised an
  unknown key path. Split into two suites with accurate names: one
  asserts settings.md constrains the depth options, the other asserts
  config-set rejects an unknown key path.

* docs(#2527): clarify resolved config path for /gsd-settings

Addresses CodeRabbit comment 3127100790 on PR #2602: the original line
implied a single `.planning/config.json` target, but settings updates
route to `.planning/workstreams/<active>/config.json` when a workstream
is active. Document both resolved paths so the merge target is
unambiguous.
2026-04-22 20:49:52 -04:00
Tom Boucher
1f2850c1a8 fix(#2597): expand dotted query tokens with trailing args (#2599)
resolveQueryArgv only expanded `init.execute-phase` → `init execute-phase`
when the tokens array had length 1. Argv like `init.execute-phase 1` has
length 2, skipped the expansion, and resolved to no registered handler.

All 50+ workflow files use the dotted form with arguments, so this broke
every non-argless query route (`init.execute-phase`, `state.update`,
`phase.add`, `milestone.complete`, etc.) at runtime.

Rename `expandSingleDottedToken` → `expandFirstDottedToken`: split only
the first token on its dots (guarding against `--` flags) and preserve
the tail as positional args. Identity comparison at the call site still
detects "no expansion" since we return the input array unchanged.

Adds regression tests for the three failure patterns reported:
`init.execute-phase 1`, `state.update status X`, `phase.add desc`.

Closes #2597
2026-04-22 17:30:08 -04:00
Tom Boucher
b35fdd51f3 Revert "feat(#2473): ship refuses to open PR when HANDOFF.json declares in-pr…" (#2596)
This reverts commit 7212cfd4de.
2026-04-22 12:57:12 -04:00
Fernando Castillo
7212cfd4de feat(#2473): ship refuses to open PR when HANDOFF.json declares in-progress work (#2553)
* feat(#2473): ship refuses to open PR when HANDOFF.json declares in-progress work

Add a preflight step to /gsd-ship that parses .planning/HANDOFF.json and
refuses to run git push + gh pr create when any remaining_tasks[].status
is not in the terminal set {done, cancelled, deferred_to_backend, wont_fix}.

Refusal names each blocking task and lists four resolutions (finish, mark
terminal, delete stale file, --force). Missing HANDOFF.json is a no-op so
projects that do not use /gsd-pause-work see no behavior change.

Also documents the terminal-statuses contract in references/artifact-types.md
and adds tests/ship-handoff-preflight.test.cjs to lock in the contract.

Closes #2473

* fix(#2473): capture node exit from $() so malformed HANDOFF.json hard-stops

Command substitution BLOCKING=$(node -e "...") discards the inner process
exit code, so a corrupted HANDOFF.json that fails JSON.parse would yield
empty BLOCKING and fall through silently to push_branch — the opposite of
what preflight is supposed to do.

Capture node's exit into HANDOFF_EXIT via $? immediately after the
assignment and branch on it. A non-zero exit is now a hard refusal with
the parser error printed on the preceding stderr line. --force does not
bypass this branch: if the file exists and can't be parsed, something is
wrong and the user should fix it (option 3 in the refusal message —
"Delete HANDOFF.json if it's stale" — still applies).

Verified with a tmp-dir simulation: captured exit 2, hard-stop fires
correctly on malformed JSON. Added a test case asserting the capture
($?) + branch (-ne 0) + parser exit (process.exit(2)) are all present,
so a future refactor can't silently reintroduce the bug.

Reported by @coderabbitai on PR #2553.
2026-04-22 12:11:31 -04:00
Tom Boucher
2b5c35cdb1 test(#2519): add regression test for sdk tarball dist inclusion (#2586)
* test(#2519): add regression test verifying sdk/package.json has files + prepublishOnly

Guards the sdk/package.json fix for #2519 (tarball shipped without dist/)
so future edits can't silently drop either the `files` whitelist or the
`prepublishOnly` build hook. Asserts:

- `files` is a non-empty array
- `files` includes "dist" (so compiled CLI ships in tarball)
- `scripts.prepublishOnly` runs a build (npm run build / tsc)
- `bin` target lives under dist/ (sanity tie-in)

Closes #2519

* test(#2519): accept valid npm glob variants for dist in files matcher

Addresses CodeRabbit nitpick: the previous equality check on 'dist' / 'dist/' /
'dist/**' would false-fail on other valid npm packaging forms like './dist',
'dist/**/*', or backslash-separated paths. Normalize each entry and use a
regex that accepts all common dist path variants.
2026-04-22 12:09:12 -04:00
Tom Boucher
73c1af5168 fix(#2543): replace legacy /gsd-<cmd> syntax with /gsd:<cmd> across all source files (#2595)
Commands are now installed as commands/gsd/<name>.md and invoked as
/gsd:<name> in Claude Code. The old hyphen form /gsd-<name> was still
hardcoded in hundreds of places across workflows, references, templates,
lib modules, and command files — causing "Unknown command" errors
whenever GSD suggested a command to the user.

Replace all /gsd-<cmd> occurrences where <cmd> is a known command name
(derived at runtime from commands/gsd/*.md) using a targeted Node.js
script. Agent names, tool names (gsd-sdk, gsd-tools), directory names,
and path fragments are not touched.

Adds regression test tests/bug-2543-gsd-slash-namespace.test.cjs that
enforces zero legacy occurrences going forward. Removes inverted
tests/stale-colon-refs.test.cjs (bug #1748) which enforced the now-obsolete
hyphen form; the new bug-2543 test supersedes it. Updates 5 assertion
tests that hardcoded the old hyphen form to accept the new colon form.

Closes #2543

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:04:25 -04:00
Tom Boucher
533973700c feat(#2538): add last: /cmd suffix to statusline (opt-in) (#2594)
Adds a `statusline.show_last_command` config toggle (default: false) that
appends ` │ last: /<cmd>` to the statusline, showing the most recently
invoked slash command in the current session.

The suffix is derived by tailing the active Claude Code transcript
(provided as transcript_path in the hook input) and extracting the last
<command-name> tag. Reads only the final 256 KiB to stay cheap per render.
Graceful degradation: missing transcript, no recorded command, unreadable
config, or parse errors all silently omit the suffix without breaking the
statusline.

Closes #2538
2026-04-22 12:04:21 -04:00
Tom Boucher
349daf7e6a fix(#2545): use word boundary in path replacement to catch ~/.claude without trailing slash (#2592)
The Copilot content converter only replaced `~/.claude/` and
`$HOME/.claude/` when followed by a literal `/`. Bare references
(e.g. `configDir = ~/.claude` at end of line) slipped through and
triggered the post-install "Found N unreplaced .claude path reference(s)"
warning, since the leak scanner uses `(?:~|$HOME)/\.claude\b`.

Switched both replacements to a `(\/|\b)` capture group so trailing-slash
and bare forms are handled in a single pass — matching the pattern
already used by Antigravity, OpenCode, Kilo, and Codex converters.

Closes #2545
2026-04-22 12:04:17 -04:00
Tom Boucher
6b7b5c15a5 fix(#2559): remove stale year injection from research agent web search instructions (#2591)
The gsd-phase-researcher and gsd-project-researcher agents instructed
WebSearch queries to always include 'current year' (e.g., 2024). As
time passes, a hardcoded year biases search results toward stale
dated content — users saw 2024-tagged queries producing stale blog
references in 2026.

Remove the year-injection guidance. Instead, rely on checking
publication dates on the returned sources. Query templates and
success criteria updated accordingly.

Closes #2559
2026-04-22 12:04:13 -04:00
Tom Boucher
67a9550720 fix(#2549,#2550,#2552): bound discuss-phase context reads, add phase-type map selection, prohibit split reads (#2590)
#2549: load_prior_context was reading every prior *-CONTEXT.md file,
growing linearly with project phase count. Cap to the 3 most recent
phases. If .planning/DECISIONS-INDEX.md exists, read that instead.

#2550: scout_codebase claimed to select maps "based on phase type" but
had no classifier — agents read all 7 maps. Replace with an explicit
phase-type-to-maps table (2–3 maps per phase type) with a Mixed fallback.

#2552: Add explicit instruction not to split-read the same file at two
different offsets. Split reads break prompt cache reuse and cost more
than a single full read.

Closes #2549
Closes #2550
Closes #2552

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 12:04:10 -04:00
Tom Boucher
fba040c72c fix(#2557): Gemini/Antigravity local hook commands use relative paths, not \$CLAUDE_PROJECT_DIR (#2589)
\$CLAUDE_PROJECT_DIR is Claude Code-specific. Gemini CLI doesn't set it, and on
Windows its path-join logic doubled the value producing unresolvable paths like
D:\Projects\GSD\'D:\Projects\GSD'. Gemini runs project hooks with project root
as cwd, so bare relative paths (e.g. node .gemini/hooks/gsd-check-update.js)
are cross-platform and correct. Claude Code and others still use the env var.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 12:04:06 -04:00
Tom Boucher
7032f44633 fix(#2544): exit 1 on missing key in config-get (#2588)
The configGet query handler previously threw GSDError with
ErrorClassification.Validation, which maps to exit code 10. Callers
using `if ! gsd-sdk query config-get key; then fallback; fi` could
not detect missing keys through the exit code alone, because exit 10
is still truthy-failure but the intent (and documented UNIX
convention — cf. `git config --get`) is exit 1 for absent key.

Change the classification for the two 'Key not found' throw sites to
ErrorClassification.Execution so the CLI exits 1 on missing key.
Usage/schema errors (no key argument, malformed JSON, missing
config.json) remain Validation.

Closes #2544
2026-04-22 12:04:03 -04:00
Tom Boucher
2404b40a15 fix(#2555): SDK agent-skills reads config.agent_skills and returns <agent_skills> block (#2587)
The SDK query handler `agent-skills` previously scanned every skill
directory on the filesystem and returned a flat JSON list, ignoring
`config.agent_skills[agentType]` entirely. Workflows that interpolate
$(gsd-sdk query agent-skills <type>) into Task() prompts got a JSON
dump of all skills instead of the documented <agent_skills> block.

Port `buildAgentSkillsBlock` semantics from
get-shit-done/bin/lib/init.cjs into the SDK handler:

- Read config.agent_skills[agentType] via loadConfig()
- Support single-string and array forms
- Validate each project-relative path stays inside the project root
  (symlink-aware, mirrors security.cjs#validatePath)
- Support `global:<name>` prefix for ~/.claude/skills/<name>/
- Skip entries whose SKILL.md is missing, with a stderr warning
- Return the exact string block workflows embed:
  <agent_skills>\nRead these user-configured skills:\n- @.../SKILL.md\n</agent_skills>
- Empty string when no agent type, no config, or nothing valid — matches
  gsd-tools.cjs cmdAgentSkills output.
2026-04-22 12:03:59 -04:00
Tom Boucher
0d6349a6c1 fix(#2554): preserve leading zero in getMilestonePhaseFilter (#2585)
The normalization `replace(/^0+/, '')` over-stripped decimal phase IDs:
`"00.1"` collapsed to `".1"`, while the disk-side extractor yielded
`"0.1"` from `"00.1-<slug>"`. Set membership failed and inserted decimal
phases were silently excluded from every disk scan inside
`buildStateFrontmatter`, causing `state update` to rewind progress
counters.

Strip leading zeros only when followed by a digit
(`replace(/^0+(?=\d)/, '')`), preserving the zero before the decimal
point while keeping existing behavior for zero-padded integer IDs.

Closes #2554
2026-04-22 12:03:56 -04:00
Tom Boucher
c47a6a2164 fix: correct VALID_CONFIG_KEYS — remove internal state key, add missing public keys, migration hints (#2561)
* fix(#2530-2535): correct VALID_CONFIG_KEYS set — remove internal state key, add missing public keys, add migration hints

- Remove workflow._auto_chain_active from VALID_CONFIG_KEYS (internal runtime state, not user-settable) (#2530)
- Add hooks.workflow_guard to VALID_CONFIG_KEYS (read by gsd-workflow-guard.js hook, already documented) (#2531)
- Add workflow.ui_review to VALID_CONFIG_KEYS (read in autonomous.md via config-get) (#2532)
- Add workflow.max_discuss_passes to VALID_CONFIG_KEYS (read in discuss-phase.md via config-get) (#2533)
- Add CONFIG_KEY_SUGGESTIONS entries for sub_repos → planning.sub_repos and plan_checker → workflow.plan_check (#2535)
- Document workflow.ui_review and workflow.max_discuss_passes in docs/CONFIGURATION.md
- Clear INTERNAL_KEYS exemption in parity test (workflow._auto_chain_active removed from schema entirely)
- Add regression test file tests/bug-2530-valid-config-keys.test.cjs covering all 6 bugs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: align SDK VALID_CONFIG_KEYS with CJS — remove internal key, add missing public keys

- Remove workflow._auto_chain_active from SDK (internal runtime state, not user-settable)
- Add workflow.ui_review, workflow.max_discuss_passes, hooks.workflow_guard to SDK
- Add ui_review and max_discuss_passes to Full Schema example in CONFIGURATION.md

Resolves CodeRabbit review on #2561.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:28:25 -04:00
forfrossen
af2dba2328 fix(hooks): detect Claude Code via stdin session_id (closes #2520) (#2521)
* fix(hooks): detect Claude Code via stdin session_id, not filtered env (#2520)

The #2344 fix assumed `CLAUDECODE` would propagate to hook subprocesses.
On Claude Code v2.1.116 it doesn't — Claude Code applies a separate env
filter to PreToolUse hook commands that drops bare CLAUDECODE and
CLAUDE_SESSION_ID, keeping only CLAUDE_CODE_*-prefixed vars plus
CLAUDE_PROJECT_DIR. As a result every Edit/Write on an existing file
produced a redundant READ-BEFORE-EDIT advisory inside Claude Code.

Use `data.session_id` from the hook's stdin JSON as the primary Claude
Code signal (it's part of Claude Code's documented PreToolUse hook-input
schema). Keep CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SSE_PORT env checks
as propagation-verified fallbacks, and keep the legacy
CLAUDE_SESSION_ID / CLAUDECODE checks for back-compat and
future-proofing.

Add tests/bug-2520-read-guard-hook-subprocess-env.test.cjs, which spawns
the hook with an env mirroring the actual Claude Code hook-subprocess
filter. Extend the legacy test harnesses to also strip the
propagation-verified CLAUDE_CODE_* vars so positive-path tests keep
passing when the suite itself runs inside a Claude Code session (same
class of leak as #2370 / PR #2375, now covering the new detection
signals).

Non-Claude-host behavior (OpenCode / MiniMax) is unchanged: with no
`session_id` on stdin and no CLAUDE_CODE_* env var, the advisory still
fires.

Closes #2520

* test(2520): isolate session_id signal from env fallbacks in regression test

Per reviewer feedback (Copilot + CodeRabbit on #2521): the session_id
isolation test used the helper's default CLAUDE_CODE_ENTRYPOINT /
CLAUDE_CODE_SSE_PORT values, so the env fallback would rescue the skip
even if the primary `data.session_id` check regressed. Pass an explicit
env override that clears those fallbacks, so only the stdin `session_id`
signal can trigger the skip.

Other cases (env-only fallback, negative / non-Claude host) already
override env appropriately.

---------

Co-authored-by: forfrossen <forfrossensvart@gmail.com>
2026-04-22 10:41:58 -04:00
elfstrob
9b5397a30f feat(sdk): add queued_phases to init.manager (closes #2497) (#2514)
* feat(sdk): add queued_phases to init.manager (closes #2497)

Surfaces the milestone immediately AFTER the active one so the
/gsd-manager dashboard can preview upcoming phases without mixing
them into the active phases grid.

Changes:
- roadmap.ts: exports two new helpers
  - extractPhasesFromSection(section): parses phase number / name /
    goal / depends_on using the same pattern initManager uses for
    the active milestone, so queued phases have identical shape.
  - extractNextMilestoneSection(content, projectDir): resolves the
    current milestone via the STATE-first path (matching upstream
    PR #2508) then scans for the next ## milestone heading. Shipped
    milestones are stripped first so they can't shadow the real
    next. Returns null when the active milestone is the last one.
- init-complex.ts: initManager now exposes
  - queued_phases: Array<{ number, name, display_name, goal,
    depends_on, dep_phases, deps_display }>
  - queued_milestone_version: string | null
  - queued_milestone_name: string | null
  Existing phases array is unchanged — callers that only care about
  the active milestone see no behavior difference.

Scope note: PR #2508 (merged upstream 2026-04-21) superseded the
#2495 + #2496 portions of this branch's original submission. This
commit is the rebased remainder contributing only #2497 on top of
upstream's new helpers.

Test coverage (7 new tests, all passing):
- roadmap.test.ts: +5 tests
  - extractPhasesFromSection parses multiple phases with goal + deps
  - extractPhasesFromSection returns [] when no phase headings
  - extractNextMilestoneSection returns the milestone after the
    STATE-resolved active one
  - extractNextMilestoneSection returns null when active is last
  - extractNextMilestoneSection returns null when no version found
- init-complex.test.ts: +4 tests under `queued_phases (#2497)`
  - surfaces next milestone with version + name metadata
  - queued entries carry name / deps_display / display_name
  - queued phases are NOT mixed into active phases list
  - returns [] + nulls when active is the last milestone

All 51 tests in roadmap.test.ts + init-complex.test.ts pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(workflows): render queued_phases section in /gsd-manager dashboard

Surfaces the new `queued_phases` / `queued_milestone_version` /
`queued_milestone_name` fields from init.manager (SDK #2497) in a
compact preview section directly below the main active-milestone
table.

Changes to workflows/manager.md:
- Initialize step: parse the optional trio
  (queued_milestone_version, queued_milestone_name, queued_phases)
  alongside the existing init.manager fields. Treat missing as
  empty for backward compatibility with older SDK versions.
- Dashboard step: new "Queued section (next milestone preview)"
  rendered between the main active-milestone grid and the
  Recommendations section. Renders only when queued_phases is
  non-empty; skipped entirely when absent or empty (e.g. active
  milestone is the last one).
- Queued rows render without D/P/E columns since the phases haven't
  been discussed yet — just number, display_name, deps_display,
  and a fixed "· Queued" status.
- Success criterion added: queued section renders when non-empty
  and is skipped when absent.

Queued phases are deliberately NOT eligible for the Continue action
menu; they live in a future milestone. The preview exists for
situational awareness only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:41:37 -04:00
186 changed files with 17662 additions and 4116 deletions

View File

@@ -1,10 +1,13 @@
name: Install Smoke
# Exercises the real install path: `npm pack` → `npm install -g <tarball>`
# → run `bin/install.js` → assert `gsd-sdk` is on PATH.
# Exercises the real install paths:
# tarball: `npm pack` → `npm install -g <tarball>` → assert gsd-sdk on PATH
# unpacked: `npm install -g <dir>` (no pack) → assert gsd-sdk on PATH + executable
#
# Closes the CI gap that let #2439 ship: the rest of the suite only reads
# `bin/install.js` as a string and never executes it.
# The tarball path is the canonical ship path. The unpacked path reproduces the
# mode-644 failure class (issue #2453): npm does NOT chmod bin targets when
# installing from an unpacked local directory, so any stale tsc output lacking
# execute bits will be caught by the unpacked job before release.
#
# - PRs: path-filtered, minimal runner (ubuntu + Node LTS) for fast signal.
# - Push to release branches / main: full matrix.
@@ -16,6 +19,7 @@ on:
- main
paths:
- 'bin/install.js'
- 'bin/gsd-sdk.js'
- 'sdk/**'
- 'package.json'
- 'package-lock.json'
@@ -40,6 +44,9 @@ concurrency:
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------------------
# Job 1: tarball install (existing canonical path)
# ---------------------------------------------------------------------------
smoke:
runs-on: ${{ matrix.os }}
timeout-minutes: 12
@@ -78,6 +85,31 @@ jobs:
if: steps.skip.outputs.skip != 'true'
with:
ref: ${{ inputs.ref || github.ref }}
# Need enough history to merge origin/main for stale-base detection.
fetch-depth: 0
# The default `refs/pull/N/merge` ref GitHub produces for PRs is cached
# against the recorded merge-base, not current main. When main advances
# after the PR was opened, the merge ref stays stale and CI can fail on
# issues that were already fixed upstream. Explicitly merge current
# origin/main into the PR head so smoke always tests the PR against the
# latest trunk. If the merge conflicts, emit a clear "rebase onto main"
# diagnostic instead of a downstream build error that looks unrelated.
- name: Rebase check — merge origin/main into PR head
if: steps.skip.outputs.skip != 'true' && github.event_name == 'pull_request'
shell: bash
run: |
set -euo pipefail
git config user.email "ci@gsd-build"
git config user.name "CI Rebase Check"
git fetch origin main
if ! git merge --no-edit --no-ff origin/main; then
echo "::error::This PR cannot cleanly merge origin/main. Rebase your branch onto current main and push again."
echo "::error::Conflicting files:"
git diff --name-only --diff-filter=U
git merge --abort
exit 1
fi
- name: Set up Node.js ${{ matrix.node-version }}
if: steps.skip.outputs.skip != 'true'
@@ -90,6 +122,23 @@ jobs:
if: steps.skip.outputs.skip != 'true'
run: npm ci
# Isolated SDK typecheck — if the build fails, emit a clear "stale base
# or real type error" diagnostic instead of letting the failure cascade
# into the tarball install step, where the downstream PATH assertion
# misreports it as "gsd-sdk not on PATH — installSdkIfNeeded regression".
- name: SDK typecheck (fails fast on type regressions)
if: steps.skip.outputs.skip != 'true'
shell: bash
run: |
set -euo pipefail
if ! npm run build:sdk; then
echo "::error::SDK build (npm run build:sdk) failed."
echo "::error::Common cause: your PR base is behind main and picks up intermediate type errors that are already fixed on trunk."
echo "::error::Fix: git fetch origin main && git rebase origin/main && git push --force-with-lease"
echo "::error::If the error persists on a fresh rebase, the type error is real — fix it in sdk/src/ and push."
exit 1
fi
- name: Pack root tarball
if: steps.skip.outputs.skip != 'true'
id: pack
@@ -109,7 +158,7 @@ jobs:
echo "$NPM_BIN" >> "$GITHUB_PATH"
echo "npm global bin: $NPM_BIN"
- name: Install tarball globally (runs bin/install.js → installSdkIfNeeded)
- name: Install tarball globally
if: steps.skip.outputs.skip != 'true'
shell: bash
env:
@@ -121,13 +170,14 @@ jobs:
cd "$TMPDIR_ROOT"
npm install -g "$WORKSPACE/$TARBALL"
command -v get-shit-done-cc
# `--claude --local` is the non-interactive code path (see
# install.js main block: when both a runtime and location are set,
# installAllRuntimes runs with isInteractive=false, no prompts).
# We tolerate non-zero here because the authoritative assertion is
# the next step: gsd-sdk must land on PATH. Some runtime targets
# may exit before the SDK step for unrelated reasons on CI.
get-shit-done-cc --claude --local || true
# `--claude --local` is the non-interactive code path. Don't swallow
# non-zero exit — if the installer fails, that IS the CI failure, and
# its own error message is more useful than the downstream "shim
# regression" assertion masking the real cause.
if ! get-shit-done-cc --claude --local; then
echo "::error::get-shit-done-cc --claude --local failed. See the install.js output above for the real error (SDK build, PATH resolution, chmod, etc.)."
exit 1
fi
- name: Assert gsd-sdk resolves on PATH
if: steps.skip.outputs.skip != 'true'
@@ -135,7 +185,7 @@ jobs:
run: |
set -euo pipefail
if ! command -v gsd-sdk >/dev/null 2>&1; then
echo "::error::gsd-sdk is not on PATH after install installSdkIfNeeded() regression"
echo "::error::gsd-sdk is not on PATH after tarball install — shim regression"
NPM_BIN="$(npm config get prefix)/bin"
echo "npm global bin: $NPM_BIN"
ls -la "$NPM_BIN" | grep -i gsd || true
@@ -150,3 +200,99 @@ jobs:
set -euo pipefail
gsd-sdk --version || gsd-sdk --help
echo "✓ gsd-sdk is executable"
# ---------------------------------------------------------------------------
# Job 2: unpacked-dir install — reproduces the mode-644 failure class (#2453)
#
# `npm install -g <directory>` does NOT chmod bin targets when the source
# file was produced by a build script (tsc emits 0o644). This job catches
# regressions where sdk/dist/cli.js loses its execute bit before publish.
# ---------------------------------------------------------------------------
smoke-unpacked:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
# See the `smoke` job above for rationale — refs/pull/N/merge is cached
# against the recorded merge-base, not current main. Explicitly merge
# origin/main so smoke-unpacked also runs against the latest trunk.
- name: Rebase check — merge origin/main into PR head
if: github.event_name == 'pull_request'
shell: bash
run: |
set -euo pipefail
git config user.email "ci@gsd-build"
git config user.name "CI Rebase Check"
git fetch origin main
if ! git merge --no-edit --no-ff origin/main; then
echo "::error::This PR cannot cleanly merge origin/main. Rebase your branch onto current main and push again."
echo "::error::Conflicting files:"
git diff --name-only --diff-filter=U
git merge --abort
exit 1
fi
- name: Set up Node.js 22
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: 'npm'
- name: Install root deps
run: npm ci
- name: Build SDK dist (sdk/dist is gitignored — must build for unpacked install)
run: npm run build:sdk
- name: Ensure npm global bin is on PATH
shell: bash
run: |
NPM_BIN="$(npm config get prefix)/bin"
echo "$NPM_BIN" >> "$GITHUB_PATH"
echo "npm global bin: $NPM_BIN"
- name: Strip execute bit from sdk/dist/cli.js to simulate tsc-fresh output
shell: bash
run: |
set -euo pipefail
# Simulate the exact state tsc produces: cli.js at mode 644.
chmod 644 sdk/dist/cli.js
echo "Stripped execute bit: $(stat -c '%a' sdk/dist/cli.js 2>/dev/null || stat -f '%p' sdk/dist/cli.js)"
- name: Install from unpacked directory (no npm pack)
shell: bash
run: |
set -euo pipefail
TMPDIR_ROOT=$(mktemp -d)
cd "$TMPDIR_ROOT"
npm install -g "$GITHUB_WORKSPACE"
command -v get-shit-done-cc
get-shit-done-cc --claude --local || true
- name: Assert gsd-sdk resolves on PATH after unpacked install
shell: bash
run: |
set -euo pipefail
if ! command -v gsd-sdk >/dev/null 2>&1; then
echo "::error::gsd-sdk is not on PATH after unpacked install — #2453 regression"
NPM_BIN="$(npm config get prefix)/bin"
ls -la "$NPM_BIN" | grep -i gsd || true
exit 1
fi
echo "✓ gsd-sdk resolves at: $(command -v gsd-sdk)"
- name: Assert gsd-sdk is executable after unpacked install (#2453)
shell: bash
run: |
set -euo pipefail
# This is the exact check that would have caught #2453 before release.
# The shim (bin/gsd-sdk.js) invokes sdk/dist/cli.js via `node`, so
# the execute bit on cli.js is not needed for the shim path. However
# installSdkIfNeeded() also chmods cli.js in-place as a safety net.
gsd-sdk --version || gsd-sdk --help
echo "✓ gsd-sdk is executable after unpacked install"

View File

@@ -189,8 +189,11 @@ jobs:
git add package.json package-lock.json sdk/package.json
git commit -m "chore: bump to ${PRE_VERSION}"
- name: Build SDK
run: cd sdk && npm ci && npm run build
- name: Build SDK dist for tarball
run: npm run build:sdk
- name: Verify tarball ships sdk/dist/cli.js (bug #2647)
run: bash scripts/verify-tarball-sdk-dist.sh
- name: Dry-run publish validation
run: |
@@ -330,8 +333,11 @@ jobs:
npm ci
npm run test:coverage
- name: Build SDK
run: cd sdk && npm ci && npm run build
- name: Build SDK dist for tarball
run: npm run build:sdk
- name: Verify tarball ships sdk/dist/cli.js (bug #2647)
run: bash scripts/verify-tarball-sdk-dist.sh
- name: Dry-run publish validation
run: |

View File

@@ -35,6 +35,31 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Fetch full history so we can merge origin/main for stale-base detection.
fetch-depth: 0
# GitHub's `refs/pull/N/merge` is cached against the recorded merge-base.
# When main advances after a PR is opened, the cache stays stale and CI
# runs against the pre-advance state — hiding bugs that are already fixed
# on trunk and surfacing type errors that were introduced and then patched
# on main in between. Explicitly merge current origin/main here so tests
# always run against the latest trunk.
- name: Rebase check — merge origin/main into PR head
if: github.event_name == 'pull_request'
shell: bash
run: |
set -euo pipefail
git config user.email "ci@gsd-build"
git config user.name "CI Rebase Check"
git fetch origin main
if ! git merge --no-edit --no-ff origin/main; then
echo "::error::This PR cannot cleanly merge origin/main. Rebase your branch onto current main and push again."
echo "::error::Conflicting files:"
git diff --name-only --diff-filter=U
git merge --abort
exit 1
fi
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -45,6 +70,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build SDK dist (required by installer)
run: npm run build:sdk
- name: Run tests with coverage
shell: bash
run: npm run test:coverage

View File

@@ -28,8 +28,17 @@ If you use GSD **as a workflow**—milestones, phases, `.planning/` artifacts, b
### Fixed
- **End-of-phase routing suggestions now use `/gsd-<cmd>` (not the retired `/gsd:<cmd>`)** — All user-visible command suggestions in workflows (`execute-phase.md`, `transition.md`), tool output (`profile-output.cjs`, `init.cjs`), references, and templates have been updated from `/gsd:<cmd>` to `/gsd-<cmd>`, matching the Claude Code skill directory name and the user-typed slash-command format. Internal `Skill(skill="gsd:<cmd>")` calls (no leading slash) are preserved unchanged — those resolve by frontmatter `name:` not directory name. The namespace test (`bug-2543-gsd-slash-namespace.test.cjs`) has been updated to enforce the current invariant. Closes #2697.
- **`gsd-sdk query` now resolves parent `.planning/` root in multi-repo (`sub_repos`) workspaces** — when invoked from inside a `sub_repos`-listed child repo (e.g. `workspace/app/`), the SDK now walks up to the parent workspace that owns `.planning/`, matching the legacy `gsd-tools.cjs` `findProjectRoot` behavior. Previously `gsd-sdk query init.new-milestone` reported `project_exists: false` from the sub-repo, while `gsd-tools.cjs` resolved the parent root correctly. Resolution happens once in `cli.ts` before dispatch; if `projectDir` already owns `.planning/` (including explicit `--project-dir`), the walk is a no-op. Ported as `findProjectRoot` in `sdk/src/query/helpers.ts` with the same detection order (own `.planning/` wins, then parent `sub_repos` match, then legacy `multiRepo: true`, then `.git` heuristic), capped at 10 parent levels and never crossing `$HOME`. Closes #2623.
- **Shell hooks falsely flagged as stale on every session** — `gsd-phase-boundary.sh`, `gsd-session-state.sh`, and `gsd-validate-commit.sh` now ship with a `# gsd-hook-version: {{GSD_VERSION}}` header; the installer substitutes `{{GSD_VERSION}}` in `.sh` hooks the same way it does for `.js` hooks; and the stale-hook detector in `gsd-check-update.js` now matches bash `#` comment syntax in addition to JS `//` syntax. All three changes are required together — neither the regex fix alone nor the install fix alone is sufficient to resolve the false positive (#2136, #2206, #2209, #2210, #2212)
## [1.38.2] - 2026-04-19
### Fixed
- **SDK decoupled from build-from-source install** — replaces the fragile `tsc` + `npm install -g ./sdk` dance on user machines with a prebuilt `sdk/dist/` shipped inside the parent `get-shit-done-cc` tarball. The `gsd-sdk` CLI is now a `bin/gsd-sdk.js` shim in the parent package that resolves `sdk/dist/cli.js` and invokes it via `node`, so npm chmods the bin entry from the tarball (not from a secondary local install) and PATH/exec-bit issues cannot occur. Repurposes `installSdkIfNeeded()` in `bin/install.js` to only verify `sdk/dist/cli.js` exists and fix its execute bit (non-fatal); deletes `resolveGsdSdk()`, `detectShellRc()`, `emitSdkFatal()` and the source-build/global-install logic (162 lines removed). `release.yml` now runs `npm run build:sdk` before publish in both rc and finalize jobs, so every published tarball contains fresh SDK dist. `sdk/package.json` `prepublishOnly` is the final safety net (`rm -rf dist && tsc && chmod +x dist/cli.js`). `install-smoke.yml` adds an `smoke-unpacked` variant that installs from the unpacked dir with the exec bit stripped, so this class of regression cannot ship again. Closes #2441 and #2453.
- **`--sdk` flag semantics changed** — previously forced a rebuild of the SDK from source; now verifies the bundled `sdk/dist/` is resolvable. Users who were invoking `get-shit-done-cc --sdk` as a "force rebuild" no longer need it — the SDK ships prebuilt.
### Added
- **`/gsd-ingest-docs` command** — Scan a repo containing mixed ADRs, PRDs, SPECs, and DOCs and bootstrap or merge the full `.planning/` setup from them in a single pass. Parallel classification (`gsd-doc-classifier`), synthesis with precedence rules and cycle detection (`gsd-doc-synthesizer`), three-bucket conflicts report (`INGEST-CONFLICTS.md`: auto-resolved, competing-variants, unresolved-blockers), and hard-block on LOCKED-vs-LOCKED ADR contradictions in both new and merge modes. Supports directory-convention discovery and `--manifest <file>` YAML override with per-doc precedence. v1 caps at 50 docs per invocation; `--resolve interactive` is reserved. Extracts shared conflict-detection contract into `references/doc-conflict-engine.md` which `/gsd-import` now also consumes (#2387)
- **`/gsd-plan-review-convergence` command** — Cross-AI plan convergence loop that automates `plan-phase → review → replan → re-review` cycles. Spawns isolated agents for `gsd-plan-phase` and `gsd-review`; orchestrator only does loop control, HIGH concern counting, stall detection, and escalation. Supports `--codex`, `--gemini`, `--claude`, `--opencode`, `--all` reviewers and `--max-cycles N` (default 3). Loop exits when no HIGH concerns remain; stall detection warns when count isn't decreasing; escalation gate asks user to proceed or review manually when max cycles reached (#2306)
@@ -2368,7 +2377,8 @@ Technical implementation details for Phase 2 appear in the **Changed** section b
- YOLO mode for autonomous execution
- Interactive mode with checkpoints
[Unreleased]: https://github.com/gsd-build/get-shit-done/compare/v1.37.1...HEAD
[Unreleased]: https://github.com/gsd-build/get-shit-done/compare/v1.38.2...HEAD
[1.38.2]: https://github.com/gsd-build/get-shit-done/compare/v1.37.1...v1.38.2
[1.37.1]: https://github.com/gsd-build/get-shit-done/compare/v1.37.0...v1.37.1
[1.37.0]: https://github.com/gsd-build/get-shit-done/compare/v1.36.0...v1.37.0
[1.36.0]: https://github.com/gsd-build/get-shit-done/releases/tag/v1.36.0

View File

@@ -314,6 +314,15 @@ bin/install.js — Installer (multi-runtime)
get-shit-done/
bin/lib/ — Core library modules (.cjs)
workflows/ — Workflow definitions (.md)
Large workflows split per progressive-disclosure
pattern: workflows/<name>/modes/*.md +
workflows/<name>/templates/*. Parent dispatches
to mode files. See workflows/discuss-phase/ as
the canonical example (#2551). New modes for
discuss-phase land in
workflows/discuss-phase/modes/<mode>.md.
Per-file budgets enforced by
tests/workflow-size-budget.test.cjs.
references/ — Reference documentation (.md)
templates/ — File templates
agents/ — Agent definitions (.md) — CANONICAL SOURCE

View File

@@ -94,6 +94,19 @@ Based on focus, determine which documents you'll write:
- `arch` → ARCHITECTURE.md, STRUCTURE.md
- `quality` → CONVENTIONS.md, TESTING.md
- `concerns` → CONCERNS.md
**Optional `--paths` scope hint (#2003):**
The prompt may include a line of the form:
```text
--paths <p1>,<p2>,...
```
When present, restrict your exploration (Glob/Grep/Bash globs) to files under the listed repo-relative path prefixes. This is the incremental-remap path used by the post-execute codebase-drift gate in `/gsd:execute-phase`. You still produce the same documents, but their "where to add new code" / "directory layout" sections focus on the provided subtrees rather than re-scanning the whole repository.
**Path validation:** Reject any `--paths` value containing `..`, starting with `/`, or containing shell metacharacters (`;`, `` ` ``, `$`, `&`, `|`, `<`, `>`). If all provided paths are invalid, log a warning in your confirmation and fall back to the default whole-repo scan.
If no `--paths` hint is provided, behave exactly as before.
</step>
<step name="explore_codebase">

View File

@@ -145,7 +145,7 @@ When researching "best library for X": find what the ecosystem actually uses, do
1. `mcp__context7__resolve-library-id` with libraryName
2. `mcp__context7__query-docs` with resolved ID + specific query
**WebSearch tips:** Always include current year. Use multiple query variations. Cross-verify with authoritative sources.
**WebSearch tips:** Use multiple query variations. Cross-verify with authoritative sources. Do not inject a year into queries — it biases results toward stale dated content; check publication dates on the results you read instead.
## Enhanced Web Search (Brave API)
@@ -836,6 +836,6 @@ Quality indicators:
- **Verified, not assumed:** Findings cite Context7 or official docs
- **Honest about gaps:** LOW confidence items flagged, unknowns admitted
- **Actionable:** Planner could create tasks based on this research
- **Current:** Year included in searches, publication dates checked
- **Current:** Publication dates checked on sources (do not inject year into queries)
</success_criteria>

View File

@@ -116,12 +116,12 @@ For finding what exists, community patterns, real-world usage.
**Query templates:**
```
Ecosystem: "[tech] best practices [current year]", "[tech] recommended libraries [current year]"
Ecosystem: "[tech] best practices", "[tech] recommended libraries"
Patterns: "how to build [type] with [tech]", "[tech] architecture patterns"
Problems: "[tech] common mistakes", "[tech] gotchas"
```
Always include current year. Use multiple query variations. Mark WebSearch-only findings as LOW confidence.
Use multiple query variations. Mark WebSearch-only findings as LOW confidence. Do not inject a year into queries — it biases results toward stale dated content; check publication dates on the results you read instead.
### Enhanced Web Search (Brave API)
@@ -672,6 +672,6 @@ Research is complete when:
- [ ] Files written (DO NOT commit — orchestrator handles this)
- [ ] Structured return provided to orchestrator
**Quality:** Comprehensive not shallow. Opinionated not wishy-washy. Verified not assumed. Honest about gaps. Actionable for roadmap. Current (year in searches).
**Quality:** Comprehensive not shallow. Opinionated not wishy-washy. Verified not assumed. Honest about gaps. Actionable for roadmap. Current (check publication dates, do not inject year into queries).
</success_criteria>

32
bin/gsd-sdk.js Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* bin/gsd-sdk.js — back-compat shim for external callers of `gsd-sdk`.
*
* When the parent package is installed globally (`npm install -g get-shit-done-cc`
* or `npx get-shit-done-cc`), npm creates a `gsd-sdk` symlink in the global bin
* directory pointing at this file. npm correctly chmods bin entries from a tarball,
* so the execute-bit problem that afflicted the sub-install approach (issue #2453)
* cannot occur here.
*
* This shim resolves sdk/dist/cli.js relative to its own location and delegates
* to it via `node`, so `gsd-sdk <args>` behaves identically to
* `node <packageDir>/sdk/dist/cli.js <args>`.
*
* Call sites (slash commands, agent prompts, hook scripts) continue to work without
* changes because `gsd-sdk` still resolves on PATH — it just comes from this shim
* in the parent package rather than from a separately installed @gsd-build/sdk.
*/
'use strict';
const path = require('path');
const { spawnSync } = require('child_process');
const cliPath = path.resolve(__dirname, '..', 'sdk', 'dist', 'cli.js');
const result = spawnSync(process.execPath, [cliPath, ...process.argv.slice(2)], {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status ?? 1);

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ description: Insert urgent work as decimal phase (e.g., 72.1) between existing p
argument-hint: <after> <description>
allowed-tools:
- Read
- Write
- Bash
---

View File

@@ -71,7 +71,7 @@ For each directory found:
- Check if PLAN.md exists
- Check if SUMMARY.md exists; if so, read `status` from its frontmatter via:
```bash
gsd-sdk query frontmatter.get .planning/quick/{dir}/SUMMARY.md status 2>/dev/null
gsd-sdk query frontmatter.get .planning/quick/{dir}/SUMMARY.md status
```
- Determine directory creation date: `stat -f "%SB" -t "%Y-%m-%d"` (macOS) or `stat -c "%w"` (Linux); fall back to the date prefix in the directory name (format: `YYYYMMDD-` prefix)
- Derive display status:

View File

@@ -0,0 +1,39 @@
---
name: gsd:settings-advanced
description: Power-user configuration — plan bounce, timeouts, branch templates, cross-AI execution, runtime knobs
allowed-tools:
- Read
- Write
- Bash
- AskUserQuestion
---
<objective>
Interactive configuration of GSD power-user knobs that don't belong in the common-case `/gsd-settings` prompt.
Routes to the settings-advanced workflow which handles:
- Config existence ensuring (workstream-aware path resolution)
- Current settings reading and parsing
- Sectioned prompts: Planning Tuning, Execution Tuning, Discussion Tuning, Cross-AI Execution, Git Customization, Runtime / Output
- Config merging that preserves every unrelated key
- Confirmation table display
Use `/gsd-settings` for the common-case toggles (model profile, research/plan_check/verifier, branching strategy, context warnings). Use `/gsd-settings-advanced` once those are set and you want to tune the internals.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/settings-advanced.md
</execution_context>
<process>
**Follow the settings-advanced workflow** from `@~/.claude/get-shit-done/workflows/settings-advanced.md`.
The workflow handles all logic including:
1. Config file creation with defaults if missing (via `gsd-sdk query config-ensure-section`)
2. Current config reading
3. Six sectioned AskUserQuestion batches with current values pre-selected
4. Numeric-input validation (non-numeric rejected, empty input keeps current)
5. Answer parsing and config merging (preserves unrelated keys)
6. File writing (atomic)
7. Confirmation table display
</process>

View File

@@ -0,0 +1,44 @@
---
name: gsd:settings-integrations
description: Configure third-party API keys, code-review CLI routing, and agent-skill injection
allowed-tools:
- Read
- Write
- Bash
- AskUserQuestion
---
<objective>
Interactive configuration of GSD's third-party integration surface:
- Search API keys: `brave_search`, `firecrawl`, `exa_search`, and
the `search_gitignored` toggle
- Code-review CLI routing: `review.models.{claude,codex,gemini,opencode}`
- Agent-skill injection: `agent_skills.<agent-type>`
API keys are stored plaintext in `.planning/config.json` but are masked
(`****<last-4>`) in every piece of interactive output. The workflow never
echoes plaintext to stdout, stderr, or any log.
This command is deliberately distinct from `/gsd-settings` (workflow toggles)
and any `/gsd-settings-advanced` tuning surface. It handles *connectivity*,
not pipeline shape.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/settings-integrations.md
</execution_context>
<process>
**Follow the settings-integrations workflow** from
`@~/.claude/get-shit-done/workflows/settings-integrations.md`.
The workflow handles:
1. Resolving `$GSD_CONFIG_PATH` (flat vs workstream)
2. Reading current integration values (masked for display)
3. Section 1 — Search Integrations: Brave / Firecrawl / Exa / search_gitignored
4. Section 2 — Review CLI Routing: review.models.{claude,codex,gemini,opencode}
5. Section 3 — Agent Skills Injection: agent_skills.<agent-type>
6. Writing values via `gsd-sdk query config-set` (which merges, preserving
unrelated keys)
7. Masked confirmation display
</process>

View File

@@ -38,7 +38,7 @@ ls .planning/threads/*.md 2>/dev/null
For each thread file found:
- Read frontmatter `status` field via:
```bash
gsd-sdk query frontmatter.get .planning/threads/{file} status 2>/dev/null
gsd-sdk query frontmatter.get .planning/threads/{file} status
```
- If frontmatter `status` field is missing, fall back to reading markdown heading `## Status: OPEN` (or IN PROGRESS / RESOLVED) from the file body
- Read frontmatter `updated` field for the last-updated date

View File

@@ -343,18 +343,26 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp
| Property | Value |
|----------|-------|
| **Spawned by** | `/gsd-map-codebase` |
| **Spawned by** | `/gsd-map-codebase`, post-execute drift gate in `/gsd:execute-phase` |
| **Parallelism** | 4 instances (tech, architecture, quality, concerns) |
| **Tools** | Read, Bash, Grep, Glob, Write |
| **Model (balanced)** | Haiku |
| **Color** | Cyan |
| **Produces** | `.planning/codebase/*.md` (7 documents) |
| **Produces** | `.planning/codebase/*.md` (7 documents, with `last_mapped_commit` frontmatter) |
**Key behaviors:**
- Read-only exploration + structured output
- Writes documents directly to disk
- No reasoning required — pattern extraction from file contents
**`--paths <p1,p2,...>` scope hint (#2003):**
Accepts an optional `--paths` directive in its prompt. When present, the
mapper restricts Glob/Grep/Bash exploration to the listed repo-relative path
prefixes — this is the incremental-remap path used by the post-execute
codebase-drift gate. Path values that contain `..`, start with `/`, or
include shell metacharacters are rejected. Without the hint, the mapper
runs its default whole-repo scan.
---
### gsd-debugger

View File

@@ -131,6 +131,33 @@ Orchestration logic that commands reference. Contains the step-by-step process i
**Total workflows:** see [`docs/INVENTORY.md`](INVENTORY.md#workflows) for the authoritative count and full roster.
#### Progressive disclosure for workflows
Workflow files are loaded verbatim into Claude's context every time the
corresponding `/gsd:*` command is invoked. To keep that cost bounded, the
workflow size budget enforced by `tests/workflow-size-budget.test.cjs`
mirrors the agent budget from #2361:
| Tier | Per-file line limit |
|-----------|--------------------|
| `XL` | 1700 — top-level orchestrators (`execute-phase`, `plan-phase`, `new-project`) |
| `LARGE` | 1500 — multi-step planners and large feature workflows |
| `DEFAULT` | 1000 — focused single-purpose workflows (the target tier) |
`workflows/discuss-phase.md` is held to a stricter <500-line ceiling per
issue #2551. When a workflow grows beyond its tier, extract per-mode bodies
into `workflows/<workflow>/modes/<mode>.md`, templates into
`workflows/<workflow>/templates/`, and shared knowledge into
`get-shit-done/references/`. The parent file becomes a thin dispatcher that
Reads only the mode and template files needed for the current invocation.
`workflows/discuss-phase/` is the canonical example of this pattern —
parent dispatches, modes/ holds per-flag behavior (`power.md`, `all.md`,
`auto.md`, `chain.md`, `text.md`, `batch.md`, `analyze.md`, `default.md`,
`advisor.md`), and templates/ holds CONTEXT.md, DISCUSSION-LOG.md, and
checkpoint.json schemas that are read only when the corresponding output
file is being written.
### Agents (`agents/*.md`)
Specialized agent definitions with frontmatter specifying:
@@ -384,7 +411,9 @@ plan-phase
├── Research gate (blocks if RESEARCH.md has unresolved open questions)
├── Phase Researcher → RESEARCH.md
├── Planner (with reachability check) → PLAN.md files
── Plan Checker → Verify loop (max 3x)
── Plan Checker → Verify loop (max 3x)
├── Requirements coverage gate (REQ-IDs → plans)
└── Decision coverage gate (CONTEXT.md `<decisions>` → plans, BLOCKING — #2492)
state planned-phase → STATE.md (Planned/Ready to execute)
@@ -395,6 +424,7 @@ execute-phase (context reduction: truncated prompts, cache-friendly ordering)
├── Executor per plan → code + atomic commits
├── SUMMARY.md per plan
└── Verifier → VERIFICATION.md
└── Decision coverage gate (CONTEXT.md decisions → shipped artifacts, NON-BLOCKING — #2492)
verify-work → UAT.md (user acceptance testing)
@@ -467,8 +497,8 @@ Equivalent paths for other runtimes:
│ ├── ARCHITECTURE.md
│ └── PITFALLS.md
├── codebase/ # Brownfield mapping (from /gsd-map-codebase)
│ ├── STACK.md
│ ├── ARCHITECTURE.md
│ ├── STACK.md # YAML frontmatter carries `last_mapped_commit`
│ ├── ARCHITECTURE.md # for the post-execute drift gate (#2003)
│ ├── CONVENTIONS.md
│ ├── CONCERNS.md
│ ├── STRUCTURE.md
@@ -502,6 +532,30 @@ Equivalent paths for other runtimes:
└── continue-here.md # Context handoff (from pause-work)
```
### Post-Execute Codebase Drift Gate (#2003)
After the last wave of `/gsd:execute-phase` commits, the workflow runs a
non-blocking `codebase_drift_gate` step (between `schema_drift_gate` and
`verify_phase_goal`). It compares the diff `last_mapped_commit..HEAD`
against `.planning/codebase/STRUCTURE.md` and counts four kinds of
structural elements:
1. New directories outside mapped paths
2. New barrel exports at `(packages|apps)/<name>/src/index.*`
3. New migration files
4. New route modules under `routes/` or `api/`
If the count meets `workflow.drift_threshold` (default 3), the gate either
**warns** (default) with the suggested `/gsd:map-codebase --paths …` command,
or **auto-remaps** (`workflow.drift_action = auto-remap`) by spawning
`gsd-codebase-mapper` scoped to the affected paths. Any error in detection
or remap is logged and the phase continues — drift detection cannot fail
verification.
`last_mapped_commit` lives in YAML frontmatter at the top of each
`.planning/codebase/*.md` file; `bin/lib/drift.cjs` provides
`readMappedCommit` and `writeMappedCommit` round-trip helpers.
---
## Installer Architecture

View File

@@ -475,6 +475,25 @@ User-facing entry point: `/gsd-graphify` (see [Command Reference](COMMANDS.md#gs
---
## Reviewer CLI Routing
`review.models.<cli>` maps a reviewer flavor to a shell command invoked by the code-review workflow. Set via [`/gsd-settings-integrations`](COMMANDS.md#gsd-settings-integrations) or directly:
```bash
gsd-sdk query config-set review.models.codex "codex exec --model gpt-5"
gsd-sdk query config-set review.models.gemini "gemini -m gemini-2.5-pro"
gsd-sdk query config-set review.models.opencode "opencode run --model claude-sonnet-4"
gsd-sdk query config-set review.models.claude "" # clear — fall back to session model
```
Slugs are validated against `[a-zA-Z0-9_-]+`; empty or path-containing slugs are rejected. See [`docs/CONFIGURATION.md`](CONFIGURATION.md#code-review-cli-routing) for the full field reference.
## Secret Handling
API keys configured via `/gsd-settings-integrations` (`brave_search`, `firecrawl`, `exa_search`) are written plaintext to `.planning/config.json` but are masked (`****<last-4>`) in every `config-set` / `config-get` output, confirmation table, and interactive prompt. See `get-shit-done/bin/lib/secrets.cjs` for the masking implementation. The `config.json` file itself is the security boundary — protect it with filesystem permissions and keep it out of git (`.planning/` is gitignored by default).
---
## See also
- [sdk/src/query/QUERY-HANDLERS.md](../sdk/src/query/QUERY-HANDLERS.md) — registry matrix, routing, golden parity, intentional CJS differences

View File

@@ -562,6 +562,24 @@ Interactive command center for managing multiple phases from one terminal.
/gsd-manager # Open command center dashboard
```
**Checkpoint Heartbeats (#2410):**
Background `execute-phase` runs emit `[checkpoint]` markers at every wave and plan
boundary so the Claude API SSE stream never idles long enough to trigger
`Stream idle timeout - partial response received` on multi-plan phases. The
format is:
```
[checkpoint] phase {N} wave {W}/{M} starting, {count} plan(s), {P}/{Q} plans done
[checkpoint] phase {N} wave {W}/{M} plan {plan_id} starting ({P}/{Q} plans done)
[checkpoint] phase {N} wave {W}/{M} plan {plan_id} complete ({P}/{Q} plans done)
[checkpoint] phase {N} wave {W}/{M} complete, {P}/{Q} plans done ({ok}/{count} ok)
```
If a background phase fails partway through, grep the transcript for `[checkpoint]`
to see the last confirmed boundary. The manager's background-completion handler
uses these markers to report partial progress when an agent errors out.
**Manager Passthrough Flags:**
Configure per-step flags in `.planning/config.json` under `manager.flags`. These flags are appended to each dispatched command:
@@ -1037,12 +1055,73 @@ Manage parallel workstreams for concurrent work on different milestone areas.
### `/gsd-settings`
Interactive configuration of workflow toggles and model profile.
Interactive configuration of workflow toggles and model profile. Questions are grouped into six visual sections:
- **Planning** — Research, Plan Checker, Pattern Mapper, Nyquist, UI Phase, UI Gate, AI Phase
- **Execution** — Verifier, TDD Mode, Code Review, Code Review Depth _(conditional — only when Code Review is on)_, UI Review
- **Docs & Output** — Commit Docs, Skip Discuss, Worktrees
- **Features** — Intel, Graphify
- **Model & Pipeline** — Model Profile, Auto-Advance, Branching
- **Misc** — Context Warnings, Research Qs
All answers are merged via `gsd-sdk query config-set` into the resolved project config path (`.planning/config.json` for a standard install, or `.planning/workstreams/<active>/config.json` when a workstream is active), preserving unrelated keys. After confirmation, the user may save the full settings object to `~/.gsd/defaults.json` so future `/gsd-new-project` runs start from the same baseline.
```bash
/gsd-settings # Interactive config
```
### `/gsd-settings-advanced`
Interactive configuration of power-user knobs — plan bounce, subagent timeouts, branch templates, cross-AI delegation, context window, and runtime output. Use after `/gsd-settings` once the common-case toggles are dialed in.
Six sections, each a focused prompt batch:
| Section | Keys |
|---------|------|
| Planning Tuning | `workflow.plan_bounce`, `workflow.plan_bounce_passes`, `workflow.plan_bounce_script`, `workflow.subagent_timeout`, `workflow.inline_plan_threshold` |
| Execution Tuning | `workflow.node_repair`, `workflow.node_repair_budget`, `workflow.auto_prune_state` |
| Discussion Tuning | `workflow.max_discuss_passes` |
| Cross-AI Execution | `workflow.cross_ai_execution`, `workflow.cross_ai_command`, `workflow.cross_ai_timeout` |
| Git Customization | `git.base_branch`, `git.phase_branch_template`, `git.milestone_branch_template` |
| Runtime / Output | `response_language`, `context_window`, `search_gitignored`, `graphify.build_timeout` |
Current values are pre-selected; an empty input keeps the existing value. Numeric fields reject non-numeric input and re-prompt. Null-allowed fields (`plan_bounce_script`, `cross_ai_command`, `response_language`) accept an empty input as a clear. Writes route through `gsd-sdk query config-set`, which preserves every unrelated key.
```bash
/gsd-settings-advanced # Six-section interactive config
```
See [CONFIGURATION.md](CONFIGURATION.md) for the full schema and defaults.
### `/gsd-settings-integrations`
Interactive configuration of third-party integrations and cross-tool routing.
Distinct from `/gsd-settings` (workflow toggles) — this command handles
connectivity: API keys, reviewer CLI routing, and agent-skill injection.
Covers:
- **Search integrations:** `brave_search`, `firecrawl`, `exa_search` API keys,
and the `search_gitignored` toggle.
- **Code-review CLI routing:** `review.models.{claude,codex,gemini,opencode}`
— a shell command per reviewer flavor.
- **Agent-skill injection:** `agent_skills.<agent-type>` — skill names
injected into an agent's spawn frontmatter. Agent-type slugs are validated
against `[a-zA-Z0-9_-]+` so path separators and shell metacharacters are
rejected.
API keys are stored plaintext in `.planning/config.json` but displayed masked
(`****<last-4>`) in every interactive output, confirmation table, and
`config-set` stdout/stderr line. Plaintext is never echoed, never logged,
and never written to any file outside `config.json` by this workflow.
```bash
/gsd-settings-integrations # Interactive config (three sections)
```
See [`docs/CONFIGURATION.md`](CONFIGURATION.md) for the per-field reference and
[`docs/CLI-TOOLS.md`](CLI-TOOLS.md) for the reviewer-CLI routing contract.
### `/gsd-set-profile`
Quick profile switch.

View File

@@ -30,10 +30,12 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
"nyquist_validation": true,
"ui_phase": true,
"ui_safety_gate": true,
"ui_review": true,
"node_repair": true,
"node_repair_budget": 2,
"research_before_questions": false,
"discuss_mode": "discuss",
"max_discuss_passes": 3,
"skip_discuss": false,
"tdd_mode": false,
"text_mode": false,
@@ -50,7 +52,8 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
"cross_ai_timeout": 300,
"security_enforcement": true,
"security_asvs_level": 1,
"security_block_on": "high"
"security_block_on": "high",
"post_planning_gaps": true
},
"hooks": {
"context_warnings": true,
@@ -109,9 +112,12 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
|---------|------|---------|---------|-------------|
| `mode` | enum | `interactive`, `yolo` | `interactive` | `yolo` auto-approves decisions; `interactive` confirms at each step |
| `granularity` | enum | `coarse`, `standard`, `fine` | `standard` | Controls phase count: `coarse` (3-5), `standard` (5-8), `fine` (8-12) |
| `model_profile` | enum | `quality`, `balanced`, `budget`, `inherit` | `balanced` | Model tier for each agent (see [Model Profiles](#model-profiles)) |
| `model_profile` | enum | `quality`, `balanced`, `budget`, `adaptive`, `inherit` | `balanced` | Model tier for each agent (see [Model Profiles](#model-profiles)). `adaptive` was added per [#1713](https://github.com/gsd-build/get-shit-done/issues/1713) / [#1806](https://github.com/gsd-build/get-shit-done/issues/1806) and resolves the same way as the other tiers under runtime-aware profiles. |
| `runtime` | string | `claude`, `codex`, or any string | (none) | Active runtime for [runtime-aware profile resolution](#runtime-aware-profiles-2517). When set, profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs. Today only the Codex install path emits per-agent model IDs from this resolver; other runtimes (`opencode`, `gemini`, `qwen`, `copilot`, …) consume the resolver at spawn time and gain dedicated install-path support in [#2612](https://github.com/gsd-build/get-shit-done/issues/2612). When unset (default), behavior is unchanged from prior versions. Added in v1.39 |
| `model_profile_overrides.<runtime>.<tier>` | string \| object | per-runtime tier override | (none) | Override the runtime-aware tier mapping for a specific `(runtime, tier)`. Tier is one of `opus`, `sonnet`, `haiku`. Value is either a model ID string (e.g. `"gpt-5-pro"`) or `{ model, reasoning_effort }`. See [Runtime-Aware Profiles](#runtime-aware-profiles-2517). Added in v1.39 |
| `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 |
| `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32 |
| `context_window` | number | any integer | `200000` | Context window size in tokens. Set `1000000` for 1M-context models (e.g., `claude-opus-4-7[1m]`). Values `>= 500000` enable adaptive context enrichment (full-body reads of prior SUMMARY.md, deeper anti-pattern reads). Configured via `/gsd-settings-advanced`. |
| `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 |
| `claude_md_path` | string | any file path | `./CLAUDE.md` | Custom output path for the generated CLAUDE.md file. Useful for monorepos or projects that need CLAUDE.md in a non-root location. Defaults to `./CLAUDE.md` at the project root. Added in v1.36 |
| `claude_md_assembly.mode` | enum | `embed`, `link` | `embed` | Controls how managed sections are written into CLAUDE.md. `embed` (default) inlines content between GSD markers. `link` writes `@.planning/<source-path>` instead — Claude Code expands the reference at runtime, reducing CLAUDE.md size by ~65% on typical projects. `link` only applies to sections that have a real source file; `workflow` and fallback sections always embed. Per-block overrides: `claude_md_assembly.blocks.<section>` (e.g. `claude_md_assembly.blocks.architecture: link`). Added in v1.38 |
@@ -126,6 +132,41 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
---
## Integration Settings
Configured interactively via [`/gsd-settings-integrations`](COMMANDS.md#gsd-settings-integrations). These are *connectivity* settings — API keys and cross-tool routing — and are intentionally kept separate from `/gsd-settings` (workflow toggles).
### Search API keys
API key fields accept a string value (the key itself). They can also be set to the sentinels `true`/`false`/`null` to override auto-detection from env vars / `~/.gsd/*_api_key` files (legacy behavior, see rows above).
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `brave_search` | string \| boolean \| null | `null` | Brave Search API key used for web research. Displayed as `****<last-4>` in all UI / `config-set` output; never echoed plaintext |
| `firecrawl` | string \| boolean \| null | `null` | Firecrawl API key for deep-crawl scraping. Masked in display |
| `exa_search` | string \| boolean \| null | `null` | Exa Search API key for semantic search. Masked in display |
**Masking convention (`get-shit-done/bin/lib/secrets.cjs`):** keys 8+ characters render as `****<last-4>`; shorter keys render as `****`; `null`/empty renders as `(unset)`. Plaintext is written as-is to `.planning/config.json` — that file is the security boundary — but the CLI, confirmation tables, logs, and `AskUserQuestion` descriptions never display the plaintext. This applies to the `config-set` command output itself: `config-set brave_search <key>` returns a JSON payload with the value masked.
### Code-review CLI routing
`review.models.<cli>` maps a reviewer flavor to a shell command. The code-review workflow shells out using this command when a matching flavor is requested.
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `review.models.claude` | string | (session model) | Command for Claude-flavored review. Defaults to the session model when unset |
| `review.models.codex` | string | `null` | Command for Codex review, e.g. `"codex exec --model gpt-5"` |
| `review.models.gemini` | string | `null` | Command for Gemini review, e.g. `"gemini -m gemini-2.5-pro"` |
| `review.models.opencode` | string | `null` | Command for OpenCode review, e.g. `"opencode run --model claude-sonnet-4"` |
The `<cli>` slug is validated against `[a-zA-Z0-9_-]+`. Empty or path-containing slugs are rejected by `config-set`.
### Agent-skill injection (dynamic)
`agent_skills.<agent-type>` extends the `agent_skills` map documented below. Slug is validated against `[a-zA-Z0-9_-]+` — no path separators, no whitespace, no shell metacharacters. Configured interactively via `/gsd-settings-integrations`.
---
## Workflow Toggles
All workflow toggles follow the **absent = enabled** pattern. If a key is missing from config, it defaults to `true`.
@@ -139,10 +180,12 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `workflow.nyquist_validation` | boolean | `true` | Test coverage mapping during plan-phase research |
| `workflow.ui_phase` | boolean | `true` | Generate UI design contracts for frontend phases |
| `workflow.ui_safety_gate` | boolean | `true` | Prompt to run /gsd-ui-phase for frontend phases during plan-phase |
| `workflow.ui_review` | boolean | `true` | Run visual quality audit (`/gsd-ui-review`) after phase execution in autonomous mode. When `false`, the UI audit step is skipped. |
| `workflow.node_repair` | boolean | `true` | Autonomous task repair on verification failure |
| `workflow.node_repair_budget` | number | `2` | Max repair attempts per failed task |
| `workflow.research_before_questions` | boolean | `false` | Run research before discussion questions instead of after |
| `workflow.discuss_mode` | string | `'discuss'` | Controls how `/gsd-discuss-phase` gathers context. `'discuss'` (default) asks questions one-by-one. `'assumptions'` reads the codebase first, generates structured assumptions with confidence levels, and only asks you to correct what's wrong. Added in v1.28 |
| `workflow.max_discuss_passes` | number | `3` | Maximum number of question rounds in discuss-phase before the workflow stops asking. Useful in headless/auto mode to prevent infinite discussion loops. |
| `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 |
| `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 |
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31 |
@@ -151,6 +194,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `workflow.plan_bounce` | boolean | `false` | Run external validation script against generated plans. When enabled, the plan-phase orchestrator pipes each PLAN.md through the script specified by `plan_bounce_script` and blocks on non-zero exit. Added in v1.36 |
| `workflow.plan_bounce_script` | string | (none) | Path to the external script invoked for plan bounce validation. Receives the PLAN.md path as its first argument. Required when `plan_bounce` is `true`. Added in v1.36 |
| `workflow.plan_bounce_passes` | number | `2` | Number of sequential bounce passes to run. Each pass feeds the previous pass's output back into the validator. Higher values increase rigor at the cost of latency. Added in v1.36 |
| `workflow.post_planning_gaps` | boolean | `true` | Unified post-planning gap report (#2493). After all plans are generated and committed, scans REQUIREMENTS.md and CONTEXT.md `<decisions>` against every PLAN.md in the phase directory, then prints one `Source \| Item \| Status` table. Word-boundary matching (REQ-1 vs REQ-10) and natural sort (REQ-02 before REQ-10). Non-blocking — informational report only. Set to `false` to skip Step 13e of plan-phase. |
| `workflow.plan_chunked` | boolean | `false` | Enable chunked planning mode. When `true` (or when `--chunked` flag is passed to `/gsd-plan-phase`), the orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3-5 min each). Each plan is committed individually for crash resilience. If a Task hangs and the terminal is force-killed, rerunning with `--chunked` resumes from the last completed plan. Particularly useful on Windows where long-lived Tasks may hang on stdio. Added in v1.38 |
| `workflow.code_review_command` | string | (none) | Shell command for external code review integration in `/gsd-ship`. Receives changed file paths via stdin. Non-zero exit blocks the ship workflow. Added in v1.36 |
| `workflow.tdd_mode` | boolean | `false` | Enable TDD pipeline as a first-class execution mode. When `true`, the planner aggressively applies `type: tdd` to eligible tasks (business logic, APIs, validations, algorithms) and the executor enforces RED/GREEN/REFACTOR gate sequence. An end-of-phase collaborative review checkpoint verifies gate compliance. Added in v1.36 |
@@ -162,6 +206,8 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `workflow.pattern_mapper` | boolean | `true` | Run the `gsd-pattern-mapper` agent between research and planning to map new files to existing codebase analogs |
| `workflow.subagent_timeout` | number | `600` | Timeout in seconds for individual subagent invocations. Increase for long-running research or execution phases |
| `workflow.inline_plan_threshold` | number | `3` | Maximum number of tasks in a phase before the planner generates a separate PLAN.md file instead of inlining tasks in the prompt |
| `workflow.drift_threshold` | number | `3` | Minimum number of new structural elements (new directories, barrel exports, migrations, route modules) introduced during a phase before the post-execute codebase-drift gate takes action. See [#2003](https://github.com/gsd-build/get-shit-done/issues/2003). Added in v1.39 |
| `workflow.drift_action` | string | `warn` | What to do when `workflow.drift_threshold` is exceeded after `/gsd-execute-phase`. `warn` prints a message suggesting `/gsd-map-codebase --paths …`; `auto-remap` spawns `gsd-codebase-mapper` scoped to the affected paths. Added in v1.39 |
### Recommended Presets
@@ -181,6 +227,17 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `planning.search_gitignored` | boolean | `false` | Add `--no-ignore` to broad searches to include `.planning/` |
| `planning.sub_repos` | array of strings | `[]` | Paths of nested sub-repos relative to the project root. When set, GSD-aware tooling scopes phase-lookup, path-resolution, and commit operations per sub-repo instead of treating the outer repo as a monorepo |
### Project-Root Resolution in Multi-Repo Workspaces
When `sub_repos` is set and `gsd-tools.cjs` or `gsd-sdk query` is invoked from inside a listed child repo, both CLIs walk up to the parent workspace that owns `.planning/` before dispatching handlers. Resolution order (checked at each ancestor up to 10 levels, never above `$HOME`):
1. If the starting directory already has its own `.planning/`, it is the project root (no walk-up).
2. Parent has `.planning/config.json` listing the starting directory's top-level segment in `sub_repos` (or the legacy `planning.sub_repos` shape).
3. Parent has `.planning/config.json` with legacy `multiRepo: true` and the starting directory is inside a git repo.
4. Parent has `.planning/` and an ancestor up to the candidate parent contains `.git` (heuristic fallback).
If none match, the starting directory is returned unchanged. Explicit `--project-dir /path/to/workspace` is idempotent under this resolution.
### Auto-Detection
If `.planning/` is in `.gitignore`, `commit_docs` is automatically `false` regardless of config.json. This prevents git errors.
@@ -193,6 +250,7 @@ If `.planning/` is in `.gitignore`, `commit_docs` is automatically `false` regar
|---------|------|---------|-------------|
| `hooks.context_warnings` | boolean | `true` | Show context window usage warnings via context monitor hook |
| `hooks.workflow_guard` | boolean | `false` | Warn when file edits happen outside GSD workflow context (advises using `/gsd-quick` or `/gsd-fast`) |
| `statusline.show_last_command` | boolean | `false` | Append `last: /<cmd>` suffix to the statusline showing the most recently invoked slash command. Opt-in; reads the active session transcript to extract the latest `<command-name>` tag (closes #2538) |
The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and cannot be disabled — it's a security feature, not a workflow toggle.
@@ -407,6 +465,60 @@ These keys live under `workflow.*` — that is where the workflows and installer
---
## Decision Coverage Gates (`workflow.context_coverage_gate`)
When `discuss-phase` writes implementation decisions into CONTEXT.md
`<decisions>`, two gates ensure those decisions survive the trip into
plans and shipped code (issue #2492).
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `workflow.context_coverage_gate` | boolean | `true` | Toggle for both decision-coverage gates. When `false`, both the plan-phase translation gate and the verify-phase validation gate skip silently. |
### What the gates do
**Plan-phase translation gate (BLOCKING).** Runs immediately after the
existing requirements coverage gate, before plans are committed. For each
trackable decision in `<decisions>`, it checks that the decision id
(`D-NN`) or its text appears in at least one plan's `must_haves`,
`truths`, or body. A miss surfaces the missing decision by id and refuses
to mark the phase planned.
**Verify-phase validation gate (NON-BLOCKING).** Runs alongside the other
verify steps. Searches every shipped artifact (PLAN.md, SUMMARY.md, files
modified, recent commit subjects) for each trackable decision. Misses are
written to VERIFICATION.md as a warning section but do **not** flip the
overall verification status. The asymmetry is deliberate — by verify time
the work is done, and a fuzzy substring miss should not fail an otherwise
green phase.
### How to write decisions the gates accept
The discuss-phase template already produces `D-NN`-numbered decisions.
The gate is happiest when:
1. Every plan that implements a decision **cites the id** somewhere —
`must_haves.truths: ["D-12: bit offsets exposed"]` or a `D-12:` mention
in the plan body. Strict id match is the cheapest, deterministic path.
2. Soft phrase matching is a fallback for paraphrases — if a 6+-word slice
of the decision text appears verbatim in a plan/summary, it counts.
### Opt-outs
A decision is **not** subject to the gates when any of the following
apply:
- It lives under the `### Claude's Discretion` heading inside `<decisions>`.
- It is tagged `[informational]`, `[folded]`, or `[deferred]` in its
bullet (e.g., `- **D-08 [informational]:** Naming style for internal
helpers`).
Use these escape hatches when a decision genuinely doesn't need plan
coverage — implementation discretion, future ideas captured for the
record, or items already deferred to a later phase.
---
## Review Settings
Configure per-CLI model selection for `/gsd-review`. When set, overrides the CLI's default model for that reviewer.
@@ -508,6 +620,17 @@ Override specific agents without changing the entire profile:
Valid override values: `opus`, `sonnet`, `haiku`, `inherit`, or any fully-qualified model ID (e.g., `"openai/o3"`, `"google/gemini-2.5-pro"`).
`model_overrides` can be set in either `.planning/config.json` (per-project)
or `~/.gsd/defaults.json` (global). Per-project entries win on conflict and
non-conflicting global entries are preserved, so you can tune a single
agent's model in one repo without re-setting global defaults. This applies
uniformly across Claude Code, Codex, OpenCode, Kilo, and the other
supported runtimes. On Codex and OpenCode, the resolved model is embedded
into each agent's static config at install time — `spawn_agent` and
OpenCode's `task` interface do not accept an inline `model` parameter, so
running `gsd install <runtime>` after editing `model_overrides` is required
for the change to take effect. See issue #2256.
### Non-Claude Runtimes (Codex, OpenCode, Gemini CLI, Kilo)
When GSD is installed for a non-Claude runtime, the installer automatically sets `resolve_model_ids: "omit"` in `~/.gsd/defaults.json`. This causes GSD to return an empty model parameter for all agents, so each agent uses whatever model the runtime is configured with. No additional setup is needed for the default case.
@@ -545,6 +668,64 @@ The intent is the same as the Claude profile tiers -- use a stronger model for p
| `true` | Maps aliases to full Claude model IDs (`claude-opus-4-6`) | Claude Code with API that requires full IDs |
| `"omit"` | Returns empty string (runtime picks its default) | Non-Claude runtimes (Codex, OpenCode, Gemini CLI, Kilo) |
### Runtime-Aware Profiles (#2517)
When `runtime` is set, profile tiers (`opus`/`sonnet`/`haiku`) resolve to runtime-native model IDs instead of Claude aliases. This lets a single shared `.planning/config.json` work cleanly across Claude and Codex.
**Built-in tier maps:**
| Runtime | `opus` | `sonnet` | `haiku` | reasoning_effort |
|---------|--------|----------|---------|------------------|
| `claude` | `claude-opus-4-6` | `claude-sonnet-4-6` | `claude-haiku-4-5` | (not used) |
| `codex` | `gpt-5.4` | `gpt-5.3-codex` | `gpt-5.4-mini` | `xhigh` / `medium` / `medium` |
**Codex example** — one config, tiered models, no large `model_overrides` block:
```json
{
"runtime": "codex",
"model_profile": "balanced"
}
```
This resolves `gsd-planner``gpt-5.4` (xhigh), `gsd-executor``gpt-5.3-codex` (medium), `gsd-codebase-mapper``gpt-5.4-mini` (medium). The Codex installer embeds `model = "..."` and `model_reasoning_effort = "..."` in each generated agent TOML.
**Claude example** — explicit opt-in resolves to full Claude IDs (no `resolve_model_ids: true` needed):
```json
{
"runtime": "claude",
"model_profile": "quality"
}
```
**Per-runtime overrides** — replace one or more tier defaults:
```json
{
"runtime": "codex",
"model_profile": "quality",
"model_profile_overrides": {
"codex": {
"opus": "gpt-5-pro",
"haiku": { "model": "gpt-5-nano", "reasoning_effort": "low" }
}
}
}
```
**Precedence (highest to lowest):**
1. `model_overrides[<agent>]` — explicit per-agent ID always wins.
2. **Runtime-aware tier resolution** (this section) — when `runtime` is set and profile is not `inherit`.
3. `resolve_model_ids: "omit"` — returns empty string when no `runtime` is set.
4. Claude-native default — `model_profile` tier as alias (current default).
5. `inherit` — propagates literal `inherit` for `Task(model="inherit")` semantics.
**Backwards compatibility.** Setups without `runtime` set see zero behavior change — every existing config continues to work identically. Codex installs that auto-set `resolve_model_ids: "omit"` continue to omit the model field unless the user opts in by setting `runtime: "codex"`.
**Unknown runtimes.** If `runtime` is set to a value with no built-in tier map and no `model_profile_overrides[<runtime>]`, GSD falls back to the Claude-alias safe default rather than emit a model ID the runtime cannot accept. To support a new runtime, populate `model_profile_overrides.<runtime>.{opus,sonnet,haiku}` with valid IDs.
### Profile Philosophy
| Profile | Philosophy | When to Use |

View File

@@ -802,6 +802,45 @@
| `TESTING.md` | Test infrastructure, coverage, patterns |
| `INTEGRATIONS.md` | External services, APIs, third-party dependencies |
**Incremental remap — `--paths` (#2003):** The mapper accepts an optional
`--paths <p1,p2,...>` scope hint. When provided, it restricts exploration
to the listed repo-relative prefixes instead of scanning the whole tree.
This is the pathway used by the post-execute codebase-drift gate to refresh
only the subtrees the phase actually changed. Each produced document carries
`last_mapped_commit` in its YAML frontmatter so drift can be measured
against the mapping point, not HEAD.
### 27a. Post-Execute Codebase Drift Detection
**Introduced by:** #2003
**Trigger:** Runs automatically at the end of every `/gsd:execute-phase`
**Configuration:**
- `workflow.drift_threshold` (integer, default `3`) — minimum new
structural elements before the gate acts.
- `workflow.drift_action` (`warn` | `auto-remap`, default `warn`) —
warn-only or spawn `gsd-codebase-mapper` with `--paths` scoped to
affected subtrees.
**What counts as drift:**
- New directory outside mapped paths
- New barrel export at `(packages|apps)/*/src/index.*`
- New migration file (supabase/prisma/drizzle/src/migrations/…)
- New route module under `routes/` or `api/`
**Non-blocking guarantee:** any internal failure (missing STRUCTURE.md,
git errors, mapper spawn failure) logs a single line and the phase
continues. Drift detection cannot fail verification.
**Requirements:**
- REQ-DRIFT-01: System MUST detect the four drift categories from `git diff
--name-status last_mapped_commit..HEAD`
- REQ-DRIFT-02: Action fires only when element count ≥ `workflow.drift_threshold`
- REQ-DRIFT-03: `warn` action MUST NOT spawn any agent
- REQ-DRIFT-04: `auto-remap` action MUST pass sanitized `--paths` to the mapper
- REQ-DRIFT-05: Detection/remap failure MUST be non-blocking for `/gsd:execute-phase`
- REQ-DRIFT-06: `last_mapped_commit` round-trip through YAML frontmatter
on each `.planning/codebase/*.md` file
---
## Utility Features

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-04-20",
"generated": "2026-04-23",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -103,6 +103,8 @@
"/gsd-session-report",
"/gsd-set-profile",
"/gsd-settings",
"/gsd-settings-advanced",
"/gsd-settings-integrations",
"/gsd-ship",
"/gsd-sketch",
"/gsd-sketch-wrap-up",
@@ -110,11 +112,11 @@
"/gsd-spike",
"/gsd-spike-wrap-up",
"/gsd-stats",
"/gsd-sync-skills",
"/gsd-thread",
"/gsd-ui-phase",
"/gsd-ui-review",
"/gsd-ultraplan-phase",
"/gsd-sync-skills",
"/gsd-undo",
"/gsd-update",
"/gsd-validate-phase",
@@ -185,6 +187,8 @@
"scan.md",
"secure-phase.md",
"session-report.md",
"settings-advanced.md",
"settings-integrations.md",
"settings.md",
"ship.md",
"sketch-wrap-up.md",
@@ -238,6 +242,7 @@
"project-skills-discovery.md",
"questioning.md",
"revision-loop.md",
"scout-codebase.md",
"sketch-interactivity.md",
"sketch-theme-system.md",
"sketch-tooling.md",
@@ -263,8 +268,11 @@
"config-schema.cjs",
"config.cjs",
"core.cjs",
"decisions.cjs",
"docs.cjs",
"drift.cjs",
"frontmatter.cjs",
"gap-checker.cjs",
"graphify.cjs",
"gsd2-import.cjs",
"init.cjs",
@@ -277,6 +285,7 @@
"profile-pipeline.cjs",
"roadmap.cjs",
"schema-detect.cjs",
"secrets.cjs",
"security.cjs",
"state.cjs",
"template.cjs",

View File

@@ -54,7 +54,7 @@ Full roster at `agents/gsd-*.md`. The "Primary doc" column flags whether [`docs/
---
## Commands (83 shipped)
## Commands (85 shipped)
Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md` section order; each row carries the command name, a one-line role derived from the command's frontmatter `description:`, and a link to the source file. `tests/command-count-sync.test.cjs` locks the count against the filesystem.
@@ -163,6 +163,8 @@ Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md
| `/gsd-sketch-wrap-up` | Package sketch design findings into a persistent project skill for future build conversations. | [commands/gsd/sketch-wrap-up.md](../commands/gsd/sketch-wrap-up.md) |
| `/gsd-profile-user` | Generate developer behavioral profile and Claude-discoverable artifacts. | [commands/gsd/profile-user.md](../commands/gsd/profile-user.md) |
| `/gsd-settings` | Configure GSD workflow toggles and model profile. | [commands/gsd/settings.md](../commands/gsd/settings.md) |
| `/gsd-settings-advanced` | Power-user configuration — plan bounce, timeouts, branch templates, cross-AI execution, runtime knobs. | [commands/gsd/settings-advanced.md](../commands/gsd/settings-advanced.md) |
| `/gsd-settings-integrations` | Configure third-party API keys, code-review CLI routing, and agent-skill injection. | [commands/gsd/settings-integrations.md](../commands/gsd/settings-integrations.md) |
| `/gsd-set-profile` | Switch model profile for GSD agents (quality/balanced/budget/inherit). | [commands/gsd/set-profile.md](../commands/gsd/set-profile.md) |
| `/gsd-pr-branch` | Create a clean PR branch by filtering out `.planning/` commits. | [commands/gsd/pr-branch.md](../commands/gsd/pr-branch.md) |
| `/gsd-sync-skills` | Sync managed GSD skill directories across runtime roots for multi-runtime users. | [commands/gsd/sync-skills.md](../commands/gsd/sync-skills.md) |
@@ -173,7 +175,7 @@ Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md
---
## Workflows (81 shipped)
## Workflows (83 shipped)
Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators that commands reference internally; most are not read directly by end users. Rows below map each workflow file to its role (derived from the `<purpose>` block) and, where applicable, to the command that invokes it.
@@ -243,6 +245,8 @@ Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators
| `secure-phase.md` | Retroactive threat-mitigation audit for a completed phase. | `/gsd-secure-phase` |
| `session-report.md` | Session report — token usage, work summary, outcomes. | `/gsd-session-report` |
| `settings.md` | Configure GSD workflow toggles and model profile. | `/gsd-settings`, `/gsd-set-profile` |
| `settings-advanced.md` | Configure GSD power-user knobs — plan bounce, timeouts, branch templates, cross-AI execution, runtime knobs. | `/gsd-settings-advanced` |
| `settings-integrations.md` | Configure third-party API keys (Brave/Firecrawl/Exa), `review.models.<cli>` CLI routing, and `agent_skills.<agent-type>` injection with masked (`****<last-4>`) display. | `/gsd-settings-integrations` |
| `ship.md` | Create PR, run review, and prepare for merge after verification. | `/gsd-ship` |
| `sketch.md` | Explore design directions through throwaway HTML mockups with 2-3 variants per sketch. | `/gsd-sketch` |
| `sketch-wrap-up.md` | Curate sketch findings and package them as a persistent `sketch-findings-[project]` skill. | `/gsd-sketch-wrap-up` |
@@ -265,7 +269,7 @@ Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators
---
## References (50 shipped)
## References (51 shipped)
Full roster at `get-shit-done/references/*.md`. References are shared knowledge documents that workflows and agents `@-reference`. The groupings below match [`docs/ARCHITECTURE.md`](ARCHITECTURE.md#references-get-shit-donereferencesmd) — core, workflow, thinking-model clusters, and the modular planner decomposition.
@@ -299,6 +303,7 @@ Full roster at `get-shit-done/references/*.md`. References are shared knowledge
| `continuation-format.md` | Session continuation/resume format. |
| `domain-probes.md` | Domain-specific probing questions for discuss-phase. |
| `gate-prompts.md` | Gate/checkpoint prompt templates. |
| `scout-codebase.md` | Phase-type→codebase-map selection table for discuss-phase scout step (extracted via #2551). |
| `revision-loop.md` | Plan revision iteration patterns. |
| `universal-anti-patterns.md` | Universal anti-patterns to detect and avoid. |
| `artifact-types.md` | Planning artifact type definitions. |
@@ -350,11 +355,11 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
| `planner-revision.md` | Plan revision patterns for iterative refinement. |
| `planner-source-audit.md` | Planner source-audit and authority-limit rules. |
> **Subdirectory:** `get-shit-done/references/few-shot-examples/` contains additional few-shot examples (`plan-checker.md`, `verifier.md`) that are referenced from specific agents. These are not counted in the 50 top-level references.
> **Subdirectory:** `get-shit-done/references/few-shot-examples/` contains additional few-shot examples (`plan-checker.md`, `verifier.md`) that are referenced from specific agents. These are not counted in the 51 top-level references.
---
## CLI Modules (26 shipped)
## CLI Modules (30 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -366,8 +371,11 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `config-schema.cjs` | Single source of truth for `VALID_CONFIG_KEYS` and dynamic key patterns; imported by both the validator and the config-schema-docs parity test |
| `config.cjs` | `config.json` read/write, section initialization; imports validator from `config-schema.cjs` |
| `core.cjs` | Error handling, output formatting, shared utilities, runtime fallbacks |
| `decisions.cjs` | Shared parser for CONTEXT.md `<decisions>` blocks (D-NN entries); used by `gap-checker.cjs` and intended for #2492 plan/verify decision gates |
| `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection |
| `drift.cjs` | Post-execute codebase structural drift detector (#2003): classifies file changes into new-dir/barrel/migration/route categories and round-trips `last_mapped_commit` frontmatter |
| `frontmatter.cjs` | YAML frontmatter CRUD operations |
| `gap-checker.cjs` | Post-planning gap analysis (#2493): unified REQUIREMENTS.md + CONTEXT.md decisions vs PLAN.md coverage report (`gsd-tools gap-analysis`) |
| `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` |
| `gsd2-import.cjs` | External-plan ingest for `/gsd-from-gsd2` |
| `init.cjs` | Compound context loading for each workflow type |
@@ -380,6 +388,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning |
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
| `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, etc.) |
| `secrets.cjs` | Secret-config masking convention (`****<last-4>`) for integration keys managed by `/gsd-settings-integrations` — keeps plaintext out of `config-set` output |
| `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON/shell helpers |
| `state.cjs` | STATE.md parsing, updating, progression, metrics |
| `template.cjs` | Template selection and filling with variable substitution |

View File

@@ -179,6 +179,47 @@ By default, `/gsd-discuss-phase` asks open-ended questions about your implementa
See [docs/workflow-discuss-mode.md](workflow-discuss-mode.md) for the full discuss-mode reference.
### Decision Coverage Gates
The discuss-phase captures implementation decisions in CONTEXT.md under a
`<decisions>` block as numbered bullets (`- **D-01:** …`). Two gates — added
for issue #2492 — ensure those decisions survive into plans and shipped
code.
**Plan-phase translation gate (blocking).** After planning, GSD refuses to
mark the phase planned until every trackable decision appears in at least
one plan's `must_haves`, `truths`, or body. The gate names each missed
decision by id (`D-07: …`) so you know exactly what to add, move, or
reclassify.
**Verify-phase validation gate (non-blocking).** During verification, GSD
searches plans, SUMMARY.md, modified files, and recent commit messages for
each trackable decision. Misses are logged to VERIFICATION.md as a warning
section; verification status is unchanged. The asymmetry is deliberate —
the blocking gate is cheap at plan time but hostile at verify time.
**Writing decisions the gate can match.** Two match modes:
1. **Strict id match (recommended).** Cite the decision id anywhere in a
plan that implements it — `must_haves.truths: ["D-12: bit offsets
exposed"]`, a bullet in the plan body, a frontmatter comment. This is
deterministic and unambiguous.
2. **Soft phrase match (fallback).** If a 6+-word slice of the decision
text appears verbatim in any plan or shipped artifact, it counts. This
forgives paraphrasing but is less reliable.
**Opting a decision out.** If a decision genuinely should not be tracked —
an implementation-discretion note, an informational capture, a decision
already deferred — mark it one of these ways:
- Move it under the `### Claude's Discretion` heading inside `<decisions>`.
- Tag it in its bullet: `- **D-08 [informational]:** …`,
`- **D-09 [folded]:** …`, `- **D-10 [deferred]:** …`.
**Disabling the gates.** Set
`workflow.context_coverage_gate: false` in `.planning/config.json` (or via
`/gsd-settings`) to skip both gates silently. Default is `true`.
---
## UI Design Contract
@@ -585,6 +626,20 @@ claude --dangerously-skip-permissions
# (normal phase workflow from here)
```
**Post-execute drift detection (#2003).** After every `/gsd:execute-phase`,
GSD checks whether the phase introduced enough structural change
(new directories, barrel exports, migrations, or route modules) to make
`.planning/codebase/STRUCTURE.md` stale. If it did, the default behavior is
to print a one-shot warning suggesting the exact `/gsd:map-codebase --paths …`
invocation to refresh just the affected subtrees. Flip the behavior with:
```bash
/gsd:settings workflow.drift_action auto-remap # remap automatically
/gsd:settings workflow.drift_threshold 5 # tune sensitivity
```
The gate is non-blocking: any internal failure logs and the phase continues.
### Quick Bug Fix
```bash
@@ -740,6 +795,19 @@ To assign different models to different agents on a non-Claude runtime, add `mod
The installer auto-configures `resolve_model_ids: "omit"` for Gemini CLI, OpenCode, Kilo, and Codex. If you're manually setting up a non-Claude runtime, add it to `.planning/config.json` yourself.
#### Switching from Claude to Codex with one config change (#2517)
If you want tiered models on Codex without writing a large `model_overrides` block, set `runtime: "codex"` and pick a profile:
```json
{
"runtime": "codex",
"model_profile": "balanced"
}
```
GSD will resolve each agent's tier (`opus`/`sonnet`/`haiku`) to the Codex-native model and reasoning effort defined in the runtime tier map (`gpt-5.4` xhigh / `gpt-5.3-codex` medium / `gpt-5.4-mini` medium). The Codex installer embeds both `model` and `model_reasoning_effort` into each agent's TOML automatically. To override a single tier, add `model_profile_overrides.codex.<tier>`. See [Runtime-Aware Profiles](CONFIGURATION.md#runtime-aware-profiles-2517).
See the [Configuration Reference](CONFIGURATION.md#non-claude-runtimes-codex-opencode-gemini-cli-kilo) for the full explanation.
### Installing for Cline

View File

@@ -112,6 +112,7 @@
* verify artifacts <plan-file> Check must_haves.artifacts
* verify key-links <plan-file> Check must_haves.key_links
* verify schema-drift <phase> [--skip] Detect schema file changes without push
* verify codebase-drift Detect structural drift since last codebase map (#2003)
*
* Template Fill:
* template fill summary --phase N Create pre-filled SUMMARY.md
@@ -187,6 +188,7 @@ const profileOutput = require('./lib/profile-output.cjs');
const workstream = require('./lib/workstream.cjs');
const docs = require('./lib/docs.cjs');
const learnings = require('./lib/learnings.cjs');
const gapChecker = require('./lib/gap-checker.cjs');
// ─── Arg parsing helpers ──────────────────────────────────────────────────────
@@ -481,6 +483,12 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
} else if (subcommand === 'prune') {
const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw);
} else if (subcommand === 'milestone-switch') {
// Bug #2630: reset STATE.md frontmatter + Current Position for new milestone.
// NB: the flag is `--milestone`, not `--version` — gsd-tools reserves
// `--version` as a globally-invalid help flag (see NEVER_VALID_FLAGS above).
const { milestone, name } = parseNamedArgs(args, ['milestone', 'name']);
state.cmdStateMilestoneSwitch(cwd, milestone, name, raw);
} else {
state.cmdStateLoad(cwd, raw);
}
@@ -593,8 +601,10 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
} else if (subcommand === 'schema-drift') {
const skipFlag = args.includes('--skip');
verify.cmdVerifySchemaDrift(cwd, args[2], skipFlag, raw);
} else if (subcommand === 'codebase-drift') {
verify.cmdVerifyCodebaseDrift(cwd, raw);
} else {
error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links, schema-drift');
error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links, schema-drift, codebase-drift');
}
break;
}
@@ -709,6 +719,13 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
break;
}
case 'gap-analysis': {
// Post-planning gap checker (#2493) — unified REQUIREMENTS.md +
// CONTEXT.md <decisions> coverage report against PLAN.md files.
gapChecker.cmdGapAnalysis(cwd, args.slice(1), raw);
break;
}
case 'phase': {
const subcommand = args[1];
if (subcommand === 'next-decimal') {

View File

@@ -25,7 +25,6 @@ const VALID_CONFIG_KEYS = new Set([
'workflow.discuss_mode',
'workflow.skip_discuss',
'workflow.auto_prune_state',
'workflow._auto_chain_active',
'workflow.use_worktrees',
'workflow.code_review',
'workflow.code_review_depth',
@@ -35,15 +34,23 @@ const VALID_CONFIG_KEYS = new Set([
'workflow.plan_bounce_script',
'workflow.plan_bounce_passes',
'workflow.plan_chunked',
'workflow.post_planning_gaps',
'workflow.security_enforcement',
'workflow.security_asvs_level',
'workflow.security_block_on',
'workflow.drift_threshold',
'workflow.drift_action',
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
'workflow.cross_ai_execution', 'workflow.cross_ai_command', 'workflow.cross_ai_timeout',
'workflow.subagent_timeout',
'workflow.inline_plan_threshold',
'hooks.context_warnings',
'hooks.workflow_guard',
'workflow.context_coverage_gate',
'statusline.show_last_command',
'workflow.ui_review',
'workflow.max_discuss_passes',
'features.thinking_partner',
'context',
'features.global_learnings',
@@ -51,11 +58,14 @@ const VALID_CONFIG_KEYS = new Set([
'project_code', 'phase_naming',
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
'response_language',
'context_window',
'intel.enabled',
'graphify.enabled',
'graphify.build_timeout',
'claude_md_path',
'claude_md_assembly.mode',
// #2517 — runtime-aware model profiles
'runtime',
]);
/**
@@ -67,6 +77,10 @@ const DYNAMIC_KEY_PATTERNS = [
{ test: (k) => /^review\.models\.[a-zA-Z0-9_-]+$/.test(k), description: 'review.models.<cli-name>' },
{ test: (k) => /^features\.[a-zA-Z0-9_]+$/.test(k), description: 'features.<feature_name>' },
{ test: (k) => /^claude_md_assembly\.blocks\.[a-zA-Z0-9_]+$/.test(k), description: 'claude_md_assembly.blocks.<section>' },
// #2517 — runtime-aware model profile overrides: model_profile_overrides.<runtime>.<tier>
// <runtime> is a free string (so users can map non-built-in runtimes); <tier> is enum-restricted.
{ test: (k) => /^model_profile_overrides\.[a-zA-Z0-9_-]+\.(opus|sonnet|haiku)$/.test(k),
description: 'model_profile_overrides.<runtime>.<opus|sonnet|haiku>' },
];
/**

View File

@@ -11,6 +11,7 @@ const {
formatAgentToModelMapAsTable,
} = require('./model-profiles.cjs');
const { VALID_CONFIG_KEYS, isValidConfigKey } = require('./config-schema.cjs');
const { isSecretKey, maskSecret } = require('./secrets.cjs');
const CONFIG_KEY_SUGGESTIONS = {
'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation',
@@ -24,6 +25,8 @@ const CONFIG_KEY_SUGGESTIONS = {
'workflow.code_review_level': 'workflow.code_review_depth',
'workflow.review_depth': 'workflow.code_review_depth',
'review.model': 'review.models.<cli-name>',
'sub_repos': 'planning.sub_repos',
'plan_checker': 'workflow.plan_check',
};
function validateKnownConfigKeyPath(keyPath) {
@@ -117,6 +120,7 @@ function buildNewProjectConfig(userChoices) {
plan_bounce_script: null,
plan_bounce_passes: 2,
auto_prune_state: false,
post_planning_gaps: CONFIG_DEFAULTS.post_planning_gaps,
security_enforcement: CONFIG_DEFAULTS.security_enforcement,
security_asvs_level: CONFIG_DEFAULTS.security_asvs_level,
security_block_on: CONFIG_DEFAULTS.security_block_on,
@@ -331,7 +335,44 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
error(`Invalid context value '${value}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
}
// Codebase drift detector (#2003)
const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
if (keyPath === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
error(`Invalid workflow.drift_action '${value}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`);
}
if (keyPath === 'workflow.drift_threshold') {
if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) {
error(`Invalid workflow.drift_threshold '${value}'. Must be a positive integer.`);
}
}
// Post-planning gap checker (#2493)
if (keyPath === 'workflow.post_planning_gaps') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid workflow.post_planning_gaps '${value}'. Must be a boolean (true or false).`);
}
}
const setConfigValueResult = setConfigValue(cwd, keyPath, parsedValue);
// Mask secrets in both JSON and text output. The plaintext is written
// to config.json (that's where secrets live on disk); the CLI output
// must never echo it. See lib/secrets.cjs.
if (isSecretKey(keyPath)) {
const masked = maskSecret(parsedValue);
const maskedPrev = setConfigValueResult.previousValue === undefined
? undefined
: maskSecret(setConfigValueResult.previousValue);
const maskedResult = {
...setConfigValueResult,
value: masked,
previousValue: maskedPrev,
masked: true,
};
output(maskedResult, raw, `${keyPath}=${masked}`);
return;
}
output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`);
}
@@ -374,6 +415,14 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
error(`Key not found: ${keyPath}`);
}
// Never echo plaintext for sensitive keys via config-get. Plaintext lives
// in config.json on disk; the CLI surface always shows the masked form.
if (isSecretKey(keyPath)) {
const masked = maskSecret(current);
output(masked, raw, masked);
return;
}
output(current, raw, String(current));
}

View File

@@ -266,6 +266,7 @@ const CONFIG_DEFAULTS = {
security_enforcement: true, // workflow.security_enforcement — threat-model-anchored security verification via /gsd-secure-phase
security_asvs_level: 1, // workflow.security_asvs_level — OWASP ASVS verification level (1=opportunistic, 2=standard, 3=comprehensive)
security_block_on: 'high', // workflow.security_block_on — minimum severity that blocks phase advancement ('high' | 'medium' | 'low')
post_planning_gaps: true, // workflow.post_planning_gaps — unified post-planning gap report (#2493): scan REQUIREMENTS.md + CONTEXT.md decisions vs all PLAN.md files
};
function loadConfig(cwd) {
@@ -287,26 +288,40 @@ function loadConfig(cwd) {
// Auto-detect and sync sub_repos: scan for child directories with .git
let configDirty = false;
// Migrate legacy "multiRepo: true" boolean → sub_repos array
// Migrate legacy "multiRepo: true" boolean → planning.sub_repos array.
// Canonical location is planning.sub_repos (#2561); writing to top-level
// would be flagged as unknown by the validator below (#2638).
if (parsed.multiRepo === true && !parsed.sub_repos && !parsed.planning?.sub_repos) {
const detected = detectSubRepos(cwd);
if (detected.length > 0) {
parsed.sub_repos = detected;
if (!parsed.planning) parsed.planning = {};
parsed.planning.sub_repos = detected;
parsed.planning.commit_docs = false;
delete parsed.multiRepo;
configDirty = true;
}
}
// Keep sub_repos in sync with actual filesystem
const currentSubRepos = parsed.sub_repos || parsed.planning?.sub_repos || [];
// Self-heal legacy/buggy installs: strip any stale top-level sub_repos,
// preserving its value as the planning.sub_repos seed if that slot is empty.
if (Object.prototype.hasOwnProperty.call(parsed, 'sub_repos')) {
if (!parsed.planning) parsed.planning = {};
if (!parsed.planning.sub_repos) {
parsed.planning.sub_repos = parsed.sub_repos;
}
delete parsed.sub_repos;
configDirty = true;
}
// Keep planning.sub_repos in sync with actual filesystem
const currentSubRepos = parsed.planning?.sub_repos || [];
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
const detected = detectSubRepos(cwd);
if (detected.length > 0) {
const sorted = [...currentSubRepos].sort();
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
parsed.sub_repos = detected;
if (!parsed.planning) parsed.planning = {};
parsed.planning.sub_repos = detected;
configDirty = true;
}
}
@@ -339,6 +354,13 @@ function loadConfig(cwd) {
);
}
// #2517 — Validate runtime/tier values for keys that loadConfig handles but
// can be edited directly into config.json (bypassing config-set's enum check).
// This catches typos like `runtime: "codx"` and `model_profile_overrides.codex.banana`
// at read time without rejecting back-compat values from new runtimes
// (review findings #10, #13).
_warnUnknownProfileOverrides(parsed, '.planning/config.json');
const get = (key, nested) => {
if (parsed[key] !== undefined) return parsed[key];
if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
@@ -374,6 +396,7 @@ function loadConfig(cwd) {
plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
post_planning_gaps: get('post_planning_gaps', { section: 'workflow', field: 'post_planning_gaps' }) ?? defaults.post_planning_gaps,
parallelization,
brave_search: get('brave_search') ?? defaults.brave_search,
firecrawl: get('firecrawl') ?? defaults.firecrawl,
@@ -390,6 +413,18 @@ function loadConfig(cwd) {
project_code: get('project_code') ?? defaults.project_code,
subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
model_overrides: parsed.model_overrides || null,
// #2517 — runtime-aware profiles. `runtime` defaults to null (back-compat).
// When null, resolveModelInternal preserves today's Claude-native behavior.
// NOTE: `runtime` and `model_profile_overrides` are intentionally read
// flat-only (not via `get()` with a workflow.X fallback) — they are
// top-level keys per docs/CONFIGURATION.md. The lighter-touch decision
// here was to document the constraint rather than introduce nested
// resolution edge cases for two new keys (review finding #9). The
// schema validation in `_warnUnknownProfileOverrides` runs against the
// raw `parsed` blob, so direct `.planning/config.json` edits surface
// unknown runtime/tier names at load time, not silently (review finding #10).
runtime: parsed.runtime || null,
model_profile_overrides: parsed.model_profile_overrides || null,
agent_skills: parsed.agent_skills || {},
manager: parsed.manager || {},
response_language: get('response_language') || null,
@@ -415,6 +450,9 @@ function loadConfig(cwd) {
plan_checker: globalDefaults.plan_checker ?? defaults.plan_checker,
verifier: globalDefaults.verifier ?? defaults.verifier,
nyquist_validation: globalDefaults.nyquist_validation ?? defaults.nyquist_validation,
post_planning_gaps: globalDefaults.post_planning_gaps
?? globalDefaults.workflow?.post_planning_gaps
?? defaults.post_planning_gaps,
parallelization: globalDefaults.parallelization ?? defaults.parallelization,
text_mode: globalDefaults.text_mode ?? defaults.text_mode,
resolve_model_ids: globalDefaults.resolve_model_ids ?? defaults.resolve_model_ids,
@@ -1285,8 +1323,11 @@ function extractCurrentMilestone(content, cwd) {
// Milestone headings look like: ## v2.0, ## Roadmap v2.0, ## ✅ v1.0, etc.
const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length;
const restContent = content.slice(sectionStart + sectionMatch[0].length);
// Exclude phase headings (e.g. "### Phase 12: v1.0 Tech-Debt Closure") from
// being treated as milestone boundaries just because they mention vX.Y in
// the title. Phase headings always start with the literal `Phase `. See #2619.
const nextMilestonePattern = new RegExp(
`^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
'mi'
);
const nextMatch = restContent.match(nextMilestonePattern);
@@ -1449,32 +1490,220 @@ const MODEL_ALIAS_MAP = {
'haiku': 'claude-haiku-4-5',
};
/**
* #2517 — runtime-aware tier resolution.
* Maps `model_profile` tiers (opus/sonnet/haiku) to runtime-native model IDs and
* (where supported) reasoning_effort settings.
*
* Each entry: { model: <id>, reasoning_effort?: <level> }
*
* `claude` mirrors MODEL_ALIAS_MAP — present for symmetry so `runtime: "claude"`
* resolves through the same code path. `codex` defaults are taken from the spec
* in #2517. Unknown runtimes fall back to the Claude alias to avoid emitting
* provider-specific IDs the runtime cannot accept.
*/
const RUNTIME_PROFILE_MAP = {
claude: {
opus: { model: 'claude-opus-4-6' },
sonnet: { model: 'claude-sonnet-4-6' },
haiku: { model: 'claude-haiku-4-5' },
},
codex: {
opus: { model: 'gpt-5.4', reasoning_effort: 'xhigh' },
sonnet: { model: 'gpt-5.3-codex', reasoning_effort: 'medium' },
haiku: { model: 'gpt-5.4-mini', reasoning_effort: 'medium' },
},
};
const RUNTIMES_WITH_REASONING_EFFORT = new Set(['codex']);
/**
* Tier enum allowed under `model_profile_overrides[runtime][tier]`. Mirrors the
* regex in `config-schema.cjs` (DYNAMIC_KEY_PATTERNS) so loadConfig surfaces the
* same constraint at read time, not only at config-set time (review finding #10).
*/
const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']);
/**
* Allowlist of runtime names the install pipeline currently knows how to emit
* native model IDs for. Synced with `getDirName` in `bin/install.js` and the
* runtime list in `docs/CONFIGURATION.md`. Free-string runtimes outside this
* set are still accepted (#2517 deliberately leaves the runtime field open) —
* a warning fires once at loadConfig so a typo like `runtime: "codx"` does not
* silently fall back to Claude defaults (review findings #10, #13).
*/
const KNOWN_RUNTIMES = new Set([
'claude', 'codex', 'opencode', 'kilo', 'gemini', 'qwen',
'copilot', 'cursor', 'windsurf', 'augment', 'trae', 'codebuddy',
'antigravity', 'cline',
]);
const _warnedConfigKeys = new Set();
/**
* Emit a one-time stderr warning for unknown runtime/tier keys in a parsed
* config blob. Idempotent across calls — the same (file, key) pair only warns
* once per process so loadConfig can be called repeatedly without spamming.
*
* Does NOT reject — preserves back-compat for users on a runtime not yet in the
* allowlist (the new-runtime case must always be possible without code changes).
*/
function _warnUnknownProfileOverrides(parsed, configLabel) {
if (!parsed || typeof parsed !== 'object') return;
const runtime = parsed.runtime;
if (runtime && typeof runtime === 'string' && !KNOWN_RUNTIMES.has(runtime)) {
const key = `${configLabel}::runtime::${runtime}`;
if (!_warnedConfigKeys.has(key)) {
_warnedConfigKeys.add(key);
try {
process.stderr.write(
`gsd: warning — config key "runtime" has unknown value "${runtime}". ` +
`Known runtimes: ${[...KNOWN_RUNTIMES].sort().join(', ')}. ` +
`Resolution will fall back to safe defaults. (#2517)\n`
);
} catch { /* stderr might be closed in some test harnesses */ }
}
}
const overrides = parsed.model_profile_overrides;
if (!overrides || typeof overrides !== 'object') return;
for (const [overrideRuntime, tierMap] of Object.entries(overrides)) {
if (!KNOWN_RUNTIMES.has(overrideRuntime)) {
const key = `${configLabel}::override-runtime::${overrideRuntime}`;
if (!_warnedConfigKeys.has(key)) {
_warnedConfigKeys.add(key);
try {
process.stderr.write(
`gsd: warning — model_profile_overrides.${overrideRuntime}.* uses ` +
`unknown runtime "${overrideRuntime}". Known runtimes: ` +
`${[...KNOWN_RUNTIMES].sort().join(', ')}. (#2517)\n`
);
} catch { /* ok */ }
}
}
if (!tierMap || typeof tierMap !== 'object') continue;
for (const tierName of Object.keys(tierMap)) {
if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
const key = `${configLabel}::override-tier::${overrideRuntime}.${tierName}`;
if (!_warnedConfigKeys.has(key)) {
_warnedConfigKeys.add(key);
try {
process.stderr.write(
`gsd: warning — model_profile_overrides.${overrideRuntime}.${tierName} ` +
`uses unknown tier "${tierName}". Allowed tiers: opus, sonnet, haiku. (#2517)\n`
);
} catch { /* ok */ }
}
}
}
}
}
// Internal helper exposed for tests so per-process warning state can be reset
// between cases that intentionally exercise the warning path repeatedly.
function _resetRuntimeWarningCacheForTests() {
_warnedConfigKeys.clear();
}
/**
* #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
*
* Single source of truth shared by core.cjs (resolveModelInternal /
* resolveReasoningEffortInternal) and bin/install.js (Codex/OpenCode TOML emit
* paths). Always merges built-in defaults with user overrides at the field
* level so partial overrides keep the unspecified fields:
*
* `{ codex: { opus: "gpt-5-pro" } }` keeps reasoning_effort: 'xhigh'
* `{ codex: { opus: { reasoning_effort: 'low' } } }` keeps model: 'gpt-5.4'
*
* Without this field-merge, the documented string-shorthand example silently
* dropped reasoning_effort and a partial-object override silently dropped the
* model — both reported as critical findings in the #2609 review.
*
* Inputs:
* - runtime: string (e.g. 'codex', 'claude', 'opencode')
* - tier: 'opus' | 'sonnet' | 'haiku'
* - overrides: optional `model_profile_overrides` blob (may be null/undefined)
*
* Returns `{ model: string, reasoning_effort?: string } | null`.
*/
function resolveTierEntry({ runtime, tier, overrides }) {
if (!runtime || !tier) return null;
const builtin = RUNTIME_PROFILE_MAP[runtime]?.[tier] || null;
const userRaw = overrides?.[runtime]?.[tier];
// String shorthand from CONFIGURATION.md examples — `{ codex: { opus: "gpt-5-pro" } }`.
// Treat as `{ model: "gpt-5-pro" }` so the field-merge below still preserves
// reasoning_effort from the built-in defaults.
let userEntry = null;
if (userRaw) {
userEntry = typeof userRaw === 'string' ? { model: userRaw } : userRaw;
}
if (!builtin && !userEntry) return null;
// Field-merge: user fields win, built-in fills the gaps.
return { ...(builtin || {}), ...(userEntry || {}) };
}
/**
* Convenience wrapper used by resolveModelInternal / resolveReasoningEffortInternal.
* Pulls runtime + overrides out of a loaded config and delegates to resolveTierEntry.
*/
function _resolveRuntimeTier(config, tier) {
return resolveTierEntry({
runtime: config.runtime,
tier,
overrides: config.model_profile_overrides,
});
}
function resolveModelInternal(cwd, agentType) {
const config = loadConfig(cwd);
// Check per-agent override first — always respected regardless of resolve_model_ids.
// 1. Per-agent override — always respected; highest precedence.
// Users who set fully-qualified model IDs (e.g., "openai/gpt-5.4") get exactly that.
const override = config.model_overrides?.[agentType];
if (override) {
return override;
}
// resolve_model_ids: "omit" — return empty string so the runtime uses its configured
// default model. For non-Claude runtimes (OpenCode, Codex, etc.) that don't recognize
// Claude aliases (opus/sonnet/haiku/inherit). Set automatically during install. See #1156.
// 2. Compute the tier (opus/sonnet/haiku) for this agent under the active profile.
const profile = String(config.model_profile || 'balanced').toLowerCase();
const agentModels = MODEL_PROFILES[agentType];
const tier = agentModels ? (agentModels[profile] || agentModels['balanced']) : null;
// 3. Runtime-aware resolution (#2517) — only when `runtime` is explicitly set
// to a non-Claude runtime. `runtime: "claude"` is the implicit default and is
// treated as a no-op here so it does not silently override `resolve_model_ids:
// "omit"` (review finding #4). Deliberate ordering for non-Claude runtimes:
// explicit opt-in beats `resolve_model_ids: "omit"` so users on Codex installs
// that auto-set "omit" can still flip on tiered behavior by setting runtime
// alone. inherit profile is preserved verbatim.
if (config.runtime && config.runtime !== 'claude' && profile !== 'inherit' && tier) {
const entry = _resolveRuntimeTier(config, tier);
if (entry?.model) return entry.model;
// Unknown runtime with no user-supplied overrides — fall through to Claude-safe
// default rather than emit an ID the runtime can't accept.
}
// 4. resolve_model_ids: "omit" — return empty string so the runtime uses its
// configured default model. For non-Claude runtimes (OpenCode, Codex, etc.) that
// don't recognize Claude aliases. Set automatically during install. See #1156.
if (config.resolve_model_ids === 'omit') {
return '';
}
// Fall back to profile lookup
const profile = String(config.model_profile || 'balanced').toLowerCase();
const agentModels = MODEL_PROFILES[agentType];
// 5. Profile lookup (Claude-native default).
if (!agentModels) return 'sonnet';
if (profile === 'inherit') return 'inherit';
const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet';
// `tier` is guaranteed truthy here: agentModels exists, and MODEL_PROFILES
// entries always define `balanced`, so `agentModels[profile] || agentModels.balanced`
// resolves to a string. Keep the local for readability — no defensive fallback.
const alias = tier;
// resolve_model_ids: true — map alias to full Claude model ID
// Prevents 404s when the Task tool passes aliases directly to the API
// resolve_model_ids: true — map alias to full Claude model ID.
// Prevents 404s when the Task tool passes aliases directly to the API.
if (config.resolve_model_ids) {
return MODEL_ALIAS_MAP[alias] || alias;
}
@@ -1482,6 +1711,41 @@ function resolveModelInternal(cwd, agentType) {
return alias;
}
/**
* #2517 — Resolve runtime-specific reasoning_effort for an agent.
* Returns null unless:
* - `runtime` is explicitly set in config,
* - the runtime supports reasoning_effort (currently: codex),
* - profile is not 'inherit',
* - the resolved tier entry has a `reasoning_effort` value.
*
* Never returns a value for Claude — keeps reasoning_effort out of Claude spawn paths.
*/
function resolveReasoningEffortInternal(cwd, agentType) {
const config = loadConfig(cwd);
if (!config.runtime) return null;
// Strict allowlist: reasoning_effort only propagates for runtimes whose
// install path actually accepts it. Adding a new runtime here is the only
// way to enable effort propagation — overrides cannot bypass the gate.
// Without this, a typo in `runtime` (e.g. `"codx"`) plus a user override
// for that typo would leak `xhigh` into a Claude or unknown install
// (review finding #3).
if (!RUNTIMES_WITH_REASONING_EFFORT.has(config.runtime)) return null;
// Per-agent override means user supplied a fully-qualified ID; reasoning_effort
// for that case must be set via per-agent mechanism, not tier inference.
if (config.model_overrides?.[agentType]) return null;
const profile = String(config.model_profile || 'balanced').toLowerCase();
if (profile === 'inherit') return null;
const agentModels = MODEL_PROFILES[agentType];
if (!agentModels) return null;
const tier = agentModels[profile] || agentModels['balanced'];
if (!tier) return null;
const entry = _resolveRuntimeTier(config, tier);
return entry?.reasoning_effort || null;
}
// ─── Summary body helpers ─────────────────────────────────────────────────
/**
@@ -1492,11 +1756,28 @@ function resolveModelInternal(cwd, agentType) {
*/
function extractOneLinerFromBody(content) {
if (!content) return null;
// Normalize EOLs so matching works for LF and CRLF files.
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Strip frontmatter first
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
// Find the first **...** line after a # heading
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
return match ? match[1].trim() : null;
const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, '');
// Find the first **...** span on a line after a # heading.
// Two supported template forms:
// 1) Labeled: **One-liner:** Real prose here. (bug #2660 — new template)
// 2) Bare: **Real prose here.** (legacy template)
// For (1), the first bold span ends in a colon and the prose that follows
// on the same line is the one-liner. For (2), the bold span itself is the
// one-liner.
const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m);
if (!match) return null;
const boldInner = match[1].trim();
const afterBold = match[2];
// Labeled form: bold span is a "Label:" prefix — capture prose after it.
if (/:\s*$/.test(boldInner)) {
const prose = afterBold.trim();
return prose.length > 0 ? prose : null;
}
// Bare form: the bold content itself is the one-liner.
return boldInner.length > 0 ? boldInner : null;
}
// ─── Misc utilities ───────────────────────────────────────────────────────────
@@ -1624,7 +1905,7 @@ function getMilestonePhaseFilter(cwd) {
}
const normalized = new Set(
[...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase())
[...milestonePhaseNums].map(n => (n.replace(/^0+(?=\d)/, '') || '0').toLowerCase())
);
function isDirInMilestone(dirName) {
@@ -1760,6 +2041,13 @@ module.exports = {
getArchivedPhaseDirs,
getRoadmapPhaseInternal,
resolveModelInternal,
resolveReasoningEffortInternal,
RUNTIME_PROFILE_MAP,
RUNTIMES_WITH_REASONING_EFFORT,
KNOWN_RUNTIMES,
RUNTIME_OVERRIDE_TIERS,
resolveTierEntry,
_resetRuntimeWarningCacheForTests,
pathExistsInternal,
generateSlugInternal,
getMilestoneInfo,

View File

@@ -0,0 +1,48 @@
'use strict';
/**
* Shared parser for CONTEXT.md `<decisions>` blocks.
*
* Used by:
* - gap-checker.cjs (#2493 post-planning gap analysis)
* - intended for #2492 (plan-phase decision gate, verify-phase decision validator)
*
* Format produced by discuss-phase.md:
*
* <decisions>
* ## Implementation Decisions
*
* ### Category
* - **D-01:** Decision text
* - **D-02:** Another decision
* </decisions>
*
* D-IDs outside the <decisions> block are ignored. Missing block returns [].
*/
/**
* Parse the <decisions> section of a CONTEXT.md string.
*
* @param {string|null|undefined} contextMd - File contents, may be empty/missing.
* @returns {Array<{id: string, text: string}>}
*/
function parseDecisions(contextMd) {
if (!contextMd || typeof contextMd !== 'string') return [];
const blockMatch = contextMd.match(/<decisions>([\s\S]*?)<\/decisions>/);
if (!blockMatch) return [];
const block = blockMatch[1];
const decisionRe = /^\s*-\s*\*\*(D-[A-Za-z0-9_-]+):\*\*\s*(.+?)\s*$/gm;
const out = [];
const seen = new Set();
let m;
while ((m = decisionRe.exec(block)) !== null) {
const id = m[1];
if (seen.has(id)) continue;
seen.add(id);
out.push({ id, text: m[2] });
}
return out;
}
module.exports = { parseDecisions };

View File

@@ -0,0 +1,378 @@
/**
* Codebase Drift Detection (#2003)
*
* Detects structural drift between a committed codebase and the
* `.planning/codebase/STRUCTURE.md` map produced by `gsd-codebase-mapper`.
*
* Four categories of drift element:
* - new_dir → a newly-added file whose directory prefix does not appear
* in STRUCTURE.md
* - barrel → a newly-added barrel export at
* (packages|apps)/<name>/src/index.(ts|tsx|js|mjs|cjs)
* - migration → a newly-added migration file under one of the recognized
* migration directories (supabase, prisma, drizzle, src/migrations, …)
* - route → a newly-added route module under a `routes/` or `api/` dir
*
* Each file is counted at most once; when a file matches multiple categories
* the most specific category wins (migration > route > barrel > new_dir).
*
* Design decisions (see PR for full rubber-duck):
* - The library is pure. It takes parsed git diff output and returns a
* structured result. The CLI/workflow layer is responsible for running
* git and for spawning mappers.
* - `last_mapped_commit` is stored as YAML-style frontmatter at the top of
* each `.planning/codebase/*.md` file. This keeps the baseline attached
* to the file, survives git moves, and avoids a sidecar JSON.
* - The detector NEVER throws on malformed input — it returns a
* `{ skipped: true }` result. The phase workflow depends on this
* non-blocking guarantee.
*/
'use strict';
const fs = require('node:fs');
// ─── Constants ───────────────────────────────────────────────────────────────
const DRIFT_CATEGORIES = Object.freeze(['new_dir', 'barrel', 'migration', 'route']);
// Category priority when a single file matches multiple rules.
// Higher index = more specific = wins.
const CATEGORY_PRIORITY = { new_dir: 0, barrel: 1, route: 2, migration: 3 };
const BARREL_RE = /^(packages|apps)\/[^/]+\/src\/index\.(ts|tsx|js|mjs|cjs)$/;
const MIGRATION_RES = [
/^supabase\/migrations\/.+\.sql$/,
/^prisma\/migrations\/.+/,
/^drizzle\/meta\/.+/,
/^drizzle\/migrations\/.+/,
/^src\/migrations\/.+\.(ts|js|sql)$/,
/^db\/migrations\/.+\.(sql|ts|js)$/,
/^migrations\/.+\.(sql|ts|js)$/,
];
const ROUTE_RES = [
/^(apps|packages)\/[^/]+\/src\/routes\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
/^src\/routes\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
/^src\/api\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
/^(apps|packages)\/[^/]+\/src\/api\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
];
// A conservative allowlist for `--paths` arguments passed to the mapper:
// repo-relative path components separated by /, containing only
// alphanumerics, dash, underscore, and dot (no `..`, no `/..`).
const SAFE_PATH_RE = /^(?!.*\.\.)(?:[A-Za-z0-9_.][A-Za-z0-9_.\-]*)(?:\/[A-Za-z0-9_.][A-Za-z0-9_.\-]*)*$/;
// ─── Classification ──────────────────────────────────────────────────────────
/**
* Classify a single file path into a drift category or null.
*
* @param {string} file - repo-relative path, forward slashes.
* @returns {'barrel'|'migration'|'route'|null}
*/
function classifyFile(file) {
if (typeof file !== 'string' || !file) return null;
const norm = file.replace(/\\/g, '/');
if (MIGRATION_RES.some((r) => r.test(norm))) return 'migration';
if (ROUTE_RES.some((r) => r.test(norm))) return 'route';
if (BARREL_RE.test(norm)) return 'barrel';
return null;
}
/**
* True iff any prefix of `file` (dir1, dir1/dir2, …) appears as a substring
* of `structureMd`. Used to decide whether a file is in "mapped territory".
*
* Matching is deliberately substring-based — STRUCTURE.md is free-form
* markdown, not a structured manifest. If the map mentions `src/lib/` the
* check `structureMd.includes('src/lib')` holds.
*/
function isPathMapped(file, structureMd) {
const norm = file.replace(/\\/g, '/');
const parts = norm.split('/');
// Check prefixes from longest to shortest; any hit means "mapped".
for (let i = parts.length - 1; i >= 1; i--) {
const prefix = parts.slice(0, i).join('/');
if (structureMd.includes(prefix)) return true;
}
// Finally, if even the top-level dir is mentioned, count as mapped.
if (parts.length > 0 && structureMd.includes(parts[0] + '/')) return true;
if (parts.length > 0 && structureMd.includes('`' + parts[0] + '`')) return true;
return false;
}
// ─── Main detection ──────────────────────────────────────────────────────────
/**
* Detect codebase drift.
*
* @param {object} input
* @param {string[]} input.addedFiles - files with git status A (new)
* @param {string[]} input.modifiedFiles - files with git status M
* @param {string[]} input.deletedFiles - files with git status D
* @param {string|null|undefined} input.structureMd - contents of STRUCTURE.md
* @param {number} [input.threshold=3] - min number of drift elements that triggers action
* @param {'warn'|'auto-remap'} [input.action='warn']
* @returns {object} result
*/
function detectDrift(input) {
try {
if (!input || typeof input !== 'object') {
return skipped('invalid-input');
}
const {
addedFiles,
modifiedFiles,
deletedFiles,
structureMd,
} = input;
const threshold = Number.isInteger(input.threshold) && input.threshold >= 1
? input.threshold
: 3;
const action = input.action === 'auto-remap' ? 'auto-remap' : 'warn';
if (structureMd === null || structureMd === undefined) {
return skipped('missing-structure-md');
}
if (typeof structureMd !== 'string') {
return skipped('invalid-structure-md');
}
const added = Array.isArray(addedFiles) ? addedFiles.filter((x) => typeof x === 'string') : [];
const modified = Array.isArray(modifiedFiles) ? modifiedFiles : [];
const deleted = Array.isArray(deletedFiles) ? deletedFiles : [];
// Build elements. One element per file, highest-priority category wins.
/** @type {{category: string, path: string}[]} */
const elements = [];
const seen = new Map();
for (const rawFile of added) {
const file = rawFile.replace(/\\/g, '/');
const specific = classifyFile(file);
let category = specific;
if (!category) {
if (!isPathMapped(file, structureMd)) {
category = 'new_dir';
} else {
continue; // mapped, known, ordinary file — not drift
}
}
// Dedup: if we've already counted this path at higher-or-equal priority, skip
const prior = seen.get(file);
if (prior && CATEGORY_PRIORITY[prior] >= CATEGORY_PRIORITY[category]) continue;
seen.set(file, category);
}
for (const [file, category] of seen.entries()) {
elements.push({ category, path: file });
}
// Sort for stable output.
elements.sort((a, b) =>
a.category === b.category
? a.path.localeCompare(b.path)
: a.category.localeCompare(b.category),
);
const actionRequired = elements.length >= threshold;
let directive = 'none';
let spawnMapper = false;
let affectedPaths = [];
let message = '';
if (actionRequired) {
directive = action;
affectedPaths = chooseAffectedPaths(elements.map((e) => e.path));
if (action === 'auto-remap') {
spawnMapper = true;
}
message = buildMessage(elements, affectedPaths, action);
}
return {
skipped: false,
elements,
actionRequired,
directive,
spawnMapper,
affectedPaths,
threshold,
action,
message,
counts: {
added: added.length,
modified: modified.length,
deleted: deleted.length,
},
};
} catch (err) {
// Non-blocking: never throw from this function.
return skipped('exception:' + (err && err.message ? err.message : String(err)));
}
}
function skipped(reason) {
return {
skipped: true,
reason,
elements: [],
actionRequired: false,
directive: 'none',
spawnMapper: false,
affectedPaths: [],
message: '',
};
}
function buildMessage(elements, affectedPaths, action) {
const byCat = {};
for (const e of elements) {
(byCat[e.category] ||= []).push(e.path);
}
const lines = [
`Codebase drift detected: ${elements.length} structural element(s) since last mapping.`,
'',
];
const labels = {
new_dir: 'New directories',
barrel: 'New barrel exports',
migration: 'New migrations',
route: 'New route modules',
};
for (const cat of ['new_dir', 'barrel', 'migration', 'route']) {
if (byCat[cat]) {
lines.push(`${labels[cat]}:`);
for (const p of byCat[cat]) lines.push(` - ${p}`);
}
}
lines.push('');
if (action === 'auto-remap') {
lines.push(`Auto-remap scheduled for paths: ${affectedPaths.join(', ')}`);
} else {
lines.push(
`Run /gsd-map-codebase --paths ${affectedPaths.join(',')} to refresh planning context.`,
);
}
return lines.join('\n');
}
// ─── Affected paths ──────────────────────────────────────────────────────────
/**
* Collapse a list of drifted file paths into a sorted, deduplicated list of
* the top-level directory prefixes (depth 2 when the repo uses an
* `<apps|packages>/<name>/…` layout; depth 1 otherwise).
*/
function chooseAffectedPaths(paths) {
const out = new Set();
for (const raw of paths || []) {
if (typeof raw !== 'string' || !raw) continue;
const file = raw.replace(/\\/g, '/');
const parts = file.split('/');
if (parts.length === 0) continue;
const top = parts[0];
if ((top === 'apps' || top === 'packages') && parts.length >= 2) {
out.add(`${top}/${parts[1]}`);
} else {
out.add(top);
}
}
return [...out].sort();
}
/**
* Filter `paths` to only those that are safe to splice into a mapper prompt.
* Any path that is absolute, contains traversal, or includes shell
* metacharacters is dropped.
*/
function sanitizePaths(paths) {
if (!Array.isArray(paths)) return [];
const out = [];
for (const p of paths) {
if (typeof p !== 'string') continue;
if (p.startsWith('/')) continue;
if (!SAFE_PATH_RE.test(p)) continue;
out.push(p);
}
return out;
}
// ─── Frontmatter helpers ─────────────────────────────────────────────────────
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
function parseFrontmatter(content) {
if (typeof content !== 'string') return { data: {}, body: '' };
const m = content.match(FRONTMATTER_RE);
if (!m) return { data: {}, body: content };
const data = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z0-9_][A-Za-z0-9_-]*):\s*(.*)$/);
if (!kv) continue;
data[kv[1]] = kv[2];
}
return { data, body: content.slice(m[0].length) };
}
function serializeFrontmatter(data, body) {
const keys = Object.keys(data);
if (keys.length === 0) return body;
const lines = ['---'];
for (const k of keys) lines.push(`${k}: ${data[k]}`);
lines.push('---');
return lines.join('\n') + '\n' + body;
}
/**
* Read `last_mapped_commit` from the frontmatter of a `.planning/codebase/*.md`
* file. Returns null if the file does not exist or has no frontmatter.
*/
function readMappedCommit(filePath) {
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch {
return null;
}
const { data } = parseFrontmatter(content);
const sha = data.last_mapped_commit;
return typeof sha === 'string' && sha.length > 0 ? sha : null;
}
/**
* Upsert `last_mapped_commit` and `last_mapped_at` into the frontmatter of
* the given file, preserving any other frontmatter keys and the body.
*/
function writeMappedCommit(filePath, commitSha, isoDate) {
// Symmetric with readMappedCommit (which returns null on missing files):
// tolerate a missing target by creating a minimal frontmatter-only file
// rather than throwing ENOENT. This matters when a mapper produces a new
// doc and the caller stamps it before any prior content existed.
let content = '';
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
const { data, body } = parseFrontmatter(content);
data.last_mapped_commit = commitSha;
if (isoDate) data.last_mapped_at = isoDate;
fs.writeFileSync(filePath, serializeFrontmatter(data, body));
}
// ─── Exports ─────────────────────────────────────────────────────────────────
module.exports = {
DRIFT_CATEGORIES,
classifyFile,
detectDrift,
chooseAffectedPaths,
sanitizePaths,
readMappedCommit,
writeMappedCommit,
// Exposed for the CLI layer to reuse the same parser.
parseFrontmatter,
};

View File

@@ -0,0 +1,183 @@
'use strict';
/**
* Post-planning gap analysis (#2493).
*
* Reads REQUIREMENTS.md (planning-root) and CONTEXT.md (per-phase) and compares
* each REQ-ID and D-ID against the concatenated text of all PLAN.md files in
* the phase directory. Emits a unified `Source | Item | Status` report.
*
* Gated on workflow.post_planning_gaps (default true). When false, returns
* { enabled: false } and does not scan.
*
* Coverage detection uses word-boundary regex matching to avoid false positives
* (REQ-1 must not match REQ-10).
*/
const fs = require('fs');
const path = require('path');
const { planningPaths, planningDir, escapeRegex, output, error } = require('./core.cjs');
const { parseDecisions } = require('./decisions.cjs');
/**
* Parse REQ-IDs from REQUIREMENTS.md content.
*
* Supports both checkbox (`- [ ] **REQ-NN** ...`) and traceability table
* (`| REQ-NN | ... |`) formats.
*/
function parseRequirements(reqMd) {
if (!reqMd || typeof reqMd !== 'string') return [];
const out = [];
const seen = new Set();
const checkboxRe = /^\s*-\s*\[[x ]\]\s*\*\*(REQ-[A-Za-z0-9_-]+)\*\*\s*(.*)$/gm;
let cm = checkboxRe.exec(reqMd);
while (cm !== null) {
const id = cm[1];
if (!seen.has(id)) {
seen.add(id);
out.push({ id, text: (cm[2] || '').trim() });
}
cm = checkboxRe.exec(reqMd);
}
const tableRe = /\|\s*(REQ-[A-Za-z0-9_-]+)\s*\|/g;
let tm = tableRe.exec(reqMd);
while (tm !== null) {
const id = tm[1];
if (!seen.has(id)) {
seen.add(id);
out.push({ id, text: '' });
}
tm = tableRe.exec(reqMd);
}
return out;
}
function detectCoverage(items, planText) {
return items.map(it => {
const re = new RegExp('\\b' + escapeRegex(it.id) + '\\b');
return {
source: it.source,
item: it.id,
status: re.test(planText) ? 'Covered' : 'Not covered',
};
});
}
function naturalKey(s) {
return String(s).replace(/(\d+)/g, (_, n) => n.padStart(8, '0'));
}
function sortRows(rows) {
const sourceOrder = { 'REQUIREMENTS.md': 0, 'CONTEXT.md': 1 };
return rows.slice().sort((a, b) => {
const so = (sourceOrder[a.source] ?? 99) - (sourceOrder[b.source] ?? 99);
if (so !== 0) return so;
return naturalKey(a.item).localeCompare(naturalKey(b.item));
});
}
function formatGapTable(rows) {
if (rows.length === 0) {
return '## Post-Planning Gap Analysis\n\nNo requirements or decisions to check.\n';
}
const header = '| Source | Item | Status |\n|--------|------|--------|';
const body = rows.map(r => {
const tick = r.status === 'Covered' ? '\u2713 Covered' : '\u2717 Not covered';
return `| ${r.source} | ${r.item} | ${tick} |`;
}).join('\n');
return `## Post-Planning Gap Analysis\n\n${header}\n${body}\n`;
}
function readGate(cwd) {
const cfgPath = path.join(planningDir(cwd), 'config.json');
try {
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
if (raw && raw.workflow && typeof raw.workflow.post_planning_gaps === 'boolean') {
return raw.workflow.post_planning_gaps;
}
} catch { /* fall through */ }
return true;
}
function runGapAnalysis(cwd, phaseDir) {
if (!readGate(cwd)) {
return {
enabled: false,
rows: [],
table: '',
summary: 'workflow.post_planning_gaps disabled — skipping post-planning gap analysis',
counts: { total: 0, covered: 0, uncovered: 0 },
};
}
const absPhaseDir = path.isAbsolute(phaseDir) ? phaseDir : path.join(cwd, phaseDir);
const reqPath = planningPaths(cwd).requirements;
const reqMd = fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf-8') : '';
const reqItems = parseRequirements(reqMd).map(r => ({ ...r, source: 'REQUIREMENTS.md' }));
const ctxPath = path.join(absPhaseDir, 'CONTEXT.md');
const ctxMd = fs.existsSync(ctxPath) ? fs.readFileSync(ctxPath, 'utf-8') : '';
const dItems = parseDecisions(ctxMd).map(d => ({ ...d, source: 'CONTEXT.md' }));
const items = [...reqItems, ...dItems];
let planText = '';
try {
if (fs.existsSync(absPhaseDir)) {
const files = fs.readdirSync(absPhaseDir).filter(f => /-PLAN\.md$/.test(f));
planText = files.map(f => {
try { return fs.readFileSync(path.join(absPhaseDir, f), 'utf-8'); }
catch { return ''; }
}).join('\n');
}
} catch { /* unreadable */ }
if (items.length === 0) {
return {
enabled: true,
rows: [],
table: '## Post-Planning Gap Analysis\n\nNo requirements or decisions to check.\n',
summary: 'no requirements or decisions to check',
counts: { total: 0, covered: 0, uncovered: 0 },
};
}
const rows = sortRows(detectCoverage(items, planText));
const uncovered = rows.filter(r => r.status === 'Not covered').length;
const covered = rows.length - uncovered;
const summary = uncovered === 0
? `\u2713 All ${rows.length} items covered by plans`
: `\u26A0 ${uncovered} of ${rows.length} items not covered by any plan`;
return {
enabled: true,
rows,
table: formatGapTable(rows) + '\n' + summary + '\n',
summary,
counts: { total: rows.length, covered, uncovered },
};
}
function cmdGapAnalysis(cwd, args, raw) {
const idx = args.indexOf('--phase-dir');
if (idx === -1 || !args[idx + 1]) {
error('Usage: gap-analysis --phase-dir <path-to-phase-directory>');
}
const phaseDir = args[idx + 1];
const result = runGapAnalysis(cwd, phaseDir);
output(result, raw, result.table || result.summary);
}
module.exports = {
parseRequirements,
detectCoverage,
formatGapTable,
sortRows,
runGapAnalysis,
cmdGapAnalysis,
};

View File

@@ -827,20 +827,70 @@ function cmdInitMilestoneOp(cwd, raw) {
let phaseCount = 0;
let completedPhases = 0;
const phasesDir = path.join(planningDir(cwd), 'phases');
// Bug #2633 — ROADMAP.md (current milestone section) is the authority for
// phase counts, NOT the on-disk `.planning/phases/` directory. After
// `phases clear` between milestones, on-disk dirs will be a subset of the
// roadmap until each phase is materialized; reading from disk causes
// `all_phases_complete: true` to fire prematurely.
let roadmapPhaseNumbers = [];
try {
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
const currentSection = extractCurrentMilestone(roadmapRaw, cwd);
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
let m;
while ((m = phasePattern.exec(currentSection)) !== null) {
roadmapPhaseNumbers.push(m[1]);
}
} catch { /* intentionally empty */ }
// Canonicalize a phase token by stripping leading zeros from the integer
// head while preserving any [A-Z]? suffix and dotted segments. So "03" →
// "3", "03A" → "3A", "03.1" → "3.1", "3A" → "3A". Disk dirs that pad
// ("03-alpha") then match roadmap tokens ("Phase 3") without ever
// collapsing distinct tokens like "3" / "3A" / "3.1" into the same bucket.
const canonicalizePhase = (tok) => {
const m = tok.match(/^(\d+)([A-Z]?(?:\.\d+)*)$/);
return m ? String(parseInt(m[1], 10)) + m[2] : tok;
};
const diskPhaseDirs = new Map();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
phaseCount = dirs.length;
for (const e of entries) {
if (!e.isDirectory()) continue;
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
if (!m) continue;
diskPhaseDirs.set(canonicalizePhase(m[1]), e.name);
}
} catch { /* intentionally empty */ }
// Count phases with summaries (completed)
for (const dir of dirs) {
if (roadmapPhaseNumbers.length > 0) {
phaseCount = roadmapPhaseNumbers.length;
for (const num of roadmapPhaseNumbers) {
const dirName = diskPhaseDirs.get(canonicalizePhase(num));
if (!dirName) continue;
try {
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const phaseFiles = fs.readdirSync(path.join(phasesDir, dirName));
const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
if (hasSummary) completedPhases++;
} catch { /* intentionally empty */ }
}
} catch { /* intentionally empty */ }
} else {
// Fallback: no parseable ROADMAP — preserve legacy on-disk behavior.
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
phaseCount = dirs.length;
for (const dir of dirs) {
try {
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
if (hasSummary) completedPhases++;
} catch { /* intentionally empty */ }
}
} catch { /* intentionally empty */ }
}
// Check archive
const archiveDir = path.join(planningRoot(cwd), 'archive');
@@ -1230,6 +1280,7 @@ function cmdInitProgress(cwd, raw) {
// Build set of phases defined in ROADMAP for the current milestone
const roadmapPhaseNums = new Set();
const roadmapPhaseNames = new Map();
const roadmapCheckboxStates = new Map();
try {
const roadmapContent = extractCurrentMilestone(
fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd
@@ -1240,6 +1291,13 @@ function cmdInitProgress(cwd, raw) {
roadmapPhaseNums.add(hm[1]);
roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim());
}
// #2646: parse `- [x] Phase N` checkbox states so ROADMAP-only phases
// inherit completion from the ROADMAP when no phase directory exists.
const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
let cbm;
while ((cbm = cbPattern.exec(roadmapContent)) !== null) {
roadmapCheckboxStates.set(cbm[2], cbm[1].toLowerCase() === 'x');
}
} catch { /* intentionally empty */ }
const isDirInMilestone = getMilestonePhaseFilter(cwd);
@@ -1295,21 +1353,27 @@ function cmdInitProgress(cwd, raw) {
}
} catch { /* intentionally empty */ }
// Add phases defined in ROADMAP but not yet scaffolded to disk
// Add phases defined in ROADMAP but not yet scaffolded to disk. When the
// ROADMAP has a `- [x] Phase N` checkbox, honor it as 'complete' so
// completed_count and status reflect the ROADMAP source of truth (#2646).
for (const [num, name] of roadmapPhaseNames) {
const stripped = num.replace(/^0+/, '') || '0';
if (!seenPhaseNums.has(stripped)) {
const checkboxComplete =
roadmapCheckboxStates.get(num) === true ||
roadmapCheckboxStates.get(stripped) === true;
const status = checkboxComplete ? 'complete' : 'not_started';
const phaseInfo = {
number: num,
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
directory: null,
status: 'not_started',
status,
plan_count: 0,
summary_count: 0,
has_research: false,
};
phases.push(phaseInfo);
if (!nextPhase && !currentPhase) {
if (!nextPhase && !currentPhase && status !== 'complete') {
nextPhase = phaseInfo;
}
}

View File

@@ -0,0 +1,33 @@
'use strict';
/**
* Secrets handling — masking convention for API keys and other
* credentials managed via /gsd-settings-integrations.
*
* Convention: strings 8+ chars long render as `****<last-4>`; shorter
* strings render as `****` with no tail (to avoid leaking a meaningful
* fraction of a short secret). null/empty renders as `(unset)`.
*
* Keys considered sensitive are listed in SECRET_CONFIG_KEYS and matched
* at the exact key-path level. The list is intentionally narrow — these
* are the fields documented as secrets in docs/CONFIGURATION.md.
*/
const SECRET_CONFIG_KEYS = new Set([
'brave_search',
'firecrawl',
'exa_search',
]);
function isSecretKey(keyPath) {
return SECRET_CONFIG_KEYS.has(keyPath);
}
function maskSecret(value) {
if (value === null || value === undefined || value === '') return '(unset)';
const s = String(value);
if (s.length < 8) return '****';
return '****' + s.slice(-4);
}
module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret };

View File

@@ -1253,6 +1253,70 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
}
/**
* Bug #2630: reset STATE.md for a new milestone cycle.
* Stomps frontmatter milestone/milestone_name/status/progress AND rewrites
* the Current Position body. Preserves Accumulated Context.
* Symmetric with the SDK `stateMilestoneSwitch` handler.
*/
function cmdStateMilestoneSwitch(cwd, version, name, raw) {
if (!version || !String(version).trim()) {
output({ error: 'milestone required (--milestone <vX.Y>)' }, raw);
return;
}
const resolvedName = (name && String(name).trim()) || 'milestone';
const statePath = planningPaths(cwd).state;
const today = new Date().toISOString().split('T')[0];
const lockPath = acquireStateLock(statePath);
try {
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
const existingFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const resetPositionBody =
`\nPhase: Not started (defining requirements)\n` +
`Plan: —\n` +
`Status: Defining requirements\n` +
`Last activity: ${today} — Milestone ${version} started\n\n`;
let newBody;
if (positionPattern.test(body)) {
newBody = body.replace(positionPattern, (_m, header) => `${header}${resetPositionBody}`);
} else {
const preface = body.trim().length > 0 ? body : '# Project State\n';
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
}
const fm = {
gsd_state_version: existingFm.gsd_state_version || '1.0',
milestone: version,
milestone_name: resolvedName,
status: 'planning',
last_updated: new Date().toISOString(),
last_activity: today,
progress: {
total_phases: 0,
completed_phases: 0,
total_plans: 0,
completed_plans: 0,
percent: 0,
},
};
const yamlStr = reconstructFrontmatter(fm);
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
atomicWriteFileSync(statePath, normalizeMd(assembled), 'utf-8');
output(
{ switched: true, version, name: resolvedName, status: 'planning' },
raw,
'true',
);
} finally {
releaseStateLock(lockPath);
}
}
/**
* Gate 1: Validate STATE.md against filesystem.
* Returns { valid, warnings, drift } JSON.
@@ -1644,6 +1708,7 @@ module.exports = {
cmdStateValidate,
cmdStateSync,
cmdStatePrune,
cmdStateMilestoneSwitch,
cmdSignalWaiting,
cmdSignalResume,
};

View File

@@ -591,28 +591,57 @@ function cmdValidateHealth(cwd, options, raw) {
} else {
const stateContent = fs.readFileSync(statePath, 'utf-8');
// Extract phase references from STATE.md
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)].map(m => m[1]);
// Get disk phases
const diskPhases = new Set();
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map(m => m[1]);
// Bug #2633 — ROADMAP.md is the authority for which phases are valid.
// STATE.md may legitimately reference current-milestone future phases
// (not yet materialized on disk) and shipped-milestone history phases
// (archived / cleared off disk). Matching only against on-disk dirs
// produces false W002 warnings in both cases.
const validPhases = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const m = e.name.match(/^(\d+(?:\.\d+)*)/);
if (m) diskPhases.add(m[1]);
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
if (m) validPhases.add(m[1]);
}
}
} catch { /* intentionally empty */ }
// Union in every phase declared anywhere in ROADMAP.md (current + shipped + backlog).
try {
if (fs.existsSync(roadmapPath)) {
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
const all = [...roadmapRaw.matchAll(/#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)];
for (const m of all) validPhases.add(m[1]);
}
} catch { /* intentionally empty */ }
// Compare canonical full phase tokens. Also accept a leading-zero variant
// on the integer prefix only (e.g. "03" matching "3", "03.1" matching
// "3.1") so historic STATE.md formatting still validates. Suffix tokens
// like "3A" must match exactly — never collapsed to "3".
const normalizedValid = new Set();
for (const p of validPhases) {
normalizedValid.add(p);
const dotIdx = p.indexOf('.');
const head = dotIdx === -1 ? p : p.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : p.slice(dotIdx);
if (/^\d+$/.test(head)) {
normalizedValid.add(head.padStart(2, '0') + tail);
}
}
// Check for invalid references
for (const ref of phaseRefs) {
const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0');
if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
// Only warn if phases dir has any content (not just an empty project)
if (diskPhases.size > 0) {
const dotIdx = ref.indexOf('.');
const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : ref.slice(dotIdx);
const padded = /^\d+$/.test(head) ? head.padStart(2, '0') + tail : ref;
if (!normalizedValid.has(ref) && !normalizedValid.has(padded)) {
// Only warn if we know any valid phases (not just an empty project)
if (normalizedValid.size > 0) {
addIssue(
'warning',
'W002',
`STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`,
`STATE.md references phase ${ref}, but only phases ${[...validPhases].sort().join(', ')} are declared`,
'Review STATE.md manually before changing it; /gsd-health --repair will not overwrite an existing STATE.md for phase mismatches'
);
}
@@ -1169,6 +1198,141 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
}, raw);
}
// ─── Codebase Drift Detection (#2003) ────────────────────────────────────────
/**
* Detect structural drift between the committed tree and
* `.planning/codebase/STRUCTURE.md`. Non-blocking: any failure returns a
* `{ skipped: true }` JSON result with a reason; the command never exits
* non-zero so `execute-phase`'s drift gate cannot fail the phase.
*/
function cmdVerifyCodebaseDrift(cwd, raw) {
const drift = require('./drift.cjs');
const emit = (payload) => output(payload, raw);
try {
const codebaseDir = path.join(planningDir(cwd), 'codebase');
const structurePath = path.join(codebaseDir, 'STRUCTURE.md');
if (!fs.existsSync(structurePath)) {
emit({
skipped: true,
reason: 'no-structure-md',
action_required: false,
directive: 'none',
elements: [],
});
return;
}
let structureMd;
try {
structureMd = fs.readFileSync(structurePath, 'utf-8');
} catch (err) {
emit({
skipped: true,
reason: 'cannot-read-structure-md: ' + err.message,
action_required: false,
directive: 'none',
elements: [],
});
return;
}
const lastMapped = drift.readMappedCommit(structurePath);
// Verify we're inside a git repo and resolve the diff range.
const revProbe = execGit(cwd, ['rev-parse', 'HEAD']);
if (revProbe.exitCode !== 0) {
emit({
skipped: true,
reason: 'not-a-git-repo',
action_required: false,
directive: 'none',
elements: [],
});
return;
}
// Empty-tree SHA is a stable fallback when no mapping commit is recorded.
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
let base = lastMapped;
if (!base) {
base = EMPTY_TREE;
} else {
// Verify the commit is reachable; if not, fall back to EMPTY_TREE.
const verify = execGit(cwd, ['cat-file', '-t', base]);
if (verify.exitCode !== 0) base = EMPTY_TREE;
}
const diff = execGit(cwd, ['diff', '--name-status', base, 'HEAD']);
if (diff.exitCode !== 0) {
emit({
skipped: true,
reason: 'git-diff-failed',
action_required: false,
directive: 'none',
elements: [],
});
return;
}
const added = [];
const modified = [];
const deleted = [];
for (const line of diff.stdout.split(/\r?\n/)) {
if (!line.trim()) continue;
const m = line.match(/^([A-Z])\d*\t(.+?)(?:\t(.+))?$/);
if (!m) continue;
const status = m[1];
// For renames (R), use the new path (m[3] if present, else m[2]).
const file = m[3] || m[2];
if (status === 'A' || status === 'R' || status === 'C') added.push(file);
else if (status === 'M') modified.push(file);
else if (status === 'D') deleted.push(file);
}
// Threshold and action read from config, with defaults.
const config = loadConfig(cwd);
const threshold = Number.isInteger(config?.workflow?.drift_threshold) && config.workflow.drift_threshold >= 1
? config.workflow.drift_threshold
: 3;
const action = config?.workflow?.drift_action === 'auto-remap' ? 'auto-remap' : 'warn';
const result = drift.detectDrift({
addedFiles: added,
modifiedFiles: modified,
deletedFiles: deleted,
structureMd,
threshold,
action,
});
emit({
skipped: !!result.skipped,
reason: result.reason || null,
action_required: !!result.actionRequired,
directive: result.directive,
spawn_mapper: !!result.spawnMapper,
affected_paths: result.affectedPaths || [],
elements: result.elements || [],
threshold,
action,
last_mapped_commit: lastMapped,
message: result.message || '',
});
} catch (err) {
// Non-blocking: never bubble up an exception.
emit({
skipped: true,
reason: 'exception: ' + (err && err.message ? err.message : String(err)),
action_required: false,
directive: 'none',
elements: [],
});
}
}
module.exports = {
cmdVerifySummary,
cmdVerifyPlanStructure,
@@ -1181,4 +1345,5 @@ module.exports = {
cmdValidateHealth,
cmdValidateAgents,
cmdVerifySchemaDrift,
cmdVerifyCodebaseDrift,
};

View File

@@ -268,6 +268,7 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research":
| `workflow.security_enforcement` | boolean | `true` | `true`, `false` | Enable threat-model-anchored security verification via `/gsd-secure-phase`. When `false`, security checks are skipped entirely |
| `workflow.security_asvs_level` | number | `1` | `1`, `2`, `3` | OWASP ASVS verification level. Level 1 = opportunistic, Level 2 = standard, Level 3 = comprehensive |
| `workflow.security_block_on` | string | `"high"` | `"high"`, `"medium"`, `"low"` | Minimum severity that blocks phase advancement |
| `workflow.post_planning_gaps` | boolean | `true` | `true`, `false` | Post-planning gap report (#2493). After plans are generated, scans REQUIREMENTS.md and CONTEXT.md `<decisions>` against all PLAN.md files and emits a unified `Source \| Item \| Status` table. Non-blocking. Set to `false` to skip Step 13e of plan-phase. _Alias:_ `post_planning_gaps` is the flat-key form used in `CONFIG_DEFAULTS`; `workflow.post_planning_gaps` is the canonical namespaced form. |
### Git Fields

View File

@@ -0,0 +1,51 @@
# Codebase scout — map selection table
> Lazy-loaded reference for the `scout_codebase` step in
> `workflows/discuss-phase.md` (extracted via #2551 progressive-disclosure
> refactor). Read this only when prior `.planning/codebase/*.md` maps exist
> and the workflow needs to pick which 23 to load.
## Phase-type → recommended maps
Read 23 maps based on inferred phase type. Do NOT read all seven —
that inflates context without improving discussion quality.
| Phase type (infer from title + ROADMAP entry) | Read these maps |
|---|---|
| UI / frontend / styling / design | CONVENTIONS.md, STRUCTURE.md, STACK.md |
| Backend / API / service / data model | STACK.md, ARCHITECTURE.md, INTEGRATIONS.md |
| Integration / third-party / provider | STACK.md, INTEGRATIONS.md, ARCHITECTURE.md |
| Infrastructure / DevOps / CI / deploy | STACK.md, ARCHITECTURE.md, INTEGRATIONS.md |
| Testing / QA / coverage | TESTING.md, CONVENTIONS.md, STRUCTURE.md |
| Documentation / content | CONVENTIONS.md, STRUCTURE.md |
| Mixed / unclear | STACK.md, ARCHITECTURE.md, CONVENTIONS.md |
Read CONCERNS.md only if the phase explicitly addresses known concerns or
security issues.
## Single-read rule
Read each map file in a **single** Read call. Do not read the same file at
two different offsets — split reads break prompt-cache reuse and cost more
than a single full read.
## No-maps fallback
If `.planning/codebase/*.md` does not exist:
1. Extract key terms from the phase goal (e.g., "feed" → "post", "card",
"list"; "auth" → "login", "session", "token")
2. `grep -rlE "{term1}|{term2}" src/ app/ --include="*.ts" ...` (use `-E`
for extended regex so the `|` alternation works on both GNU grep and BSD
grep / macOS), and `ls` the conventional component/hook/util dirs
3. Read the 35 most relevant files
## Output (internal `<codebase_context>`)
From the scan, identify:
- **Reusable assets** — components, hooks, utilities usable in this phase
- **Established patterns** — state management, styling, data fetching
- **Integration points** — routes, nav, providers where new code connects
- **Creative options** — approaches the architecture enables or constrains
Used in `analyze_phase` and `present_gray_areas`. NOT written to a file —
session-only.

View File

@@ -18,7 +18,7 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
```bash
INIT=$(gsd-sdk query init.milestone-op)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-integration-checker 2>/dev/null)
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-integration-checker)
```
Extract from init JSON: `milestone_version`, `milestone_name`, `phase_count`, `completed_phases`, `commit_docs`.

View File

@@ -41,7 +41,7 @@ When a milestone completes:
Before proceeding with milestone close, run the comprehensive open artifact audit.
```bash
gsd-sdk query audit-open 2>/dev/null
gsd-sdk query audit-open
```
If the output contains open items (any section with count > 0):

View File

@@ -87,7 +87,7 @@ This runs in parallel - all gaps investigated simultaneously.
**Load agent skills:**
```bash
AGENT_SKILLS_DEBUGGER=$(gsd-sdk query agent-skills gsd-debugger 2>/dev/null)
AGENT_SKILLS_DEBUGGER=$(gsd-sdk query agent-skills gsd-debugger)
EXPECTED_BASE=$(git rev-parse HEAD)
```

View File

@@ -66,7 +66,7 @@ Phase number from argument (required).
```bash
INIT=$(gsd-sdk query init.phase-op "${PHASE}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_ANALYZER=$(gsd-sdk query agent-skills gsd-assumptions-analyzer 2>/dev/null)
AGENT_SKILLS_ANALYZER=$(gsd-sdk query agent-skills gsd-assumptions-analyzer)
```
Parse JSON for: `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`,
@@ -619,7 +619,7 @@ Check for auto-advance trigger:
2. Sync chain flag:
```bash
if [[ ! "$ARGUMENTS" =~ --auto ]]; then
gsd-sdk query config-set workflow._auto_chain_active false 2>/dev/null
gsd-sdk query config-set workflow._auto_chain_active false || true
fi
```
3. Read consolidated auto-mode (`active` = chain flag OR user preference):

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
# Advisor mode — research-backed comparison tables
> **Lazy-loaded and gated.** The parent `workflows/discuss-phase.md` Reads
> this file ONLY when `ADVISOR_MODE` is true (i.e., when
> `$HOME/.claude/get-shit-done/USER-PROFILE.md` exists). Skip the Read
> entirely when no profile is present — that's the inverse of the
> `--advisor` flag from #2174 (don't pay the cost when unused).
## Activation
```bash
PROFILE_PATH="$HOME/.claude/get-shit-done/USER-PROFILE.md"
if [ -f "$PROFILE_PATH" ]; then
ADVISOR_MODE=true
else
ADVISOR_MODE=false
fi
```
If `ADVISOR_MODE` is false, do **not** Read this file — proceed with the
standard `default.md` discussion flow.
## Calibration tier
Resolve `vendor_philosophy` calibration tier:
1. **Priority 1:** Read `config.json` > `preferences.vendor_philosophy`
(project-level override)
2. **Priority 2:** Read USER-PROFILE.md `Vendor Choices/Philosophy` rating
(global)
3. **Priority 3:** Default to `"standard"` if neither has a value or value
is `UNSCORED`
Map to calibration tier:
- `conservative` OR `thorough-evaluator``full_maturity`
- `opinionated``minimal_decisive`
- `pragmatic-fast` OR any other value OR empty → `standard`
Resolve advisor model:
```bash
ADVISOR_MODEL=$(gsd-sdk query resolve-model gsd-advisor-researcher --raw)
```
## Non-technical owner detection
Read USER-PROFILE.md and check for product-owner signals:
```bash
PROFILE_CONTENT=$(cat "$HOME/.claude/get-shit-done/USER-PROFILE.md" 2>/dev/null || true)
```
Set `NON_TECHNICAL_OWNER = true` if ANY of the following are present:
- `learning_style: guided`
- The word `jargon` appears in a `frustration_triggers` section
- `explanation_depth: practical-detailed` (without a technical modifier)
- `explanation_depth: high-level`
**Tie-breaker / precedence (when signals conflict):**
1. An explicit `technical_background: true` (or any `explanation_depth` value
tagged with a technical modifier such as `practical-detailed:technical`)
**overrides** all inferred non-technical signals — set
`NON_TECHNICAL_OWNER = false`.
2. Otherwise, ANY single matching signal is sufficient to set
`NON_TECHNICAL_OWNER = true` (signals are OR-aggregated, not weighted).
3. Contradictory `explanation_depth` values: the most recent entry wins.
Log the resolved value and the matched/overriding signal so the user can
audit why a given framing was used.
When `NON_TECHNICAL_OWNER` is true, reframe gray area labels and
descriptions in product-outcome language before presenting them. Preserve
the same underlying decision — only change the framing:
- Technical implementation term → outcome the user will experience
- "Token architecture" → "Color system: which approach prevents the dark theme from flashing white on open"
- "CSS variable strategy" → "Theme colors: how your brand colors stay consistent in both light and dark mode"
- "Component API surface area" → "How the building blocks connect: how tightly coupled should these parts be"
- "Caching strategy: SWR vs React Query" → "Loading speed: should screens show saved data right away or wait for fresh data"
This reframing applies to:
1. Gray area labels and descriptions in `present_gray_areas`
2. Advisor research rationale rewrites in the synthesis step below
## advisor_research step
After the user selects gray areas in `present_gray_areas`, spawn parallel
research agents.
1. Display brief status: `Researching {N} areas...`
2. For EACH user-selected gray area, spawn a `Task()` in parallel:
```
Task(
prompt="First, read @~/.claude/agents/gsd-advisor-researcher.md for your role and instructions.
<gray_area>{area_name}: {area_description from gray area identification}</gray_area>
<phase_context>{phase_goal and description from ROADMAP.md}</phase_context>
<project_context>{project name and brief description from PROJECT.md}</project_context>
<calibration_tier>{resolved calibration tier: full_maturity | standard | minimal_decisive}</calibration_tier>
Research this gray area and return a structured comparison table with rationale.
${AGENT_SKILLS_ADVISOR}",
subagent_type="general-purpose",
model="{ADVISOR_MODEL}",
description="Research: {area_name}"
)
```
All `Task()` calls spawn simultaneously — do NOT wait for one before
starting the next.
3. After ALL agents return, **synthesize results** before presenting:
For each agent's return:
a. Parse the markdown comparison table and rationale paragraph
b. Verify all 5 columns present (Option | Pros | Cons | Complexity | Recommendation) — fill any missing columns rather than showing broken table
c. Verify option count matches calibration tier:
- `full_maturity`: 3-5 options acceptable
- `standard`: 2-4 options acceptable
- `minimal_decisive`: 1-2 options acceptable
If agent returned too many, trim least viable. If too few, accept as-is.
d. Rewrite rationale paragraph to weave in project context and ongoing discussion context that the agent did not have access to
e. If agent returned only 1 option, convert from table format to direct recommendation: "Standard approach for {area}: {option}. {rationale}"
f. **If `NON_TECHNICAL_OWNER` is true:** apply a plain language rewrite to the rationale paragraph. Replace implementation-level terms with outcome descriptions the user can reason about without technical context. The Recommendation column value and the table structure remain intact. Do not remove detail; translate it. Example: "SWR uses stale-while-revalidate to serve cached responses immediately" → "This approach shows you something right away, then quietly updates in the background — users see data instantly."
4. Store synthesized tables for use in `discuss_areas` (table-first flow).
## discuss_areas (advisor table-first flow)
For each selected area:
1. **Present the synthesized comparison table + rationale paragraph** (from
`advisor_research`)
2. **Use AskUserQuestion** (or text-mode equivalent if `--text` overlay):
- header: `{area_name}`
- question: `Which approach for {area_name}?`
- options: extract from the table's Option column (AskUserQuestion adds
"Other" automatically)
3. **Record the user's selection:**
- If user picks from table options → record as locked decision for that
area
- If user picks "Other" → receive their input, reflect it back for
confirmation, record
4. **Thinking partner (conditional):** same rule as default mode — if
`features.thinking_partner` is enabled and tradeoff signals are
detected, offer a 3-5 bullet analysis before locking in.
5. **After recording pick, decide whether follow-up questions are needed:**
- If the pick has ambiguity that would affect downstream planning →
ask 1-2 targeted follow-up questions using AskUserQuestion
- If the pick is clear and self-contained → move to next area
- Do NOT ask the standard 4 questions — the table already provided the
context
6. **After all areas processed:**
- header: "Done"
- question: "That covers [list areas]. Ready to create context?"
- options: "Create context" / "Revisit an area"
## Scope creep handling (advisor mode)
If user mentions something outside the phase domain:
```
"[Feature] sounds like a new capability — that belongs in its own phase.
I'll note it as a deferred idea.
Back to [current area]: [return to current question]"
```
Track deferred ideas internally.

View File

@@ -0,0 +1,28 @@
# --all mode — auto-select ALL gray areas, discuss interactively
> **Lazy-loaded.** Read this file from `workflows/discuss-phase.md` when
> `--all` is present in `$ARGUMENTS`. Behavior overlays the default mode.
## Effect
- In `present_gray_areas`: auto-select ALL gray areas without asking the user
(skips the AskUserQuestion area-selection step).
- Discussion for each area proceeds **fully interactively** — the user drives
every question for every area (use the default-mode `discuss_areas` flow).
- Does NOT auto-advance to plan-phase afterward — use `--chain` or `--auto`
if you want auto-advance.
- Log: `[--all] Auto-selected all gray areas: [list area names].`
## Why this mode exists
This is the "discuss everything" shortcut: skip the selection friction, keep
full interactive control over each individual question.
## Combination rules
- `--all --auto`: `--auto` wins for the discussion phase too (Claude picks
recommended answers); `--all`'s contribution is just area auto-selection.
- `--all --chain`: areas auto-selected, discussion interactive, then
auto-advance to plan/execute (chain semantics).
- `--all --batch` / `--all --text` / `--all --analyze`: layered overlays
apply during discussion as documented in their respective files.

View File

@@ -0,0 +1,44 @@
# --analyze mode — trade-off tables before each question
> **Lazy-loaded overlay.** Read this file from `workflows/discuss-phase.md`
> when `--analyze` is present in `$ARGUMENTS`. Combinable with default,
> `--all`, `--chain`, `--text`, `--batch`.
## Effect
Before presenting each question (or question group, in batch mode), provide
a brief **trade-off analysis** for the decision:
- 2-3 options with pros/cons based on codebase context and common patterns
- A recommended approach with reasoning
- Known pitfalls or constraints from prior phases
## Example
```markdown
**Trade-off analysis: Authentication strategy**
| Approach | Pros | Cons |
|----------|------|------|
| Session cookies | Simple, httpOnly prevents XSS | Requires CSRF protection, sticky sessions |
| JWT (stateless) | Scalable, no server state | Token size, revocation complexity |
| OAuth 2.0 + PKCE | Industry standard for SPAs | More setup, redirect flow UX |
💡 Recommended: OAuth 2.0 + PKCE — your app has social login in requirements (REQ-04) and this aligns with the existing NextAuth setup in `src/lib/auth.ts`.
How should users authenticate?
```
This gives the user context to make informed decisions without extra
prompting.
When `--analyze` is absent, present questions directly as before (no
trade-off table).
## Sourcing the analysis
- Pros/cons should reflect the codebase context loaded in `scout_codebase`
and any prior decisions surfaced in `load_prior_context`.
- The recommendation must explicitly tie to project context (e.g.,
existing libraries, prior phase decisions, documented requirements).
- If a related ADR or spec is referenced in CONTEXT.md `<canonical_refs>`,
cite it in the recommendation.

View File

@@ -0,0 +1,56 @@
# --auto mode — fully autonomous discuss-phase
> **Lazy-loaded.** Read this file from `workflows/discuss-phase.md` when
> `--auto` is present in `$ARGUMENTS`. After the discussion completes, the
> parent's `auto_advance` step also reads `modes/chain.md` to drive the
> auto-advance to plan-phase.
## Effect across steps
- **`check_existing`**: if CONTEXT.md exists, auto-select "Update it" — load
existing context and continue to `analyze_phase` (matches the parent step's
documented `--auto` branch). If no context exists, continue without
prompting. For interrupted checkpoints, auto-select "Resume". For existing
plans, auto-select "Continue and replan after". Log every decision so the
user can audit.
- **`cross_reference_todos`**: fold all todos with relevance score >= 0.4
automatically. Log the selection.
- **`present_gray_areas`**: auto-select ALL gray areas. Log:
`[--auto] Selected all gray areas: [list area names].`
- **`discuss_areas`**: for each discussion question, choose the recommended
option (first option, or the one explicitly marked "recommended") **without
using AskUserQuestion**. Skip interactive prompts entirely. Log each
auto-selected choice inline so the user can review decisions in the
context file:
```
[auto] [Area] — Q: "[question text]" → Selected: "[chosen option]" (recommended default)
```
- After all areas are auto-resolved, skip the "Explore more gray areas"
prompt and proceed directly to `write_context`.
- After `write_context`, **auto-advance** to plan-phase via `modes/chain.md`.
## CRITICAL — Auto-mode pass cap
In `--auto` mode, the discuss step MUST complete in a **single pass**. After
writing CONTEXT.md once, you are DONE — proceed immediately to
`write_context` and then auto_advance. Do NOT re-read your own CONTEXT.md to
find "gaps", "undefined types", or "missing decisions" and run additional
passes. This creates a self-feeding loop where each pass generates references
that the next pass treats as gaps, consuming unbounded time and resources.
Check the pass cap from config:
```bash
MAX_PASSES=$(gsd-sdk query config-get workflow.max_discuss_passes 2>/dev/null || echo "3")
```
If you have already written and committed CONTEXT.md, the discuss step is
complete. Move on.
## Combination rules
- `--auto --text` / `--auto --batch`: text/batch overlays are no-ops in
auto mode (no user prompts to render).
- `--auto --analyze`: trade-off tables can still be logged for the audit
trail; selection still uses the recommended option.
- `--auto --power`: `--power` wins (power mode generates files for offline
answering — incompatible with autonomous selection).

View File

@@ -0,0 +1,52 @@
# --batch mode — grouped question batches
> **Lazy-loaded overlay.** Read this file from `workflows/discuss-phase.md`
> when `--batch` is present in `$ARGUMENTS`. Combinable with default,
> `--all`, `--chain`, `--text`, `--analyze`.
## Argument parsing
Parse optional `--batch` from `$ARGUMENTS`:
- Accept `--batch`, `--batch=N`, or `--batch N`
- Default to **4 questions per batch** when no number is provided
- Clamp explicit sizes to **25** so a batch stays answerable
- If `--batch` is absent, keep the existing one-question-at-a-time flow
(default mode).
## Effect on discuss_areas
`--batch` mode: ask **25 numbered questions in one plain-text turn** per
area, instead of the default 4 single-question AskUserQuestion turns.
- Group closely related questions for the current area into a single
message
- Keep each question concrete and answerable in one reply
- When options are helpful, include short inline choices per question
rather than a separate AskUserQuestion for every item
- After the user replies, reflect back the captured decisions, note any
unanswered items, and ask only the minimum follow-up needed before
moving on
- Preserve adaptiveness between batches: use the full set of answers to
decide the next batch or whether the area is sufficiently clear
## Philosophy
Stay adaptive, but let the user choose the pacing.
- Default mode: 4 single-question turns, then check whether to continue
- `--batch` mode: 1 grouped turn with 25 numbered questions, then check
whether to continue
Each answer set should reveal the next question or next batch.
## Example batch
```
Authentication — please answer 14:
1. Which auth strategy? (a) Session cookies (b) JWT (c) OAuth 2.0 + PKCE
2. Where do tokens live? (a) httpOnly cookie (b) localStorage (c) memory only
3. Session lifetime? (a) 1h (b) 24h (c) 30d (d) configurable
4. Account recovery? (a) email reset (b) magic link (c) both
Reply with your choices (e.g. "1c, 2a, 3b, 4c") or describe in your own words.
```

View File

@@ -0,0 +1,97 @@
# --chain mode — interactive discuss, then auto-advance
> **Lazy-loaded.** Read this file from `workflows/discuss-phase.md` when
> `--chain` is present in `$ARGUMENTS`, or when the parent's `auto_advance`
> step needs to dispatch to plan-phase under `--auto`.
## Effect
- Discussion is **fully interactive** — questions, gray-area selection, and
follow-ups behave exactly the same as default mode.
- After discussion completes, **auto-advance to plan-phase → execute-phase**
(same downstream behavior as `--auto`).
- This is the middle ground: the user controls the discuss decisions, then
plan and execute run autonomously.
## auto_advance step (executed by the parent file)
1. Parse `--auto` and `--chain` flags from `$ARGUMENTS`. **Note:** `--all`
is NOT an auto-advance trigger — it only affects area selection. A
session with `--all` but without `--auto` or `--chain` returns to manual
next-steps after discussion completes.
2. **Sync chain flag with intent** — if user invoked manually (no `--auto`
and no `--chain`), clear the ephemeral chain flag from any previous
interrupted `--auto` chain. This does NOT touch `workflow.auto_advance`
(the user's persistent settings preference):
```bash
if [[ ! "$ARGUMENTS" =~ --auto ]] && [[ ! "$ARGUMENTS" =~ --chain ]]; then
gsd-sdk query config-set workflow._auto_chain_active false || true
fi
```
3. Read consolidated auto-mode (`active` = chain flag OR user preference):
```bash
AUTO_MODE=$(gsd-sdk query check auto-mode --pick active 2>/dev/null || echo "false")
```
4. **If `--auto` or `--chain` flag present AND `AUTO_MODE` is not true:**
Persist chain flag to config (handles direct usage without new-project):
```bash
gsd-sdk query config-set workflow._auto_chain_active true
```
5. **If `--auto` flag present OR `--chain` flag present OR `AUTO_MODE` is
true:** display banner and launch plan-phase.
Banner:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GSD ► AUTO-ADVANCING TO PLAN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Context captured. Launching plan-phase...
```
Launch plan-phase using the Skill tool to avoid nested Task sessions
(which cause runtime freezes due to deep agent nesting — see #686):
```
Skill(skill="gsd-plan-phase", args="${PHASE} --auto ${GSD_WS}")
```
This keeps the auto-advance chain flat — discuss, plan, and execute all
run at the same nesting level rather than spawning increasingly deep
Task agents.
6. **Handle plan-phase return:**
- **PHASE COMPLETE** → Full chain succeeded. Display:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GSD ► PHASE ${PHASE} COMPLETE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Auto-advance pipeline finished: discuss → plan → execute
/clear then:
Next: /gsd-discuss-phase ${NEXT_PHASE} ${WAS_CHAIN ? "--chain" : "--auto"} ${GSD_WS}
```
- **PLANNING COMPLETE** → Planning done, execution didn't complete:
```
Auto-advance partial: Planning complete, execution did not finish.
Continue: /gsd-execute-phase ${PHASE} ${GSD_WS}
```
- **PLANNING INCONCLUSIVE / CHECKPOINT** → Stop chain:
```
Auto-advance stopped: Planning needs input.
Continue: /gsd-plan-phase ${PHASE} ${GSD_WS}
```
- **GAPS FOUND** → Stop chain:
```
Auto-advance stopped: Gaps found during execution.
Continue: /gsd-plan-phase ${PHASE} --gaps ${GSD_WS}
```
7. **If none of `--auto`, `--chain`, nor config enabled:** route to
`confirm_creation` step (existing behavior — show manual next steps).

View File

@@ -0,0 +1,141 @@
# Default mode — interactive discuss-phase
> **Lazy-loaded.** Read this file from `workflows/discuss-phase.md` when no
> mode flag is present (the baseline interactive flow). When `--text`,
> `--batch`, or `--analyze` is also present, layer the corresponding overlay
> file from this directory on top of the rules below.
This document defines `discuss_areas` for the default flow. The shared steps
that come before (`initialize`, `check_blocking_antipatterns`, `check_spec`,
`check_existing`, `load_prior_context`, `cross_reference_todos`,
`scout_codebase`, `analyze_phase`, `present_gray_areas`) live in the parent
file and run for every mode.
## discuss_areas (default, interactive)
For each selected area, conduct a focused discussion loop.
**Research-before-questions mode:** Check if `workflow.research_before_questions` is enabled in config (from init context or `.planning/config.json`). When enabled, before presenting questions for each area:
1. Do a brief web search for best practices related to the area topic
2. Summarize the top findings in 2-3 bullet points
3. Present the research alongside the question so the user can make a more informed decision
Example with research enabled:
```text
Let's talk about [Authentication Strategy].
📊 Best practices research:
• OAuth 2.0 + PKCE is the current standard for SPAs (replaces implicit flow)
• Session tokens with httpOnly cookies preferred over localStorage for XSS protection
• Consider passkey/WebAuthn support — adoption is accelerating in 2025-2026
With that context: How should users authenticate?
```
When disabled (default), skip the research and present questions directly as before.
**Philosophy:** stay adaptive. Default flow is 4 single-question turns, then
check whether to continue. Each answer should reveal the next question.
**For each area:**
1. **Announce the area:**
```text
Let's talk about [Area].
```
2. **Ask 4 questions using AskUserQuestion:**
- header: "[Area]" (max 12 chars — abbreviate if needed)
- question: Specific decision for this area
- options: 2-3 concrete choices (AskUserQuestion adds "Other" automatically), with the recommended choice highlighted and brief explanation why
- **Annotate options with code context** when relevant:
```text
"How should posts be displayed?"
- Cards (reuses existing Card component — consistent with Messages)
- List (simpler, would be a new pattern)
- Timeline (needs new Timeline component — none exists yet)
```
- Include "You decide" as an option when reasonable — captures Claude discretion
- **Context7 for library choices:** When a gray area involves library selection (e.g., "magic links" → query next-auth docs) or API approach decisions, use `mcp__context7__*` tools to fetch current documentation and inform the options. Don't use Context7 for every question — only when library-specific knowledge improves the options.
3. **After the current set of questions, check:**
- header: "[Area]" (max 12 chars)
- question: "More questions about [area], or move to next? (Remaining: [list other unvisited areas])"
- options: "More questions" / "Next area"
When building the question text, list the remaining unvisited areas so the user knows what's ahead. For example: "More questions about Layout, or move to next? (Remaining: Loading behavior, Content ordering)"
If "More questions" → ask another 4 single questions, then check again
If "Next area" → proceed to next selected area
If "Other" (free text) → interpret intent: continuation phrases ("chat more", "keep going", "yes", "more") map to "More questions"; advancement phrases ("done", "move on", "next", "skip") map to "Next area". If ambiguous, ask: "Continue with more questions about [area], or move to the next area?"
4. **After all initially-selected areas complete:**
- Summarize what was captured from the discussion so far
- AskUserQuestion:
- header: "Done"
- question: "We've discussed [list areas]. Which gray areas remain unclear?"
- options: "Explore more gray areas" / "I'm ready for context"
- If "Explore more gray areas":
- Identify 2-4 additional gray areas based on what was learned
- Return to present_gray_areas logic with these new areas
- Loop: discuss new areas, then prompt again
- If "I'm ready for context": Proceed to write_context
**Canonical ref accumulation during discussion:**
When the user references a doc, spec, or ADR during any answer — e.g., "read adr-014", "check the MCP spec", "per browse-spec.md" — immediately:
1. Read the referenced doc (or confirm it exists)
2. Add it to the canonical refs accumulator with full relative path
3. Use what you learned from the doc to inform subsequent questions
These user-referenced docs are often MORE important than ROADMAP.md refs because they represent docs the user specifically wants downstream agents to follow. Never drop them.
**Question design:**
- Options should be concrete, not abstract ("Cards" not "Option A")
- Each answer should inform the next question or next batch
- If user picks "Other" to provide freeform input (e.g., "let me describe it", "something else", or an open-ended reply), ask your follow-up as plain text — NOT another AskUserQuestion. Wait for them to type at the normal prompt, then reflect their input back and confirm before resuming AskUserQuestion or the next numbered batch.
**Thinking partner (conditional):**
If `features.thinking_partner` is enabled in config, check the user's answer for tradeoff signals
(see `references/thinking-partner.md` for signal list). If tradeoff detected:
```text
I notice competing priorities here — {option_A} optimizes for {goal_A} while {option_B} optimizes for {goal_B}.
Want me to think through the tradeoffs before we lock this in?
[Yes, analyze] / [No, decision made]
```
If yes: provide 3-5 bullet analysis (what each optimizes/sacrifices, alignment with PROJECT.md goals, recommendation). Then return to normal flow.
**Scope creep handling:**
If user mentions something outside the phase domain:
```text
"[Feature] sounds like a new capability — that belongs in its own phase.
I'll note it as a deferred idea.
Back to [current area]: [return to current question]"
```
Track deferred ideas internally.
**Incremental checkpoint — save after each area completes:**
After each area is resolved (user says "Next area"), immediately write a checkpoint file with all decisions captured so far. This prevents data loss if the session is interrupted mid-discussion.
**Checkpoint file:** `${phase_dir}/${padded_phase}-DISCUSS-CHECKPOINT.json`
Schema: read `workflows/discuss-phase/templates/checkpoint.json` for the
canonical structure — copy it and substitute the live values.
**On session resume:** Handled in the parent's `check_existing` step. After
`write_context` completes successfully, the parent's `git_commit` step
deletes the checkpoint.
**Track discussion log data internally:**
For each question asked, accumulate:
- Area name
- All options presented (label + description)
- Which option the user selected (or their free-text response)
- Any follow-up notes or clarifications the user provided
This data is used to generate DISCUSSION-LOG.md in the parent's `git_commit` step.

View File

@@ -0,0 +1,44 @@
# --power mode — bulk question generation, async answering
> **Lazy-loaded.** Read this file from `workflows/discuss-phase.md` when
> `--power` is present in `$ARGUMENTS`. The full step-by-step instructions
> live in the existing `discuss-phase-power.md` workflow file (kept stable
> at its original path so installed `@`-references continue to resolve).
## Dispatch
```
Read @~/.claude/get-shit-done/workflows/discuss-phase-power.md
```
Execute it end-to-end. Do not continue with the standard interactive steps.
## Summary of flow
The power user mode generates ALL questions upfront into machine-readable
and human-friendly files, then waits for the user to answer at their own
pace before processing all answers in a single pass.
1. Run the same phase analysis (gray area identification) as standard mode
2. Write all questions to
`{phase_dir}/{padded_phase}-QUESTIONS.json` and
`{phase_dir}/{padded_phase}-QUESTIONS.html`
3. Notify user with file paths and wait for a "refresh" or "finalize"
command
4. On "refresh": read the JSON, process answered questions, update stats
and HTML
5. On "finalize": read all answers from JSON, generate CONTEXT.md in the
standard format
## When to use
Large phases with many gray areas, or when users prefer to answer
questions offline / asynchronously rather than interactively in the chat
session.
## Combination rules
- `--power --auto`: power wins. Power mode is incompatible with
autonomous selection — its purpose is offline answering.
- `--power --chain`: after the power-mode finalize step writes
CONTEXT.md, the chain auto-advance still applies (Read `chain.md`).

View File

@@ -0,0 +1,55 @@
# --text mode — plain-text overlay (no AskUserQuestion)
> **Lazy-loaded overlay.** Read this file from `workflows/discuss-phase.md`
> when `--text` is present in `$ARGUMENTS`, OR when
> `workflow.text_mode: true` is set in config (e.g., per-project default).
## Effect
When text mode is active, **do not use AskUserQuestion at all**. Instead,
present every question as a plain-text numbered list and ask the user to
type their choice number. Free-text input maps to the "Other" branch of
the equivalent AskUserQuestion call.
This is required for Claude Code remote sessions (`/rc` mode) where the
Claude App cannot forward TUI menu selections back to the host.
## Activation
- Per-session: pass `--text` flag to any command (e.g.,
`/gsd-discuss-phase --text`)
- Per-project: `gsd-sdk query config-set workflow.text_mode true`
Text mode applies to ALL workflows in the session, not just discuss-phase.
## Question rendering
Replace this:
```text
AskUserQuestion(
header="Layout",
question="How should posts be displayed?",
options=["Cards", "List", "Timeline"]
)
```
With this:
```text
Layout — How should posts be displayed?
1. Cards
2. List
3. Timeline
4. Other (type freeform)
Reply with a number, or describe your preference.
```
Wait for the user's reply at the normal prompt. Parse:
- Numeric reply → mapped to that option
- Free text → treated as "Other" — reflect it back, confirm, then proceed
## Empty-answer handling
The same answer-validation rules from the parent file apply: empty
responses trigger one retry, then a clarifying question. Do not proceed
with empty input.

View File

@@ -0,0 +1,18 @@
{
"phase": "{PHASE_NUM}",
"phase_name": "{phase_name}",
"timestamp": "{ISO timestamp}",
"areas_completed": ["Area 1", "Area 2"],
"areas_remaining": ["Area 3", "Area 4"],
"decisions": {
"Area 1": [
{"question": "...", "answer": "...", "options_presented": ["..."]},
{"question": "...", "answer": "...", "options_presented": ["..."]}
],
"Area 2": [
{"question": "...", "answer": "...", "options_presented": ["..."]}
]
},
"deferred_ideas": ["..."],
"canonical_refs": ["..."]
}

View File

@@ -0,0 +1,136 @@
# CONTEXT.md template — for discuss-phase write_context step
> **Lazy-loaded.** Read this file only inside the `write_context` step of
> `workflows/discuss-phase.md`, immediately before writing
> `${phase_dir}/${padded_phase}-CONTEXT.md`. Do not put a reference to this
> file in `<required_reading>` — that defeats the progressive-disclosure
> savings introduced by issue #2551.
## Variable substitutions
The caller substitutes:
- `[X]` → phase number
- `[Name]` → phase name
- `[date]` → ISO date when context was gathered
- `${padded_phase}` → zero-padded phase number (e.g., `07`, `15`)
- `{N}` → counts (requirements, etc.)
## Conditional sections
- **`<spec_lock>`** — include only when `spec_loaded = true` (a `*-SPEC.md`
was found by `check_spec`). Otherwise omit the entire `<spec_lock>` block.
- **Folded Todos / Reviewed Todos** — include subsections only when the
`cross_reference_todos` step folded or reviewed at least one todo.
## Template body
```markdown
# Phase [X]: [Name] - Context
**Gathered:** [date]
**Status:** Ready for planning
<domain>
## Phase Boundary
[Clear statement of what this phase delivers — the scope anchor]
</domain>
[If spec_loaded = true, insert this section:]
<spec_lock>
## Requirements (locked via SPEC.md)
**{N} requirements are locked.** See `{padded_phase}-SPEC.md` for full requirements, boundaries, and acceptance criteria.
Downstream agents MUST read `{padded_phase}-SPEC.md` before planning or implementing. Requirements are not duplicated here.
**In scope (from SPEC.md):** [copy the "In scope" bullet list from SPEC.md Boundaries]
**Out of scope (from SPEC.md):** [copy the "Out of scope" bullet list from SPEC.md Boundaries]
</spec_lock>
<decisions>
## Implementation Decisions
### [Category 1 that was discussed]
- **D-01:** [Decision or preference captured]
- **D-02:** [Another decision if applicable]
### [Category 2 that was discussed]
- **D-03:** [Decision or preference captured]
### Claude's Discretion
[Areas where user said "you decide" — note that Claude has flexibility here]
### Folded Todos
[If any todos were folded into scope from the cross_reference_todos step, list them here.
Each entry should include the todo title, original problem, and how it fits this phase's scope.
If no todos were folded: omit this subsection entirely.]
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
[MANDATORY section. Write the FULL accumulated canonical refs list here.
Sources: ROADMAP.md refs + REQUIREMENTS.md refs + user-referenced docs during
discussion + any docs discovered during codebase scout. Group by topic area.
Every entry needs a full relative path — not just a name.]
### [Topic area 1]
- `path/to/adr-or-spec.md` — [What it decides/defines that's relevant]
- `path/to/doc.md` §N — [Specific section reference]
### [Topic area 2]
- `path/to/feature-doc.md` — [What this doc defines]
[If no external specs: "No external specs — requirements fully captured in decisions above"]
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- [Component/hook/utility]: [How it could be used in this phase]
### Established Patterns
- [Pattern]: [How it constrains/enables this phase]
### Integration Points
- [Where new code connects to existing system]
</code_context>
<specifics>
## Specific Ideas
[Any particular references, examples, or "I want it like X" moments from discussion]
[If none: "No specific requirements — open to standard approaches"]
</specifics>
<deferred>
## Deferred Ideas
[Ideas that came up but belong in other phases. Don't lose them.]
### Reviewed Todos (not folded)
[If any todos were reviewed in cross_reference_todos but not folded into scope,
list them here so future phases know they were considered.
Each entry: todo title + reason it was deferred (out of scope, belongs in Phase Y, etc.)
If no reviewed-but-deferred todos: omit this subsection entirely.]
[If none: "None — discussion stayed within phase scope"]
</deferred>
---
*Phase: [X]-[Name]*
*Context gathered: [date]*
```

View File

@@ -0,0 +1,50 @@
# DISCUSSION-LOG.md template — for discuss-phase git_commit step
> **Lazy-loaded.** Read this file only inside the `git_commit` step of
> `workflows/discuss-phase.md`, immediately before writing
> `${phase_dir}/${padded_phase}-DISCUSSION-LOG.md`.
## Purpose
Audit trail for human review (compliance, learning, retrospectives). NOT
consumed by downstream agents — those read CONTEXT.md only.
## Template body
```markdown
# Phase [X]: [Name] - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** [ISO date]
**Phase:** [phase number]-[phase name]
**Areas discussed:** [comma-separated list]
---
[For each gray area discussed:]
## [Area Name]
| Option | Description | Selected |
|--------|-------------|----------|
| [Option 1] | [Description from AskUserQuestion] | |
| [Option 2] | [Description] | ✓ |
| [Option 3] | [Description] | |
**User's choice:** [Selected option or free-text response]
**Notes:** [Any clarifications, follow-up context, or rationale the user provided]
---
[Repeat for each area]
## Claude's Discretion
[List areas where user said "you decide" or deferred to Claude]
## Deferred Ideas
[Ideas mentioned during discussion that were noted for future phases]
```

View File

@@ -16,7 +16,7 @@ Load docs-update context:
```bash
INIT=$(gsd-sdk query docs-init)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS=$(gsd-sdk query agent-skills gsd-doc-writer 2>/dev/null)
AGENT_SKILLS=$(gsd-sdk query agent-skills gsd-doc-writer)
```
Extract from init JSON:

View File

@@ -69,7 +69,7 @@ Load all context in one call:
```bash
INIT=$(gsd-sdk query init.execute-phase "${PHASE_ARG}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS=$(gsd-sdk query agent-skills gsd-executor 2>/dev/null)
AGENT_SKILLS=$(gsd-sdk query agent-skills gsd-executor)
```
Parse JSON for: `executor_model`, `verifier_model`, `commit_docs`, `parallelization`, `branching_strategy`, `branch_name`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `plans`, `incomplete_plans`, `plan_count`, `incomplete_count`, `state_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
@@ -130,7 +130,7 @@ inline path for each plan.
```bash
# REQUIRED: prevents stale auto-chain from previous --auto runs
if [[ ! "$ARGUMENTS" =~ --auto ]]; then
gsd-sdk query config-set workflow._auto_chain_active false 2>/dev/null
gsd-sdk query config-set workflow._auto_chain_active false || true
fi
```
</step>
@@ -336,6 +336,26 @@ CROSS_AI_TIMEOUT=$(gsd-sdk query config-get workflow.cross_ai_timeout 2>/dev/nul
<step name="execute_waves">
Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZATION=true`, sequential if `false`.
**Stream-idle-timeout prevention — checkpoint heartbeats (#2410):**
Multi-plan phases can accumulate enough subagent context that the Claude API
SSE layer terminates with `Stream idle timeout - partial response received`
between a large tool_result and the next assistant turn (seen on Claude Code
+ Opus 4.7 at ~200K+ cache_read). To keep the stream warm, emit short
assistant-text heartbeats — **no tool call, just a literal line** — at every
wave and plan boundary. Each heartbeat MUST start with `[checkpoint]` so
tooling and `/gsd-manager`'s background-completion handler can grep partial
transcripts. `{P}/{Q}` is the phase-wide completed/total plans counter and
increases monotonically across waves. `{status}` is `complete` (success),
`failed` (executor error), or `checkpoint` (human-gate returned).
```
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting, {wave_plan_count} plan(s), {P}/{Q} plans done
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} starting ({P}/{Q} plans done)
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} {status} ({P}/{Q} plans done)
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} complete, {P}/{Q} plans done ({wave_success}/{wave_plan_count} ok)
```
**For each wave:**
1. **Intra-wave files_modified overlap check (BEFORE spawning):**
@@ -374,7 +394,15 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT
2. **Describe what's being built (BEFORE spawning):**
Read each plan's `<objective>`. Extract what's being built and why.
**First, emit the wave-start checkpoint heartbeat as a literal assistant-text
line — no tool call (#2410). Do NOT skip this even for single-plan waves; it
is required before any further reasoning or spawning:**
```
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting, {wave_plan_count} plan(s), {P}/{Q} plans done
```
Then read each plan's `<objective>`. Extract what's being built and why.
```
---
@@ -392,6 +420,13 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT
3. **Spawn executor agents:**
**Emit a plan-start heartbeat (literal line, no tool call) immediately before
each `Task()` dispatch (#2410):**
```
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} starting ({P}/{Q} plans done)
```
Pass paths only — executors read files themselves with their fresh context window.
For 200k models, this keeps orchestrator context lean (~10-15%).
For 1M+ models (Opus 4.6, Sonnet 4.6), richer context can be passed directly.
@@ -552,6 +587,16 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT
4. **Wait for all agents in wave to complete.**
**Plan-complete heartbeat (#2410):** as each executor returns (or is verified
via spot-check below), emit one line — `complete` advances `{P}`, `failed`
and `checkpoint` do not but still warm the stream:
```
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} complete ({P}/{Q} plans done)
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} failed ({P}/{Q} plans done)
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} checkpoint ({P}/{Q} plans done)
```
**Completion signal fallback (Copilot and runtimes where Task() may not return):**
If a spawned agent does not return a completion signal but appears to have finished
@@ -830,6 +875,15 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT
6. **Report completion — spot-check claims first:**
**Wave-close heartbeat (#2410):** after spot-checks finish (pass or fail),
before the `## Wave {N} Complete` summary, emit as a literal line:
```
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} complete, {P}/{Q} plans done ({wave_success}/{wave_plan_count} ok)
```
For each SUMMARY.md:
- Verify first 2 files from `key-files.created` exist on disk
- Check `git log --oneline --all --grep="{phase}-{plan}"` returns ≥1 commit
@@ -1270,11 +1324,22 @@ If `TEXT_MODE` is true, present as a plain-text numbered list. Otherwise use Ask
**If user selects option 3:** Stop execution. Report partial completion.
</step>
<step name="codebase_drift_gate">
Post-execution structural drift detection (#2003). Non-blocking by contract:
any internal error here MUST fall through to `verify_phase_goal`. The phase
is never failed by this gate.
Load and follow the full step spec from
`get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md` —
covers the SDK call, JSON contract, `warn` vs `auto-remap` branches, mapper
spawn template, and the two `workflow.drift_*` config keys.
</step>
<step name="verify_phase_goal">
Verify phase achieved its GOAL, not just completed tasks.
```bash
VERIFIER_SKILLS=$(gsd-sdk query agent-skills gsd-verifier 2>/dev/null)
VERIFIER_SKILLS=$(gsd-sdk query agent-skills gsd-verifier)
```
```

View File

@@ -0,0 +1,79 @@
# Step: codebase_drift_gate
Post-execution structural drift detection (#2003). Runs after the last wave
commits, before verification. **Non-blocking by contract:** any internal
error here MUST fall through and continue to `verify_phase_goal`. The phase
is never failed by this gate.
```bash
DRIFT=$(gsd-sdk query verify.codebase-drift 2>/dev/null || echo '{"skipped":true,"reason":"sdk-failed"}')
```
Parse JSON for: `skipped`, `reason`, `action_required`, `directive`,
`spawn_mapper`, `affected_paths`, `elements`, `threshold`, `action`,
`last_mapped_commit`, `message`.
**If `skipped` is true (no STRUCTURE.md, missing git, or any internal error):**
Log one line — `Codebase drift check skipped: {reason}` — and continue to
`verify_phase_goal`. Do NOT prompt the user. Do NOT block.
**If `action_required` is false:** Continue silently to `verify_phase_goal`.
**If `action_required` is true AND `directive` is `warn`:**
Print the `message` field verbatim. The format is:
```text
Codebase drift detected: {N} structural element(s) since last mapping.
New directories:
- {path}
New barrel exports:
- {path}
New migrations:
- {path}
New route modules:
- {path}
Run /gsd-map-codebase --paths {affected_paths} to refresh planning context.
```
Then continue to `verify_phase_goal`. Do NOT block. Do NOT spawn anything.
**If `action_required` is true AND `directive` is `auto-remap`:**
First load the mapper agent's skill bundle (the executor's `AGENT_SKILLS`
from step `init_context` is for `gsd-executor`, not the mapper):
```bash
AGENT_SKILLS_MAPPER=$(gsd-sdk query agent-skills gsd-codebase-mapper)
```
Then spawn `gsd-codebase-mapper` agents with the `--paths` hint:
```text
Task(
subagent_type="gsd-codebase-mapper",
description="Incremental codebase remap (drift)",
prompt="Focus: arch
Today's date: {date}
--paths {affected_paths joined by comma}
Refresh STRUCTURE.md and ARCHITECTURE.md scoped to the listed paths only.
Stamp last_mapped_commit in each document's frontmatter.
${AGENT_SKILLS_MAPPER}"
)
```
If the spawn fails or the agent reports an error: log `Codebase drift
auto-remap failed: {reason}` and continue to `verify_phase_goal`. The phase
is NOT failed by a remap failure.
If the remap succeeds: log `Codebase drift auto-remap completed for paths:
{affected_paths}` and continue to `verify_phase_goal`.
The two relevant config keys (continue on error / failure if either is invalid):
- `workflow.drift_threshold` (integer, default 3) — minimum drift elements before action
- `workflow.drift_action``warn` (default) or `auto-remap`
This step is fully non-blocking — it never fails the phase, and any
exception path returns control to `verify_phase_goal`.

View File

@@ -402,15 +402,19 @@ If SUMMARY "Issues Encountered" ≠ "None": yolo → log and continue. Interacti
</step>
<step name="update_roadmap">
**Skip this step if running in parallel mode** (the orchestrator handles ROADMAP.md
updates centrally after merging worktrees).
Run this step only when NOT executing inside a git worktree (i.e.
`use_worktrees: false`, the bug #2661 reproducer). In worktree mode each
worktree has its own ROADMAP.md, so per-plan writes here would diverge
across siblings; the orchestrator owns the post-merge sync centrally
(see execute-phase.md §5.7, single-writer contract from #1486 / dcb50396).
```bash
# Auto-detect parallel mode: .git is a file in worktrees, a directory in main repo
# Auto-detect worktree mode: .git is a file in worktrees, a directory in main repo.
# This mirrors the use_worktrees config flag for the executing handler.
IS_WORKTREE=$([ -f .git ] && echo "true" || echo "false")
# Skip in parallel mode — orchestrator handles ROADMAP.md centrally
if [ "$IS_WORKTREE" != "true" ]; then
# use_worktrees: false → this handler is the sole post-plan sync point (#2661)
gsd-sdk query roadmap.update-plan-progress "${PHASE}"
fi
```

View File

@@ -63,19 +63,35 @@ Extract from result: `phase_number`, `after_phase`, `name`, `slug`, `directory`.
</step>
<step name="update_project_state">
Update STATE.md to reflect the inserted phase:
Update STATE.md to reflect the inserted phase via SDK handlers (never raw
`Edit`/`Write` — projects may ship a `protect-files.sh` PreToolUse hook that
blocks direct STATE.md writes):
1. Read `.planning/STATE.md`
2. Update STATE.md's next-phase pointers to the newly inserted phase `{decimal_phase}`:
- Update structured field(s) used by tooling (e.g. `current_phase:`) to `{decimal_phase}`.
- Update human-readable recommendation text (e.g. `## Current Phase`, `Next recommended run:`) to `{decimal_phase}`.
- If multiple pointer locations exist, update all of them in the same edit.
3. Under "## Accumulated Context" → "### Roadmap Evolution" add entry:
```
- Phase {decimal_phase} inserted after Phase {after_phase}: {description} (URGENT)
1. Update STATE.md's next-phase pointer(s) to the newly inserted phase
`{decimal_phase}`:
```bash
gsd-sdk query state.patch '{"Current Phase":"{decimal_phase}","Next recommended run":"/gsd-plan-phase {decimal_phase}"}'
```
If "Roadmap Evolution" section doesn't exist, create it.
(Adjust field names to whatever pointers STATE.md exposes — the handler
reports which fields it matched.)
2. Append a Roadmap Evolution entry via the dedicated handler. It creates the
`### Roadmap Evolution` subsection under `## Accumulated Context` if missing
and dedupes identical entries:
```bash
gsd-sdk query state.add-roadmap-evolution \
--phase {decimal_phase} \
--action inserted \
--after {after_phase} \
--note "{description}" \
--urgent
```
Expected response shape: `{ added: true, entry: "- Phase ... (URGENT)" }`
(or `{ added: false, reason: "duplicate", entry: ... }` on replay).
</step>
<step name="completion">
@@ -129,6 +145,7 @@ Phase insertion is complete when:
- [ ] `gsd-sdk query phase.insert` executed successfully
- [ ] Phase directory created
- [ ] Roadmap updated with new phase entry (includes "(INSERTED)" marker)
- [ ] STATE.md updated with roadmap evolution note
- [ ] `gsd-sdk query state.add-roadmap-evolution ...` returned `{ added: true }` or `{ added: false, reason: "duplicate" }`
- [ ] `gsd-sdk query state.patch` returned matched next-phase pointer field(s)
- [ ] User informed of next steps and dependency implications
</success_criteria>

View File

@@ -23,7 +23,7 @@ INIT=$(gsd-sdk query init.manager)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
```
Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_count`, `in_progress_count`, `phases`, `recommended_actions`, `all_complete`, `waiting_signal`, `manager_flags`.
Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_count`, `in_progress_count`, `phases`, `recommended_actions`, `all_complete`, `waiting_signal`, `manager_flags`, and the optional trio `queued_milestone_version`, `queued_milestone_name`, `queued_phases` (added in SDK fix `2495-2496-2497` — may be absent on older SDK versions, treat missing as empty).
`manager_flags` contains per-step passthrough flags from config:
- `manager_flags.discuss` — appended to `/gsd-discuss-phase` args (e.g. `"--auto --analyze"`)
@@ -103,6 +103,28 @@ Example output:
| 6 | Polish & Final Mail… | 1-5 | · | · | · | · Up next |
```
**Queued section (next milestone preview):**
If `queued_phases` is present and non-empty, render a compact preview of the next milestone's phases directly below the main table. This surfaces upcoming work without cluttering the active-milestone grid. Skip this section entirely when `queued_phases` is empty or missing (e.g. the active milestone is the last one in the roadmap).
Use `queued_milestone_version` and `queued_milestone_name` for the header. Phases render without D/P/E columns since they aren't discussed yet — just number, name (pre-truncated `display_name`), dependencies (`deps_display`), and a fixed `· Queued` status. Phase-name padding should match the active-table column width for visual alignment.
Example:
```
───────────────────────────────────────────────────────────────
◆ Queued — {queued_milestone_version} {queued_milestone_name} ({queued_phases.length} phases)
───────────────────────────────────────────────────────────────
| # | Phase | Deps | Status |
|---|----------------------|------|--------------|
| 31| Email Logs | — | · Queued |
| 32| Today's Sheets | 31 | · Queued |
| 33| Resend Backfill | 31 | · Queued |
| 34| Business Day Audit | 31 | · Queued |
```
Queued phases are NOT eligible for the Continue action menu — they live in a future milestone and must wait for the current milestone to ship. The preview exists purely for situational awareness.
**Recommendations section:**
If `all_complete` is true:
@@ -362,4 +384,5 @@ Display final status with progress bar:
- [ ] Exit shows final status with resume instructions
- [ ] "Other" free-text input parsed for phase number and action
- [ ] Manager loop continues until user exits or milestone completes
- [ ] Queued section renders when `queued_phases` is non-empty; skipped when absent or empty
</success_criteria>

View File

@@ -27,13 +27,51 @@ Documents are reference material for Claude when planning/executing. Always incl
<process>
<step name="parse_paths_flag" priority="first">
Parse an optional `--paths <p1,p2,...>` argument. When supplied (by the
post-execute codebase-drift gate in `/gsd-execute-phase` or by a user running
`/gsd-map-codebase --paths apps/accounting,packages/ui`), the workflow
operates in **incremental-remap mode**:
- Pass `--paths <p1>,<p2>,...` through to each spawned `gsd-codebase-mapper`
agent's prompt. Agents scope their Glob/Grep/Bash exploration to the listed
repo-relative prefixes only — no whole-repo scan.
- Reject path values that contain `..`, start with `/`, or include shell
metacharacters (`;`, `` ` ``, `$`, `&`, `|`, `<`, `>`). If all provided
paths are invalid, fall back to a normal whole-repo run.
- On write, each mapper stamps `last_mapped_commit: <HEAD sha>` into the YAML
frontmatter of every document it produces (see `bin/lib/drift.cjs:writeMappedCommit`).
**Explicit contract — propagate `--paths` through a single normalized
variable.** Downstream steps (`spawn_agents`, `sequential_mapping`, and any
Task-mode prompt construction) MUST use `${PATH_SCOPE_HINT}` to ensure every
mapper receives the same deterministic scope. Without this contract
incremental-remap can silently regress to a whole-repo scan.
```bash
# Validated, comma-separated paths (empty if --paths absent or all rejected):
SCOPED_PATHS="<validated paths or empty>"
if [ -n "$SCOPED_PATHS" ]; then
PATH_SCOPE_HINT="--paths $SCOPED_PATHS"
else
PATH_SCOPE_HINT=""
fi
```
All mapper prompts built later in this workflow MUST include
`${PATH_SCOPE_HINT}` (expanded to empty when full-repo mode is in effect).
When `--paths` is absent, behave exactly as before: full-repo scan, all 7
documents refreshed.
</step>
<step name="init_context" priority="first">
Load codebase mapping context:
```bash
INIT=$(gsd-sdk query init.map-codebase)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_MAPPER=$(gsd-sdk query agent-skills gsd-codebase-mapper 2>/dev/null)
AGENT_SKILLS_MAPPER=$(gsd-sdk query agent-skills gsd-codebase-mapper)
```
Extract from init JSON: `mapper_model`, `commit_docs`, `codebase_dir`, `existing_maps`, `has_maps`, `codebase_dir_exists`, `subagent_timeout`, `date`.
@@ -124,6 +162,8 @@ Write these documents to .planning/codebase/:
IMPORTANT: Use {date} for all [YYYY-MM-DD] date placeholders in documents.
Scope: ${PATH_SCOPE_HINT:-(full repo)} — when --paths is supplied, restrict exploration to those prefixes only.
Explore thoroughly. Write documents directly using templates. Return confirmation only.
${AGENT_SKILLS_MAPPER}"
)
@@ -148,6 +188,8 @@ Write these documents to .planning/codebase/:
IMPORTANT: Use {date} for all [YYYY-MM-DD] date placeholders in documents.
Scope: ${PATH_SCOPE_HINT:-(full repo)} — when --paths is supplied, restrict exploration to those prefixes only.
Explore thoroughly. Write documents directly using templates. Return confirmation only.
${AGENT_SKILLS_MAPPER}"
)
@@ -172,6 +214,8 @@ Write these documents to .planning/codebase/:
IMPORTANT: Use {date} for all [YYYY-MM-DD] date placeholders in documents.
Scope: ${PATH_SCOPE_HINT:-(full repo)} — when --paths is supplied, restrict exploration to those prefixes only.
Explore thoroughly. Write documents directly using templates. Return confirmation only.
${AGENT_SKILLS_MAPPER}"
)
@@ -195,6 +239,8 @@ Write this document to .planning/codebase/:
IMPORTANT: Use {date} for all [YYYY-MM-DD] date placeholders in documents.
Scope: ${PATH_SCOPE_HINT:-(full repo)} — when --paths is supplied, restrict exploration to those prefixes only.
Explore thoroughly. Write document directly using template. Return confirmation only.
${AGENT_SKILLS_MAPPER}"
)
@@ -246,6 +292,8 @@ When the `Task` tool is unavailable, perform codebase mapping sequentially in th
**IMPORTANT:** Use `{date}` from init context for all `[YYYY-MM-DD]` date placeholders in documents. NEVER guess the date.
**SCOPE:** When `${PATH_SCOPE_HINT}` is non-empty (i.e. `--paths` was supplied), restrict every pass below to the validated path prefixes in `${SCOPED_PATHS}`. Do NOT scan files outside those prefixes. When `${PATH_SCOPE_HINT}` is empty, perform a full-repo scan.
Perform all 4 mapping passes sequentially:
**Pass 1: Tech Focus**

View File

@@ -173,6 +173,19 @@ This document evolves at phase transitions and milestone boundaries.
## 5. Update STATE.md
Reset STATE.md frontmatter AND body atomically via the SDK. This writes the new
milestone version/name into the YAML frontmatter, resets `status` to
`planning`, zeroes `progress.*` counters, and rewrites the `## Current Position`
section to the new-milestone template. Accumulated Context (decisions,
blockers, todos) is preserved across the switch — symmetric with
`milestone.complete`.
```bash
gsd-sdk query state.milestone-switch --milestone "v[X.Y]" --name "[Name]"
```
The resulting Current Position section looks like:
```markdown
## Current Position
@@ -182,7 +195,11 @@ Status: Defining requirements
Last activity: [today] — Milestone v[X.Y] started
```
Keep Accumulated Context section from previous milestone.
Bug #2630: a prior version of this workflow rewrote the Current Position body
manually but left the frontmatter pointing at the previous milestone, so every
downstream reader (`state.json`, `getMilestoneInfo`, progress bars) reported the
stale milestone until the first phase advance forced a resync. Always use the
SDK handler above — do not hand-edit STATE.md here.
## 6. Cleanup and Commit
@@ -203,9 +220,9 @@ gsd-sdk query commit "docs: start milestone v[X.Y] [Name]" .planning/PROJECT.md
```bash
INIT=$(gsd-sdk query init.new-milestone)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-project-researcher 2>/dev/null)
AGENT_SKILLS_SYNTHESIZER=$(gsd-sdk query agent-skills gsd-synthesizer 2>/dev/null)
AGENT_SKILLS_ROADMAPPER=$(gsd-sdk query agent-skills gsd-roadmapper 2>/dev/null)
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-project-researcher)
AGENT_SKILLS_SYNTHESIZER=$(gsd-sdk query agent-skills gsd-research-synthesizer)
AGENT_SKILLS_ROADMAPPER=$(gsd-sdk query agent-skills gsd-roadmapper)
```
Extract from init JSON: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `research_enabled`, `current_milestone`, `project_exists`, `roadmap_exists`, `latest_completed_milestone`, `phase_dir_count`, `phase_archive_path`, `agents_installed`, `missing_agents`.

View File

@@ -59,9 +59,9 @@ The document should describe what you want to build.
```bash
INIT=$(gsd-sdk query init.new-project)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-project-researcher 2>/dev/null)
AGENT_SKILLS_SYNTHESIZER=$(gsd-sdk query agent-skills gsd-synthesizer 2>/dev/null)
AGENT_SKILLS_ROADMAPPER=$(gsd-sdk query agent-skills gsd-roadmapper 2>/dev/null)
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-project-researcher)
AGENT_SKILLS_SYNTHESIZER=$(gsd-sdk query agent-skills gsd-research-synthesizer)
AGENT_SKILLS_ROADMAPPER=$(gsd-sdk query agent-skills gsd-roadmapper)
```
Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `project_exists`, `has_codebase_map`, `planning_exists`, `has_existing_code`, `has_package_file`, `is_brownfield`, `needs_codebase_map`, `has_git`, `project_path`, `agents_installed`, `missing_agents`.

View File

@@ -33,9 +33,9 @@ Load all context in one call (paths only to minimize orchestrator context):
```bash
INIT=$(gsd-sdk query init.plan-phase "$PHASE")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-researcher 2>/dev/null)
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner 2>/dev/null)
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-checker 2>/dev/null)
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-phase-researcher)
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner)
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-plan-checker)
CONTEXT_WINDOW=$(gsd-sdk query config-get context_window 2>/dev/null || echo "200000")
TDD_MODE=$(gsd-sdk query config-get workflow.tdd_mode 2>/dev/null || echo "false")
```
@@ -1310,6 +1310,72 @@ Options:
If `TEXT_MODE` is true, present as a plain-text numbered list (options already shown in the block above). Otherwise use AskUserQuestion to present the options.
## 13a. Decision Coverage Gate
After the requirements coverage gate passes, verify that every trackable
decision captured by discuss-phase in CONTEXT.md `<decisions>` is referenced
by at least one plan. This is the **translation gate** from issue #2492 —
its job is to refuse to mark a phase planned when a discuss-phase decision
silently dropped on the way into the plans.
**Skip if** `workflow.context_coverage_gate` is explicitly set to `false`
(absent key = enabled). Also skip if no CONTEXT.md exists for this phase
(nothing to translate) or if its `<decisions>` block is empty.
```bash
GATE_CFG=$(gsd-sdk query config-get workflow.context_coverage_gate 2>/dev/null || echo "true")
if [ "$GATE_CFG" != "false" ]; then
GATE_RESULT=$(gsd-sdk query check.decision-coverage-plan "${PHASE_DIR}" "${CONTEXT_PATH}")
# BLOCKING: refuse to mark phase planned when a trackable decision is uncovered.
# `passed: true` covers both real-pass and skipped cases (gate disabled / no CONTEXT.md /
# no trackable decisions). Verify-phase counterpart deliberately omits this exit-1 — that
# gate is non-blocking by design (review finding F15).
echo "$GATE_RESULT" | jq -e '.data.passed == true' >/dev/null || {
echo "$GATE_RESULT" | jq -r '.data.message'
exit 1
}
fi
```
The handler returns JSON:
```json
{
"passed": true,
"skipped": false,
"total": 2,
"covered": 2,
"uncovered": [ { "id": "D-01", "text": "...", "category": "..." } ],
"message": "..."
}
```
**If `passed` is true (or `skipped` is true):** Display
`✓ Decision coverage: {M}/{N} CONTEXT.md decisions covered by plans` (or
`(skipped — gate disabled)` / `(skipped — no decisions)`) and proceed to
step 13b.
**If `passed` is false:** Display the handler's `message` block. It already
names each uncovered decision (`D-NN | category | text`) and tells the user
what to do — cite the id in a relevant plan's `must_haves` / `truths`, or
move the decision under `### Claude's Discretion` / tag it `[informational]`
if it should not be tracked. Then offer:
```text
Options:
1. Re-plan to cover missing decisions (recommended)
2. Edit CONTEXT.md to mark dropped decisions as [informational] / Discretion
3. Proceed anyway — accept the coverage gap
```
If `TEXT_MODE` is true, present as a plain-text numbered list. Otherwise use
AskUserQuestion. Selecting "Proceed anyway" continues to step 13b but
records the override in STATE.md so verify-phase can re-surface it.
**Why this gate blocks:** failing here is cheap. The plans are the contract
between discuss-phase and execute-phase; if a decision isn't visible in any
plan, no executor will implement it. Catching that now beats discovering it
after thousands of dollars of execution.
## 13b. Record Planning Completion in STATE.md
After plans pass all gates, record that planning is complete so STATE.md reflects the new phase status:
@@ -1344,6 +1410,53 @@ gsd-sdk query commit "docs(${PADDED_PHASE}): create phase plan" --files "${PHASE
This commits all PLAN.md files for the phase plus the updated STATE.md and ROADMAP.md to version-control the planning artifacts. Skip this step if `commit_docs` is false.
## 13e. Post-Planning Gap Analysis
After all plans are generated, committed, and the Requirements Coverage Gate (§13)
has run, emit a single unified gap report covering both REQUIREMENTS.md and the
CONTEXT.md `<decisions>` section. This is a **proactive, post-hoc report** — it
does not block phase advancement and does not re-plan. It exists so that any
requirement or decision that slipped through the per-plan checks is surfaced in
one place before execution begins.
**Skip if:** `workflow.post_planning_gaps` is `false`. Default is `true`.
```bash
POST_PLANNING_GAPS=$(gsd-sdk query config-get workflow.post_planning_gaps --default true 2>/dev/null || echo true)
if [ "$POST_PLANNING_GAPS" = "true" ]; then
gsd-tools gap-analysis --phase-dir "${PHASE_DIR}"
fi
```
(`gsd-tools gap-analysis` reads `.planning/REQUIREMENTS.md`, `${PHASE_DIR}/CONTEXT.md`,
and `${PHASE_DIR}/*-PLAN.md`, then prints a markdown table with one row per
REQ-ID and D-ID. Word-boundary matching prevents `REQ-1` from being mistaken for
`REQ-10`.)
**Output format (deterministic; sorted REQUIREMENTS.md → CONTEXT.md, then natural
sort within source):**
```
## Post-Planning Gap Analysis
| Source | Item | Status |
|--------|------|--------|
| REQUIREMENTS.md | REQ-01 | ✓ Covered |
| REQUIREMENTS.md | REQ-02 | ✗ Not covered |
| CONTEXT.md | D-01 | ✓ Covered |
| CONTEXT.md | D-02 | ✗ Not covered |
⚠ N items not covered by any plan
```
**Skip-gracefully behavior:**
- REQUIREMENTS.md missing → CONTEXT-only report.
- CONTEXT.md missing → REQUIREMENTS-only report.
- Both missing or `<decisions>` block missing → "No requirements or decisions to check" line, no error.
This step is non-blocking. If items are reported as not covered, the user may
re-run `/gsd-plan-phase --gaps` to add plans, or proceed to execute-phase as-is.
## 14. Present Final Status
Route to `<offer_next>` OR `auto_advance` depending on flags/config.
@@ -1357,7 +1470,7 @@ Check for auto-advance trigger using values already loaded in step 1:
3. **Sync chain flag with intent** — if user invoked manually (no `--auto` and no `--chain`), clear the ephemeral chain flag from any previous interrupted `--auto` chain. This does NOT touch `workflow.auto_advance` (the user's persistent settings preference):
```bash
if [[ ! "$ARGUMENTS" =~ --auto ]] && [[ ! "$ARGUMENTS" =~ --chain ]]; then
gsd-sdk query config-set workflow._auto_chain_active false 2>/dev/null
gsd-sdk query config-set workflow._auto_chain_active false || true
fi
```

View File

@@ -271,7 +271,7 @@ Write updated analysis JSON back to `$ANALYSIS_PATH`.
Display: "◆ Writing profile..."
```bash
gsd-sdk query write-profile --input "$ANALYSIS_PATH" --json 2>/dev/null
gsd-sdk query write-profile --input "$ANALYSIS_PATH" --json
```
Display: "✓ Profile written to $HOME/.claude/get-shit-done/USER-PROFILE.md"
@@ -350,7 +350,7 @@ Generate selected artifacts sequentially (file I/O is fast, no benefit from para
**For /gsd-dev-preferences (if selected):**
```bash
gsd-sdk query generate-dev-preferences --analysis "$ANALYSIS_PATH" --json 2>/dev/null
gsd-sdk query generate-dev-preferences --analysis "$ANALYSIS_PATH" --json
```
Display: "✓ Generated /gsd-dev-preferences at $HOME/.claude/commands/gsd/dev-preferences.md"
@@ -358,7 +358,7 @@ Display: "✓ Generated /gsd-dev-preferences at $HOME/.claude/commands/gsd/dev-p
**For CLAUDE.md profile section (if selected):**
```bash
gsd-sdk query generate-claude-profile --analysis "$ANALYSIS_PATH" --json 2>/dev/null
gsd-sdk query generate-claude-profile --analysis "$ANALYSIS_PATH" --json
```
Display: "✓ Added profile section to CLAUDE.md"
@@ -366,7 +366,7 @@ Display: "✓ Added profile section to CLAUDE.md"
**For Global CLAUDE.md (if selected):**
```bash
gsd-sdk query generate-claude-profile --analysis "$ANALYSIS_PATH" --global --json 2>/dev/null
gsd-sdk query generate-claude-profile --analysis "$ANALYSIS_PATH" --global --json
```
Display: "✓ Added profile section to $HOME/.claude/CLAUDE.md"

View File

@@ -140,10 +140,10 @@ fi
```bash
INIT=$(gsd-sdk query init.quick "$DESCRIPTION")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner 2>/dev/null)
AGENT_SKILLS_EXECUTOR=$(gsd-sdk query agent-skills gsd-executor 2>/dev/null)
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-checker 2>/dev/null)
AGENT_SKILLS_VERIFIER=$(gsd-sdk query agent-skills gsd-verifier 2>/dev/null)
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner)
AGENT_SKILLS_EXECUTOR=$(gsd-sdk query agent-skills gsd-executor)
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-plan-checker)
AGENT_SKILLS_VERIFIER=$(gsd-sdk query agent-skills gsd-verifier)
```
Parse JSON for: `planner_model`, `executor_model`, `checker_model`, `verifier_model`, `commit_docs`, `branch_name`, `quick_id`, `slug`, `date`, `timestamp`, `quick_dir`, `task_dir`, `roadmap_exists`, `planning_exists`.

View File

@@ -42,7 +42,7 @@ If exists: Offer update/view/skip options.
INIT=$(gsd-sdk query init.phase-op "${PHASE}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
# Extract: phase_dir, padded_phase, phase_number, state_path, requirements_path, context_path
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-researcher 2>/dev/null)
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-phase-researcher)
```
## Step 4: Spawn Researcher

View File

@@ -18,7 +18,7 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
```bash
INIT=$(gsd-sdk query init.phase-op "${PHASE_ARG}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_AUDITOR=$(gsd-sdk query agent-skills gsd-security-auditor 2>/dev/null)
AGENT_SKILLS_AUDITOR=$(gsd-sdk query agent-skills gsd-security-auditor)
```
Parse: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`.

View File

@@ -0,0 +1,435 @@
<purpose>
Interactive configuration of GSD power-user knobs — plan bounce, node repair, subagent timeouts,
inline plan threshold, cross-AI execution, base branch, branch templates, response language,
context window, gitignored search, and graphify build timeout.
This is a companion to `/gsd-settings` — the common-case prompt there covers model profile,
research/plan_check/verifier toggles, branching strategy, UI/AI phase gates, and worktree
isolation. This advanced command covers everything else that is user-settable, grouped into
six sections so each prompt batch stays cognitively scoped. Every answer pre-selects the
current value; numeric-input answers that are non-numeric are rejected and re-prompted.
</purpose>
<required_reading>
Read all files referenced by the invoking prompt's execution_context before starting.
</required_reading>
<process>
<step name="ensure_and_load_config">
Ensure config exists and resolve the workstream-aware config path (mirrors `settings.md`):
```bash
gsd-sdk query config-ensure-section
if [[ -z "${GSD_CONFIG_PATH:-}" ]]; then
if [[ -f .planning/active-workstream ]]; then
WS=$(tr -d '\n\r' < .planning/active-workstream)
GSD_CONFIG_PATH=".planning/workstreams/${WS}/config.json"
else
GSD_CONFIG_PATH=".planning/config.json"
fi
fi
```
All subsequent reads and writes go through `$GSD_CONFIG_PATH`. Never hardcode
`.planning/config.json` — workstream installs must route to their own config file.
</step>
<step name="read_current">
```bash
cat "$GSD_CONFIG_PATH"
```
Parse the following current values. If a key is absent, fall back to the documented default
shown in parentheses:
Planning Tuning:
- `workflow.plan_bounce` (default: `false`)
- `workflow.plan_bounce_passes` (default: `2`)
- `workflow.plan_bounce_script` (default: `null`)
- `workflow.subagent_timeout` (default: `600`)
- `workflow.inline_plan_threshold` (default: `3`)
Execution Tuning:
- `workflow.node_repair` (default: `true`)
- `workflow.node_repair_budget` (default: `2`)
- `workflow.auto_prune_state` (default: `false`)
Discussion Tuning:
- `workflow.max_discuss_passes` (default: `3`)
Cross-AI Execution:
- `workflow.cross_ai_execution` (default: `false`)
- `workflow.cross_ai_command` (default: `null`)
- `workflow.cross_ai_timeout` (default: `300`)
Git Customization:
- `git.base_branch` (default: `main`)
- `git.phase_branch_template` (default: `gsd/phase-{phase}-{slug}`)
- `git.milestone_branch_template` (default: `gsd/{milestone}-{slug}`)
Runtime / Output:
- `response_language` (default: `null`)
- `context_window` (default: `200000`)
- `search_gitignored` (default: `false`)
- `graphify.build_timeout` (default: `300`)
Each field's **current value is pre-selected** in the prompt rendering below. When the
current value is absent from the config, render the documented default as the pre-selected
option so the user sees what the effective value is.
</step>
<step name="present_settings">
**Text mode (`workflow.text_mode: true` or `--text` flag):** Set `TEXT_MODE=true` if `--text` is
in `$ARGUMENTS` OR `text_mode` is true in config. When `TEXT_MODE=true`, replace every
`AskUserQuestion` call below with a plain-text numbered list and ask the user to type the
choice number or free-text value.
**Numeric-input validation.** For any numeric field (`*_passes`, `*_budget`, `*_timeout`,
`*_threshold`, `context_window`, `graphify.build_timeout`), if the user types a value that
is not a non-negative integer, the workflow MUST reject it, state which value was invalid,
and re-prompt that single field. The minimum accepted value is field-specific and is stated
in each field's prompt below — `workflow.plan_bounce_passes` and `workflow.max_discuss_passes`
require `>= 1`; all other numeric fields accept `>= 0`. An empty input means "keep current"
— the existing value is retained. Non-numeric input is never silently coerced.
**Free-text validation.** For branch template fields (`git.phase_branch_template`,
`git.milestone_branch_template`), if the user supplies a non-default value, it MUST be
non-empty and SHOULD contain at least one `{placeholder}`. A template missing placeholders
is rejected with a message explaining the available variables (`{phase}`, `{slug}`,
`{milestone}`) and re-prompted. An empty input means "keep current."
**Null-allowed fields.** For `response_language`, `workflow.plan_bounce_script`,
`workflow.cross_ai_command`: an empty input clears the field (`null`). A non-empty input is
stored verbatim as a string.
---
### Section 1 — Planning Tuning
```text
AskUserQuestion([
{
question: "Run external plan-bounce validator against generated PLAN.md? (current: <value or false>)",
header: "Plan Bounce",
multiSelect: false,
options: [
{ label: "No (default: false)", description: "Skip external plan validation." },
{ label: "Yes", description: "Pipe each PLAN.md through `plan_bounce_script` and block on non-zero exit." }
]
},
{
question: "How many plan-bounce passes? (current: <value or 2>)",
header: "Bounce Passes",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave the existing value unchanged." },
{ label: "Enter number", description: "Type an integer >= 1. Non-numeric input is rejected and re-prompted. Default: 2" }
]
},
{
question: "Path to plan-bounce validation script? (current: <value or null>)",
header: "Bounce Script",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave existing path unchanged." },
{ label: "Clear (null)", description: "Unset the script path." },
{ label: "Enter path", description: "Type an absolute or repo-relative path. Receives PLAN.md path as first argument." }
]
},
{
question: "Subagent timeout (seconds)? (current: <value or 600>)",
header: "Subagent Timeout",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave timeout unchanged." },
{ label: "Enter seconds", description: "Integer number of seconds. Non-numeric rejected. Default: 600" }
]
},
{
question: "Inline plan threshold — tasks allowed inline before splitting to PLAN.md? (current: <value or 3>)",
header: "Inline Plan Threshold",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave threshold unchanged." },
{ label: "Enter number", description: "Integer count. Non-numeric rejected. Default: 3" }
]
}
])
```
### Section 2 — Execution Tuning
```text
AskUserQuestion([
{
question: "Enable autonomous node repair on verification failure? (current: <value or true>)",
header: "Node Repair",
multiSelect: false,
options: [
{ label: "Yes (default: true)", description: "Executor retries failed tasks up to the repair budget." },
{ label: "No", description: "Stop on first verification failure." }
]
},
{
question: "Maximum node-repair attempts per failed task? (current: <value or 2>)",
header: "Repair Budget",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave existing budget unchanged." },
{ label: "Enter number", description: "Integer >= 0. Non-numeric rejected. Default: 2" }
]
},
{
question: "Auto-prune stale STATE.md entries at phase boundaries? (current: <value or false>)",
header: "Auto Prune",
multiSelect: false,
options: [
{ label: "No (default: false)", description: "Prompt before pruning." },
{ label: "Yes", description: "Prune stale entries without prompting." }
]
}
])
```
### Section 3 — Discussion Tuning
```text
AskUserQuestion([
{
question: "Maximum discuss-phase question rounds? (current: <value or 3>)",
header: "Max Discuss Passes",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave existing value unchanged." },
{ label: "Enter number", description: "Integer >= 1. Non-numeric rejected. Default: 3. Prevents infinite discussion loops in headless mode." }
]
}
])
```
### Section 4 — Cross-AI Execution
```text
AskUserQuestion([
{
question: "Delegate phase execution to an external AI CLI? (current: <value or false>)",
header: "Cross-AI",
multiSelect: false,
options: [
{ label: "No (default: false)", description: "Use local executor agents." },
{ label: "Yes", description: "Pipe phase prompt to `cross_ai_command` via stdin. Requires command to be set." }
]
},
{
question: "Cross-AI command template? (current: <value or null>)",
header: "Cross-AI Command",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave command unchanged." },
{ label: "Clear (null)", description: "Unset the command." },
{ label: "Enter command", description: "Shell command receiving phase prompt via stdin. Must produce SUMMARY.md-compatible output." }
]
},
{
question: "Cross-AI timeout (seconds)? (current: <value or 300>)",
header: "Cross-AI Timeout",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave timeout unchanged." },
{ label: "Enter seconds", description: "Integer seconds. Non-numeric rejected. Default: 300" }
]
}
])
```
### Section 5 — Git Customization
```text
AskUserQuestion([
{
question: "Git base branch? (current: <value or main>)",
header: "Base Branch",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave base branch unchanged." },
{ label: "Enter branch name", description: "e.g., main, master, develop. Integration branch for phase/milestone branches." }
]
},
{
question: "Phase branch template? (current: <value or gsd/phase-{phase}-{slug}>)",
header: "Phase Template",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave template unchanged." },
{ label: "Enter template", description: "Non-empty string with at least one placeholder. Available: {phase}, {slug}. Non-default values missing placeholders are rejected." }
]
},
{
question: "Milestone branch template? (current: <value or gsd/{milestone}-{slug}>)",
header: "Milestone Template",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave template unchanged." },
{ label: "Enter template", description: "Non-empty string. Available placeholders: {milestone}, {slug}. Non-default values missing placeholders are rejected." }
]
}
])
```
### Section 6 — Runtime / Output
```text
AskUserQuestion([
{
question: "Response language for agent output? (current: <value or null>)",
header: "Language",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave unchanged." },
{ label: "Clear (null)", description: "Use Claude default (English)." },
{ label: "Enter language", description: "Free-text language name or code (e.g., Japanese, pt, ko). Propagates to spawned agents." }
]
},
{
question: "Context window size (tokens)? (current: <value or 200000>)",
header: "Context Window",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave unchanged." },
{ label: "Enter number", description: "Integer. Non-numeric rejected. Default: 200000. Use 1000000 for 1M-context models. Values >= 500000 enable adaptive enrichment." }
]
},
{
question: "Include gitignored files in broad searches? (current: <value or false>)",
header: "Search Gitignored",
multiSelect: false,
options: [
{ label: "No (default: false)", description: "Respect .gitignore during searches." },
{ label: "Yes", description: "Add --no-ignore to broad searches (includes .planning/)." }
]
},
{
question: "Graphify build timeout (seconds)? (current: <value or 300>)",
header: "Graphify Timeout",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave timeout unchanged." },
{ label: "Enter seconds", description: "Integer seconds. Non-numeric rejected. Default: 300" }
]
}
])
```
</step>
<step name="update_config">
Merge the new settings into the existing config at `$GSD_CONFIG_PATH`. This merge is the
core correctness invariant: **preserve every unrelated key** — do not clobber siblings.
Apply each selected value via `gsd-sdk query config-set <key> <value>` so the central
validator (`isValidConfigKey`) accepts the write and the deep-merge preserves unrelated
keys and sibling sub-objects.
```bash
# Example — only write keys the user changed. "Keep current" selections are skipped.
gsd-sdk query config-set workflow.plan_bounce_passes 5
gsd-sdk query config-set workflow.subagent_timeout 900
gsd-sdk query config-set git.base_branch main
gsd-sdk query config-set context_window 1000000
```
Conceptual shape after merge (unchanged top-level keys like `model_profile`,
`granularity`, `mode`, `brave_search`, `agent_skills.*`, `hooks.context_warnings`, and
anything not listed in Sections 16 MUST survive the update):
```json
{
...existing_config,
"workflow": {
...existing_workflow,
"plan_bounce": <new|existing>,
"plan_bounce_passes": <new|existing>,
"plan_bounce_script": <new|existing|null>,
"subagent_timeout": <new|existing>,
"inline_plan_threshold": <new|existing>,
"node_repair": <new|existing>,
"node_repair_budget": <new|existing>,
"auto_prune_state": <new|existing>,
"max_discuss_passes": <new|existing>,
"cross_ai_execution": <new|existing>,
"cross_ai_command": <new|existing|null>,
"cross_ai_timeout": <new|existing>
},
"git": {
...existing_git,
"base_branch": <new|existing>,
"phase_branch_template": <new|existing>,
"milestone_branch_template": <new|existing>
},
"response_language": <new|existing|null>,
"context_window": <new|existing>,
"search_gitignored": <new|existing>,
"graphify": {
...existing_graphify,
"build_timeout": <new|existing>
}
}
```
Never emit a full overwrite of the file that omits keys the user did not touch. Always
route each write through `gsd-sdk query config-set` so sibling preservation is handled by
the central setter.
</step>
<step name="confirm">
Display:
```text
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GSD ► ADVANCED SETTINGS UPDATED
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
| Setting | Value |
|--------------------------------|-------|
| workflow.plan_bounce | {on/off} |
| workflow.plan_bounce_passes | {n} |
| workflow.plan_bounce_script | {path/null} |
| workflow.subagent_timeout | {seconds} |
| workflow.inline_plan_threshold | {n} |
| workflow.node_repair | {on/off} |
| workflow.node_repair_budget | {n} |
| workflow.auto_prune_state | {on/off} |
| workflow.max_discuss_passes | {n} |
| workflow.cross_ai_execution | {on/off} |
| workflow.cross_ai_command | {cmd/null} |
| workflow.cross_ai_timeout | {seconds} |
| git.base_branch | {branch} |
| git.phase_branch_template | {template} |
| git.milestone_branch_template | {template} |
| response_language | {lang/null} |
| context_window | {tokens} |
| search_gitignored | {on/off} |
| graphify.build_timeout | {seconds} |
These settings apply to future /gsd-plan-phase, /gsd-execute-phase, /gsd-discuss-phase,
and /gsd-ship runs.
For common-case toggles (model profile, research/plan_check/verifier, branching strategy,
UI/AI phase gates), use /gsd-settings.
```
</step>
</process>
<success_criteria>
- [ ] Current config read from resolved `$GSD_CONFIG_PATH`
- [ ] Six sections rendered (Planning, Execution, Discussion, Cross-AI, Git, Runtime)
- [ ] Every field pre-selected to its current value (or documented default if absent)
- [ ] Numeric inputs validated — non-numeric rejected and re-prompted
- [ ] Branch-template inputs validated — non-default must contain a placeholder
- [ ] Null-allowed fields accept an empty input as a clear
- [ ] Writes routed through `gsd-sdk query config-set` so unrelated keys are preserved
- [ ] Confirmation table rendered listing all 19 fields
</success_criteria>

View File

@@ -0,0 +1,281 @@
<purpose>
Interactive configuration of third-party integrations for GSD — search API keys
(Brave / Firecrawl / Exa), code-review CLI routing (`review.models.<cli>`), and
agent-skill injection (`agent_skills.<agent-type>`). Writes to
`.planning/config.json` via `gsd-sdk`/`gsd-tools` so unrelated keys are
preserved, never clobbered.
This command is deliberately separate from `/gsd-settings` (workflow toggles)
and any `/gsd-settings-advanced` tuning surface. It exists because API keys and
cross-tool routing are *connectivity* concerns, not workflow or tuning knobs.
</purpose>
<security>
**API keys are secrets.** They are written as plaintext to
`.planning/config.json` — that is where secrets live on disk, and file
permissions are the security boundary. The UI must never display, echo, or
log the plaintext value. The workflow follows these rules:
- **Masking convention: `****<last-4>`** (e.g. `sk-abc123def456``****f456`).
Strings shorter than 8 characters render as `****` with no tail so a short
secret does not leak a meaningful fraction of its bytes. Unset values render
as `(unset)`.
- **Plaintext is never echoed by AskUserQuestion descriptions, confirmation
tables, or any log line.** It is not written to any file under `.planning/`
other than `config.json` itself.
- **`config-set` output is masked** for keys in the secret set
(`brave_search`, `firecrawl`, `exa_search`) — see
`get-shit-done/bin/lib/secrets.cjs`.
- **Agent-type and CLI slug validation.** `agent_skills.<agent-type>` and
`review.models.<cli>` keys are matched against `^[a-zA-Z0-9_-]+$`. Inputs
containing path separators (`/`, `\`, `..`), whitespace, or shell
metacharacters are rejected. This closes off skill-injection attacks.
</security>
<required_reading>
Read all files referenced by the invoking prompt's execution_context before starting.
</required_reading>
<process>
<step name="ensure_and_load_config">
Ensure config exists and resolve the active config path (flat vs workstream, #2282):
```bash
gsd-sdk query config-ensure-section
if [[ -z "${GSD_CONFIG_PATH:-}" ]]; then
if [[ -f .planning/active-workstream ]]; then
WS=$(tr -d '\n\r' < .planning/active-workstream)
GSD_CONFIG_PATH=".planning/workstreams/${WS}/config.json"
else
GSD_CONFIG_PATH=".planning/config.json"
fi
fi
```
Store `$GSD_CONFIG_PATH`. Every subsequent read/write uses it.
</step>
<step name="read_current">
Read the current config and compute a masked view for display. For each
integration field, compute one of:
- `(unset)` — field is null / missing
- `****<last-4>` — secret field that is populated (plaintext never shown)
- `<value>` — non-secret routing/skill string, shown as-is
```bash
BRAVE=$(gsd-sdk query config-get brave_search --default null)
FIRECRAWL=$(gsd-sdk query config-get firecrawl --default null)
EXA=$(gsd-sdk query config-get exa_search --default null)
SEARCH_GITIGNORED=$(gsd-sdk query config-get search_gitignored --default false)
```
For each secret key (`brave_search`, `firecrawl`, `exa_search`) the displayed
value is `****<last-4>` when set, never the raw string. Never echo the
plaintext to stdout, stderr, or any log.
</step>
<step name="section_1_search_integrations">
**Text mode (`workflow.text_mode: true` or `--text` flag):** Set
`TEXT_MODE=true` and replace every `AskUserQuestion` call with a plain-text
numbered list. Required for non-Claude runtimes.
Ask the user what they want to do for each search API key. For keys that are
already set, show `**** already set` and offer Leave / Replace / Clear. For
unset keys, offer Skip / Set.
```text
AskUserQuestion([
{
question: "Brave Search API key — used for web research during plan/discuss phases",
header: "Brave",
multiSelect: false,
options: [
// When already set:
{ label: "Leave (**** already set)", description: "Keep current value" },
{ label: "Replace", description: "Enter a new API key" },
{ label: "Clear", description: "Remove the stored key" }
// When unset:
// { label: "Skip", description: "Leave unset" },
// { label: "Set", description: "Enter an API key" }
]
},
{
question: "Firecrawl API key — used for deep-crawl scraping",
header: "Firecrawl",
multiSelect: false,
options: [ /* same Leave/Replace/Clear or Skip/Set */ ]
},
{
question: "Exa Search API key — used for semantic search",
header: "Exa",
multiSelect: false,
options: [ /* same Leave/Replace/Clear or Skip/Set */ ]
},
{
question: "Include gitignored files in local code searches?",
header: "Gitignored",
multiSelect: false,
options: [
{ label: "No (Recommended)", description: "Respect .gitignore. Safer — excludes secrets, node_modules, build artifacts." },
{ label: "Yes", description: "Include gitignored files. Useful when secrets/artifacts genuinely contain searchable intent." }
]
}
])
```
For each "Set" or "Replace", follow with a text-input prompt that asks for the
key value. **The answer must not be echoed back** in subsequent question
descriptions or confirmation text. Write the value via:
```bash
gsd-sdk query config-set brave_search "<value>" # masked in output
gsd-sdk query config-set firecrawl "<value>" # masked in output
gsd-sdk query config-set exa_search "<value>" # masked in output
gsd-sdk query config-set search_gitignored true|false
```
For "Clear", write `null`:
```bash
gsd-sdk query config-set brave_search null
```
</step>
<step name="section_2_review_models">
`review.models.<cli>` is a map that tells the code-review workflow which
shell command to invoke for a given reviewer flavor. Supported flavors:
`claude`, `codex`, `gemini`, `opencode`.
```text
AskUserQuestion([
{
question: "Which reviewer CLI do you want to configure?",
header: "CLI",
multiSelect: false,
options: [
{ label: "Claude", description: "review.models.claude — defaults to session model when unset" },
{ label: "Codex", description: "review.models.codex — e.g. 'codex exec --model gpt-5'" },
{ label: "Gemini", description: "review.models.gemini — e.g. 'gemini -m gemini-2.5-pro'" },
{ label: "OpenCode", description: "review.models.opencode — e.g. 'opencode run --model claude-sonnet-4'" },
{ label: "Done", description: "Skip — finish this section" }
]
}
])
```
For the selected CLI, show the current value (or `(unset)`) and offer
Leave / Replace / Clear, followed by a text-input prompt for the new command
string. Write via:
```bash
gsd-sdk query config-set review.models.<cli> "<command string>"
```
Loop until the user selects "Done".
The `review.models.<cli>` key is validated by the dynamic pattern
`^review\.models\.[a-zA-Z0-9_-]+$`. Empty CLI slugs and path-containing slugs
are rejected by `config-set` before any write.
</step>
<step name="section_3_agent_skills">
`agent_skills.<agent-type>` injects extra skill names into an agent's spawn
frontmatter. The slug is user-extensible, so input is free-text validated
against `^[a-zA-Z0-9_-]+$`. Inputs with path separators, spaces, or shell
metacharacters are rejected.
```text
AskUserQuestion([
{
question: "Configure agent_skills for which agent type?",
header: "Agent Type",
multiSelect: false,
options: [
{ label: "gsd-executor", description: "Skills injected when spawning executor agents" },
{ label: "gsd-planner", description: "Skills injected when spawning planner agents" },
{ label: "gsd-verifier", description: "Skills injected when spawning verifier agents" },
{ label: "Custom…", description: "Enter a custom agent-type slug" },
{ label: "Done", description: "Skip — finish this section" }
]
}
])
```
For "Custom…", prompt for a slug and validate it matches
`^[a-zA-Z0-9_-]+$`. If it fails validation, print:
```text
Rejected: agent-type '<slug>' must match [a-zA-Z0-9_-]+ (no path separators,
spaces, or shell metacharacters).
```
and re-prompt.
For a selected slug, prompt for the comma-separated skill list (text input).
Show the current value if any, offer Leave / Replace / Clear. Write via:
```bash
gsd-sdk query config-set agent_skills.<slug> "<skill-a,skill-b,skill-c>"
```
Loop until "Done".
</step>
<step name="confirm">
Display the masked confirmation table. **No plaintext API keys appear in this
output under any circumstance.**
```text
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GSD ► INTEGRATIONS UPDATED
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Search Integrations
| Field | Value |
|--------------------|-------------------|
| brave_search | ****<last-4> | (or "(unset)")
| firecrawl | ****<last-4> |
| exa_search | ****<last-4> |
| search_gitignored | true | false |
Code Review CLI Routing
| CLI | Command |
|-------------|--------------------------------------|
| claude | <value or (session model default)> |
| codex | <value or (unset)> |
| gemini | <value or (unset)> |
| opencode | <value or (unset)> |
Agent Skills Injection
| Agent Type | Skills |
|------------------|---------------------------|
| <slug> | <skill-a, skill-b> |
| ... | ... |
Notes:
- API keys are stored plaintext in .planning/config.json. The confirmation
table above never displays plaintext — keys appear as ****<last-4>.
- Plaintext is not echoed back by this workflow, not written to any log,
and not displayed in error messages.
Quick commands:
- /gsd-settings — workflow toggles and model profile
- /gsd-set-profile <profile> — switch model profile
```
</step>
</process>
<success_criteria>
- [ ] Current config read from `$GSD_CONFIG_PATH`
- [ ] User presented with three sections: Search Integrations, Review CLI Routing, Agent Skills Injection
- [ ] API keys written plaintext only to `config.json`; never echoed, never logged, never displayed
- [ ] Masked confirmation table uses `****<last-4>` for set keys and `(unset)` for null
- [ ] `review.models.<cli>` and `agent_skills.<agent-type>` keys validated against `[a-zA-Z0-9_-]+` before write
- [ ] Config merge preserves all keys outside the three sections this workflow owns
</success_criteria>

View File

@@ -40,9 +40,17 @@ Parse current values (default to `true` if not present):
- `workflow.plan_check` — spawn plan checker during plan-phase
- `workflow.verifier` — spawn verifier during execute-phase
- `workflow.nyquist_validation` — validation architecture research during plan-phase (default: true if absent)
- `workflow.pattern_mapper` — run gsd-pattern-mapper between research and planning (default: true if absent)
- `workflow.ui_phase` — generate UI-SPEC.md design contracts for frontend phases (default: true if absent)
- `workflow.ui_safety_gate` — prompt to run /gsd-ui-phase before planning frontend phases (default: true if absent)
- `workflow.ai_integration_phase` — framework selection + eval strategy for AI phases (default: true if absent)
- `workflow.tdd_mode` — enforce RED/GREEN/REFACTOR gate sequence during execute-phase (default: false if absent)
- `workflow.code_review` — enable /gsd-code-review and /gsd-code-review-fix commands (default: true if absent)
- `workflow.code_review_depth` — default depth for /gsd-code-review: `quick`, `standard`, or `deep` (default: `"standard"` if absent; only relevant when `code_review` is on)
- `workflow.ui_review` — run visual quality audit (/gsd-ui-review) in autonomous mode (default: true if absent)
- `commit_docs` — whether `.planning/` files are committed to git (default: true if absent)
- `intel.enabled` — enable queryable codebase intelligence (/gsd-intel) (default: false if absent)
- `graphify.enabled` — enable project knowledge graph (/gsd-graphify) (default: false if absent)
- `model_profile` — which model each agent uses (default: `balanced`)
- `git.branching_strategy` — branching approach (default: `"none"`)
- `workflow.use_worktrees` — whether parallel executor agents run in worktree isolation (default: `true`)
@@ -55,14 +63,42 @@ Parse current values (default to `true` if not present):
**Non-Claude runtime note:** If `TEXT_MODE` is active (i.e. the runtime is non-Claude), prepend the following notice before the model profile question:
```
Note: Quality, Balanced, and Budget profiles select Claude model tiers (Opus/Sonnet/Haiku).
On non-Claude runtimes (Codex, Gemini CLI, etc.) these profiles have no effect on actual
model selection — GSD agents will use the runtime's default model.
Choose "Inherit" to use the session model for all agents, or configure model_overrides
manually in .planning/config.json to target specific models for this runtime.
Note: Quality, Balanced, Budget, and Adaptive profiles assign semantic tiers
(Opus/Sonnet/Haiku) to each agent. When `runtime` is set in .planning/config.json,
tiers resolve to runtime-native model IDs — on Codex that's gpt-5.4 / gpt-5.3-codex /
gpt-5.4-mini with appropriate reasoning effort. See "Runtime-Aware Profiles" in
docs/CONFIGURATION.md.
If `runtime` is unset on a non-Claude runtime, the profile tiers have no effect on
actual model selection — agents use the runtime's default model. Choose "Inherit" to
force session-model behavior, set `runtime` + a profile to get tiered models, or
configure `model_overrides` manually in .planning/config.json to target specific
models per agent.
```
Use AskUserQuestion with current values pre-selected:
Use AskUserQuestion with current values pre-selected. Questions are grouped into six visual sections; the first question in each section carries the section-denoting `header` field (AskUserQuestion renders abbreviated section tags for grouping, max 12 chars).
Section layout:
### Planning
Research, Plan Checker, Pattern Mapper, Nyquist, UI Phase, UI Gate, AI Phase
### Execution
Verifier, TDD Mode, Code Review, Code Review Depth _(conditional — only when code_review=on)_, UI Review
### Docs & Output
Commit Docs, Skip Discuss, Worktrees
### Features
Intel, Graphify
### Model & Pipeline
Model Profile, Auto-Advance, Branching
### Misc
Context Warnings, Research Qs
**Conditional visibility — code_review_depth:** This question is shown only when the user's chosen `code_review` value (after they answer that question, or the pre-selected value if unchanged) is on. If `code_review` is off, omit the `code_review_depth` question from the AskUserQuestion block and preserve the existing `workflow.code_review_depth` value in config (do not overwrite). Implementation: ask the Model + Planning + Execution-up-to-Code-Review questions first; if `code_review=on`, include `code_review_depth` in the same batch; otherwise skip it. Conceptually this is a one-branch split on the `code_review` answer.
```
AskUserQuestion([
@@ -104,6 +140,46 @@ AskUserQuestion([
{ label: "No", description: "Skip post-execution verification" }
]
},
{
question: "Enable TDD Mode? (RED/GREEN/REFACTOR gates for eligible tasks)",
header: "TDD",
multiSelect: false,
options: [
{ label: "No (Recommended)", description: "Execute tasks normally. Tests written alongside implementation." },
{ label: "Yes", description: "Planner applies type:tdd to business logic/APIs/validations; executor enforces gate sequence. End-of-phase review checks compliance." }
]
},
{
question: "Enable Code Review? (/gsd-code-review and /gsd-code-review-fix commands)",
header: "Code Review",
multiSelect: false,
options: [
{ label: "Yes (Recommended)", description: "Enable /gsd-code-review commands for reviewing source files changed during a phase." },
{ label: "No", description: "Commands exit with a configuration gate message. Use when code review is handled externally." }
]
},
// Conditional: include the following code_review_depth question ONLY when the user's
// chosen code_review value is "Yes". If code_review is "No", omit this question from
// the AskUserQuestion call and do not touch the existing workflow.code_review_depth value.
{
question: "Code Review Depth? (default depth for /gsd-code-review — override per-run with --depth=)",
header: "Review Depth",
multiSelect: false,
options: [
{ label: "Standard (Recommended)", description: "Per-file analysis. Balanced cost and signal." },
{ label: "Quick", description: "Pattern-matching only. Fastest, lowest cost." },
{ label: "Deep", description: "Cross-file analysis with import graphs. Highest cost, highest signal." }
]
},
{
question: "Enable UI Review? (visual quality audit via /gsd-ui-review in autonomous mode)",
header: "UI Review",
multiSelect: false,
options: [
{ label: "Yes (Recommended)", description: "Run visual quality audit after phase execution in autonomous mode." },
{ label: "No", description: "Skip the UI audit step. Good for backend-only projects." }
]
},
{
question: "Auto-advance pipeline? (discuss → plan → execute automatically)",
header: "Auto",
@@ -113,6 +189,15 @@ AskUserQuestion([
{ label: "Yes", description: "Chain stages via Task() subagents (same isolation)" }
]
},
{
question: "Run Pattern Mapper? (maps new files to existing codebase analogs between research and planning)",
header: "Pattern Mapper",
multiSelect: false,
options: [
{ label: "Yes (Recommended)", description: "gsd-pattern-mapper runs between research and plan steps. Surfaces conventions so new code follows house style." },
{ label: "No", description: "Skip pattern mapping. Faster; lose consistency hinting for new files." }
]
},
{
question: "Enable Nyquist Validation? (researches test coverage during planning)",
header: "Nyquist",
@@ -147,7 +232,7 @@ AskUserQuestion([
header: "AI Phase",
multiSelect: false,
options: [
{ label: "Yes (Recommended)", description: "Run /gsd-ai-phase before planning AI system phases. Surfaces the right framework, researches its docs, and designs the evaluation strategy." },
{ label: "Yes (Recommended)", description: "Run /gsd-ai-integration-phase before planning AI system phases. Surfaces the right framework, researches its docs, and designs the evaluation strategy." },
{ label: "No", description: "Skip AI design contract. Good for non-AI phases or when framework is already decided." }
]
},
@@ -179,6 +264,15 @@ AskUserQuestion([
{ label: "Yes", description: "Search web for best practices before each question group. More informed questions but uses more tokens." }
]
},
{
question: "Commit .planning/ files to git? (controls whether plans/artifacts are tracked in your repo)",
header: "Commit Docs",
multiSelect: false,
options: [
{ label: "Yes (Recommended)", description: "Commit .planning/ to git. Plans, research, and phase artifacts travel with the repo." },
{ label: "No", description: "Do not commit .planning/. Keep planning local only. Automatic when .planning/ is in .gitignore." }
]
},
{
question: "Skip discuss-phase in autonomous mode? (use ROADMAP phase goals as spec)",
header: "Skip Discuss",
@@ -196,6 +290,24 @@ AskUserQuestion([
{ label: "Yes (Recommended)", description: "Each parallel executor runs in its own worktree branch — no conflicts between agents." },
{ label: "No", description: "Disable worktree isolation. Agents run sequentially on the main working tree. Use if EnterWorktree creates branches from wrong base (known cross-platform issue)." }
]
},
{
question: "Enable Intel? (queryable codebase intelligence via /gsd-intel — builds a JSON index in .planning/intel/)",
header: "Intel",
multiSelect: false,
options: [
{ label: "No (Recommended)", description: "Skip intel indexing. Use when codebase is small or intel queries are not needed." },
{ label: "Yes", description: "Enable /gsd-intel commands. Builds and queries a JSON index of the codebase." }
]
},
{
question: "Enable Graphify? (project knowledge graph via /gsd-graphify — builds a graph in .planning/graphs/)",
header: "Graphify",
multiSelect: false,
options: [
{ label: "No (Recommended)", description: "Skip knowledge graph. Use when dependency graphs are not needed." },
{ label: "Yes", description: "Enable /gsd-graphify commands. Builds and queries a project knowledge graph." }
]
}
])
```
@@ -208,21 +320,33 @@ Merge new settings into existing config.json:
{
...existing_config,
"model_profile": "quality" | "balanced" | "budget" | "adaptive" | "inherit",
"commit_docs": true/false,
"workflow": {
"research": true/false,
"plan_check": true/false,
"verifier": true/false,
"auto_advance": true/false,
"nyquist_validation": true/false,
"pattern_mapper": true/false,
"ui_phase": true/false,
"ui_safety_gate": true/false,
"ai_integration_phase": true/false,
"tdd_mode": true/false,
"code_review": true/false,
"code_review_depth": "quick" | "standard" | "deep",
"ui_review": true/false,
"text_mode": true/false,
"research_before_questions": true/false,
"discuss_mode": "discuss" | "assumptions",
"skip_discuss": true/false,
"use_worktrees": true/false
},
"intel": {
"enabled": true/false
},
"graphify": {
"enabled": true/false
},
"git": {
"branching_strategy": "none" | "phase" | "milestone",
"quick_branch_template": <string|null>
@@ -234,6 +358,8 @@ Merge new settings into existing config.json:
}
```
**Safe merge:** Apply each chosen value via `gsd-sdk query config-set <key.path> <value>` so unrelated keys are never clobbered. `code_review_depth` is written only if the code_review question was answered `on`; otherwise leave the existing value in place.
Write updated config to `$GSD_CONFIG_PATH` (the workstream-aware path resolved in `ensure_and_load_config`). Never hardcode `.planning/config.json` — workstream installs route to `.planning/workstreams/<slug>/config.json`.
</step>
@@ -276,10 +402,21 @@ Write `~/.gsd/defaults.json` with:
"verifier": <current>,
"auto_advance": <current>,
"nyquist_validation": <current>,
"pattern_mapper": <current>,
"ui_phase": <current>,
"ui_safety_gate": <current>,
"ai_integration_phase": <current>,
"tdd_mode": <current>,
"code_review": <current>,
"code_review_depth": <current>,
"ui_review": <current>,
"skip_discuss": <current>
},
"intel": {
"enabled": <current>
},
"graphify": {
"enabled": <current>
}
}
```
@@ -298,7 +435,15 @@ Display:
| Model Profile | {quality/balanced/budget/inherit} |
| Plan Researcher | {On/Off} |
| Plan Checker | {On/Off} |
| Pattern Mapper | {On/Off} |
| Execution Verifier | {On/Off} |
| TDD Mode | {On/Off} |
| Code Review | {On/Off} |
| Code Review Depth | {quick/standard/deep} |
| UI Review | {On/Off} |
| Commit Docs | {On/Off} |
| Intel | {On/Off} |
| Graphify | {On/Off} |
| Auto-Advance | {On/Off} |
| Nyquist Validation | {On/Off} |
| UI Phase | {On/Off} |
@@ -312,10 +457,12 @@ Display:
These settings apply to future /gsd-plan-phase and /gsd-execute-phase runs.
Quick commands:
- /gsd-settings-integrations — configure API keys (Brave/Firecrawl/Exa), review.models CLI routing, and agent_skills injection
- /gsd-set-profile <profile> — switch model profile
- /gsd-plan-phase --research — force research
- /gsd-plan-phase --skip-research — skip research
- /gsd-plan-phase --skip-verify — skip plan check
- /gsd-settings-advanced — power-user tuning (plan bounce, timeouts, branch templates, cross-AI, context window)
```
</step>
@@ -323,7 +470,7 @@ Quick commands:
<success_criteria>
- [ ] Current config read
- [ ] User presented with 14 settings (profile + 11 workflow toggles + git branching + ctx warnings)
- [ ] User presented with 22 settings (profile + workflow toggles + features + git branching + ctx warnings), grouped into six sections: Planning, Execution, Docs & Output, Features, Model & Pipeline, Misc. `code_review_depth` is conditional on `code_review=on`.
- [ ] Config updated with model_profile, workflow, and git sections
- [ ] User offered to save as global defaults (~/.gsd/defaults.json)
- [ ] Changes confirmed to user

View File

@@ -21,8 +21,8 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
```bash
INIT=$(gsd-sdk query init.plan-phase "$PHASE")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_UI=$(gsd-sdk query agent-skills gsd-ui-researcher 2>/dev/null)
AGENT_SKILLS_UI_CHECKER=$(gsd-sdk query agent-skills gsd-ui-checker 2>/dev/null)
AGENT_SKILLS_UI=$(gsd-sdk query agent-skills gsd-ui-researcher)
AGENT_SKILLS_UI_CHECKER=$(gsd-sdk query agent-skills gsd-ui-checker)
```
Parse JSON for: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_context`, `has_research`, `commit_docs`.

View File

@@ -18,7 +18,7 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
```bash
INIT=$(gsd-sdk query init.phase-op "${PHASE_ARG}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_UI_REVIEWER=$(gsd-sdk query agent-skills gsd-ui-reviewer 2>/dev/null)
AGENT_SKILLS_UI_REVIEWER=$(gsd-sdk query agent-skills gsd-ui-auditor)
```
Parse: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `commit_docs`.

View File

@@ -18,7 +18,7 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
```bash
INIT=$(gsd-sdk query init.phase-op "${PHASE_ARG}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_AUDITOR=$(gsd-sdk query agent-skills gsd-nyquist-auditor 2>/dev/null)
AGENT_SKILLS_AUDITOR=$(gsd-sdk query agent-skills gsd-nyquist-auditor)
```
Parse: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`.

View File

@@ -183,6 +183,57 @@ grep -E "Phase ${PHASE_NUM}" .planning/REQUIREMENTS.md 2>/dev/null || true
For each requirement: parse description → identify supporting truths/artifacts → status: ✓ SATISFIED / ✗ BLOCKED / ? NEEDS HUMAN.
</step>
<step name="verify_decisions">
**Decision coverage validation gate (issue #2492).**
After requirements coverage, also check that each trackable CONTEXT.md
`<decisions>` entry shows up somewhere in the shipped artifacts (plans,
SUMMARY.md, files modified by the phase, or recent commit subjects on the
phase branch).
This gate is **non-blocking / warning only** by deliberate asymmetry with
the plan-phase translation gate. The plan-phase gate already blocked at
translation time, so by the time verification runs every decision has
either been translated or explicitly deferred. This gate's job is to
surface decisions that *were* translated but vanished during execution —
that's a soft signal because "honors a decision" is a fuzzy substring
heuristic, and we don't want a paraphrase miss to fail an otherwise good
phase.
**Skip if** `workflow.context_coverage_gate` is explicitly set to `false`
(absent key = enabled). Also skip cleanly when CONTEXT.md is missing or has
no `<decisions>` block.
```bash
GATE_CFG=$(gsd-sdk query config-get workflow.context_coverage_gate 2>/dev/null || echo "true")
if [ "$GATE_CFG" != "false" ]; then
# Discover the phase CONTEXT.md via glob expansion rather than `ls | head`
# (review F17 / ShellCheck SC2012). Globs preserve filenames containing
# spaces and avoid an extra subprocess.
CONTEXT_PATH=""
for f in "${PHASE_DIR}"/*-CONTEXT.md; do
[ -e "$f" ] && CONTEXT_PATH="$f" && break
done
DECISION_RESULT=$(gsd-sdk query check.decision-coverage-verify "${PHASE_DIR}" "${CONTEXT_PATH}")
fi
```
The handler returns JSON `{ skipped, blocking: false, total, honored,
not_honored: [...], message }`.
**Reporting:** Append the handler's `message` (a `### Decision Coverage`
section) to VERIFICATION.md regardless of outcome — even when all
decisions are honored, recording the count helps reviewers spot drift over
time. Set `decision_coverage` in the verification result to
`{honored, total, not_honored: [...]}` so downstream tooling can read it.
**Status impact:** none. The decision gate does NOT influence the
`gaps_found` / `human_needed` / `passed` decision tree in
`determine_status`. Its findings are warnings the user reviews and may act
on by re-opening the phase or by acknowledging the decision was abandoned
intentionally.
</step>
<step name="behavioral_verification">
**Run the project's test suite and CLI commands to verify behavior, not just structure.**
@@ -479,6 +530,7 @@ Orchestrator routes: `passed` → update_roadmap | `gaps_found` → create/execu
- [ ] All artifacts checked at all three levels
- [ ] All key links verified
- [ ] Requirements coverage assessed (if applicable)
- [ ] CONTEXT.md decisions checked against shipped artifacts (#2492 — non-blocking)
- [ ] Anti-patterns scanned and categorized
- [ ] Test quality audited (disabled tests, circular patterns, assertion strength, provenance)
- [ ] Human verification items identified

View File

@@ -32,8 +32,8 @@ If $ARGUMENTS contains a phase number, load context:
```bash
INIT=$(gsd-sdk query init.verify-work "${PHASE_ARG}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner 2>/dev/null)
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-checker 2>/dev/null)
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner)
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-plan-checker)
```
Parse JSON for: `planner_model`, `checker_model`, `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `has_verification`, `uat_path`.
@@ -464,7 +464,7 @@ Run phase artifact scan to surface any open items before marking phase verified:
`audit-open` is CJS-only until registered on `gsd-sdk query`:
```bash
gsd-sdk query audit-open --json 2>/dev/null
gsd-sdk query audit-open --json
```
Parse the JSON output. For the CURRENT PHASE ONLY, surface:

View File

@@ -36,8 +36,27 @@ process.stdin.on('end', () => {
process.exit(0);
}
// Claude Code natively enforces read-before-edit — skip the advisory (#1984, #2344)
if (process.env.CLAUDE_SESSION_ID || process.env.CLAUDECODE) {
// Claude Code natively enforces read-before-edit — skip the advisory (#1984, #2344, #2520).
//
// Detection signals, in priority order:
// 1. `data.session_id` on the hook's stdin payload — part of Claude
// Code's documented PreToolUse hook-input schema, always present.
// Reliable across Claude Code versions because it's schema, not env.
// 2. `CLAUDE_CODE_ENTRYPOINT` / `CLAUDE_CODE_SSE_PORT` — env vars that
// Claude Code does propagate to hook subprocesses (verified on
// Claude Code CLI 2.1.116).
// 3. `CLAUDE_SESSION_ID` / `CLAUDECODE` — kept for back-compat and in
// case future Claude Code versions propagate them to hook
// subprocesses. On 2.1.116 they reach Bash tool subprocesses but
// not hook subprocesses, which is why checking them alone is
// insufficient (regression of #2344 fixed here as #2520).
const isClaudeCode =
(typeof data.session_id === 'string' && data.session_id.length > 0) ||
process.env.CLAUDE_CODE_ENTRYPOINT ||
process.env.CLAUDE_CODE_SSE_PORT ||
process.env.CLAUDE_SESSION_ID ||
process.env.CLAUDECODE;
if (isClaudeCode) {
process.exit(0);
}

View File

@@ -7,6 +7,92 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
// --- Config + last-command readers ------------------------------------------
/**
* Walk up from dir looking for .planning/config.json and return its parsed contents.
* Returns {} if not found or unreadable.
*/
function readGsdConfig(dir) {
const home = os.homedir();
let current = dir;
for (let i = 0; i < 10; i++) {
const candidate = path.join(current, '.planning', 'config.json');
if (fs.existsSync(candidate)) {
try {
return JSON.parse(fs.readFileSync(candidate, 'utf8')) || {};
} catch (e) {
return {};
}
}
const parent = path.dirname(current);
if (parent === current || current === home) break;
current = parent;
}
return {};
}
/**
* Lookup a dotted key path (e.g. 'statusline.show_last_command') in a config
* object that may use either nested or flat keys.
*/
function getConfigValue(cfg, keyPath) {
if (!cfg || typeof cfg !== 'object') return undefined;
if (keyPath in cfg) return cfg[keyPath];
const parts = keyPath.split('.');
let cur = cfg;
for (const p of parts) {
if (cur == null || typeof cur !== 'object' || !(p in cur)) return undefined;
cur = cur[p];
}
return cur;
}
/**
* Extract the most recently invoked slash command from a Claude Code JSONL
* transcript file. Returns the command name (no leading slash) or null.
*
* Claude Code embeds slash invocations in user messages as
* <command-name>/foo</command-name>
* We scan lines from the end of the file, stopping at the first match.
*/
function readLastSlashCommand(transcriptPath) {
if (!transcriptPath || typeof transcriptPath !== 'string') return null;
let content;
try {
if (!fs.existsSync(transcriptPath)) return null;
// Read only the tail — typical transcripts grow large. 256 KiB comfortably
// covers dozens of recent turns while staying cheap per render.
const stat = fs.statSync(transcriptPath);
const MAX = 256 * 1024;
const start = Math.max(0, stat.size - MAX);
const fd = fs.openSync(transcriptPath, 'r');
try {
const buf = Buffer.alloc(stat.size - start);
fs.readSync(fd, buf, 0, buf.length, start);
content = buf.toString('utf8');
} finally {
fs.closeSync(fd);
}
} catch (e) {
return null;
}
// Find the LAST occurrence — scan right-to-left via lastIndexOf on the tag.
const tagClose = '</command-name>';
const idx = content.lastIndexOf(tagClose);
if (idx < 0) return null;
const openTag = '<command-name>';
const openIdx = content.lastIndexOf(openTag, idx);
if (openIdx < 0) return null;
let name = content.slice(openIdx + openTag.length, idx).trim();
// Strip a leading slash if present, and any trailing arguments-on-same-line noise.
if (name.startsWith('/')) name = name.slice(1);
// Command names in Claude Code transcripts are plain identifiers like "gsd-plan-phase"
// or namespaced like "plugin:skill". Reject anything with whitespace/newlines/control chars.
if (!name || /[\s\\"<>]/.test(name) || name.length > 80) return null;
return name;
}
// --- GSD state reader -------------------------------------------------------
/**
@@ -240,6 +326,23 @@ function runStatusline() {
} catch (e) {}
}
// Last-slash-command suffix (opt-in via statusline.show_last_command, #2538).
// Reads the active session transcript for the most recent <command-name> tag.
// Failure here must never break the statusline — wrap the entire lookup.
let lastCmdSuffix = '';
try {
const cfg = readGsdConfig(dir);
if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
const transcriptPath = data.transcript_path;
const lastCmd = readLastSlashCommand(transcriptPath);
if (lastCmd) {
lastCmdSuffix = `\x1b[2mlast: /${lastCmd}\x1b[0m`;
}
}
} catch (e) {
// Never break the statusline on config/transcript errors
}
// Output
const dirname = path.basename(dir);
const middle = task
@@ -249,9 +352,9 @@ function runStatusline() {
: null;
if (middle) {
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ ${middle}\x1b[2m${dirname}\x1b[0m${ctx}`);
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ ${middle}\x1b[2m${dirname}\x1b[0m${ctx}${lastCmdSuffix}`);
} else {
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${ctx}`);
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${ctx}${lastCmdSuffix}`);
}
} catch (e) {
// Silent fail - don't break statusline on parse errors
@@ -260,6 +363,39 @@ function runStatusline() {
}
// Export helpers for unit tests. Harmless when run as a script.
module.exports = { readGsdState, parseStateMd, formatGsdState };
module.exports = {
readGsdState, parseStateMd, formatGsdState,
readGsdConfig, getConfigValue, readLastSlashCommand,
};
/**
* Render the statusline from an already-parsed hook input object. Exported for
* testing without feeding stdin. Returns the rendered string.
*/
function renderStatusline(data) {
const model = data.model?.display_name || 'Claude';
const dir = data.workspace?.current_dir || process.cwd();
const dirname = path.basename(dir);
let lastCmdSuffix = '';
try {
const cfg = readGsdConfig(dir);
if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
const lastCmd = readLastSlashCommand(data.transcript_path);
if (lastCmd) {
lastCmdSuffix = `\x1b[2mlast: /${lastCmd}\x1b[0m`;
}
}
} catch (e) { /* swallow */ }
const gsdStateStr = formatGsdState(readGsdState(dir) || {});
const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
if (middle) {
return `\x1b[2m${model}\x1b[0m │ ${middle}\x1b[2m${dirname}\x1b[0m${lastCmdSuffix}`;
}
return `\x1b[2m${model}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${lastCmdSuffix}`;
}
module.exports.renderStatusline = renderStatusline;
if (require.main === module) runStatusline();

3304
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@
"version": "1.38.2",
"description": "A meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini and Codex by TÂCHES.",
"bin": {
"get-shit-done-cc": "bin/install.js"
"get-shit-done-cc": "bin/install.js",
"gsd-sdk": "bin/gsd-sdk.js"
},
"files": [
"bin",
@@ -14,6 +15,7 @@
"scripts",
"sdk/src",
"sdk/prompts",
"sdk/dist",
"sdk/package.json",
"sdk/package-lock.json",
"sdk/tsconfig.json"
@@ -43,14 +45,19 @@
"engines": {
"node": ">=22.0.0"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.84",
"ws": "^8.20.0"
},
"devDependencies": {
"c8": "^11.0.0",
"esbuild": "^0.24.0",
"vitest": "^4.1.2"
"c8": "^11.0.0"
},
"scripts": {
"build:hooks": "node scripts/build-hooks.js",
"prepublishOnly": "npm run build:hooks",
"build:sdk": "cd sdk && npm ci && npm run build",
"prepublishOnly": "npm run build:hooks && npm run build:sdk",
"pretest": "npm run build:sdk",
"pretest:coverage": "npm run build:sdk",
"test": "node scripts/run-tests.cjs",
"test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs"
}

View File

@@ -0,0 +1,52 @@
'use strict';
/**
* One-shot script: replace /gsd-<cmd> with /gsd:<cmd> for known command names.
* Only replaces when followed by a word boundary (space, newline, quote, backtick, ), end).
*/
const fs = require('node:fs');
const path = require('node:path');
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const cmdNames = fs.readdirSync(COMMANDS_DIR)
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''))
.sort((a, b) => b.length - a.length); // longest first to avoid partial matches
// Build regex: /gsd-(cmd1|cmd2|...) followed by non-word-char or end
const pattern = new RegExp(`/gsd-(${cmdNames.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
const SEARCH_DIRS = [
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib'),
path.join(__dirname, '..', 'get-shit-done', 'workflows'),
path.join(__dirname, '..', 'get-shit-done', 'references'),
path.join(__dirname, '..', 'get-shit-done', 'templates'),
path.join(__dirname, '..', 'get-shit-done', 'contexts'),
path.join(__dirname, '..', 'commands', 'gsd'),
];
const EXTENSIONS = new Set(['.md', '.cjs', '.js']);
function processDir(dir) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
processDir(full);
} else if (EXTENSIONS.has(path.extname(e.name))) {
const src = fs.readFileSync(full, 'utf-8');
const replaced = src.replace(pattern, (_, cmd) => `/gsd:${cmd}`);
if (replaced !== src) {
fs.writeFileSync(full, replaced, 'utf-8');
const count = (src.match(pattern) || []).length;
console.log(` ${count} replacements: ${path.relative(path.join(__dirname, '..'), full)}`);
}
}
}
}
let totalFiles = 0;
for (const dir of SEARCH_DIRS) {
processDir(dir);
}
console.log('Done.');

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Verify the published get-shit-done-cc tarball actually contains
# sdk/dist/cli.js and that the `query` subcommand is exposed.
#
# Guards regression of bug #2647: v1.38.3 shipped without sdk/dist/
# because the outer `files` whitelist and `prepublishOnly` chain
# drifted out of alignment. Any future drift fails release CI here.
#
# Run AFTER `npm run build:sdk` (so sdk/dist exists on disk) and
# before `npm publish`. Exits non-zero on any mismatch.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
echo "==> Packing tarball (ignore-scripts: sdk/dist must already exist)"
TARBALL=$(npm pack --ignore-scripts 2>/dev/null | tail -1)
if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
echo "::error::npm pack produced no tarball"
exit 1
fi
echo " tarball: $TARBALL"
EXTRACT_DIR=$(mktemp -d)
trap 'rm -rf "$EXTRACT_DIR" "$TARBALL"' EXIT
echo "==> Extracting tarball into $EXTRACT_DIR"
tar -xzf "$TARBALL" -C "$EXTRACT_DIR"
CLI_JS="$EXTRACT_DIR/package/sdk/dist/cli.js"
if [ ! -f "$CLI_JS" ]; then
echo "::error::$CLI_JS is missing from the published tarball"
echo "Tarball contents under sdk/:"
find "$EXTRACT_DIR/package/sdk" -maxdepth 2 -print | head -40
exit 1
fi
echo " OK: sdk/dist/cli.js present ($(wc -c < "$CLI_JS") bytes)"
echo "==> Installing runtime deps inside the extracted package and invoking gsd-sdk query --help"
pushd "$EXTRACT_DIR/package" >/dev/null
# Install only production deps so the extracted tarball resolves
# @anthropic-ai/claude-agent-sdk / ws the same way a real user install would.
npm install --omit=dev --no-audit --no-fund --silent
OUTPUT=$(node sdk/dist/cli.js query --help 2>&1 || true)
popd >/dev/null
echo "$OUTPUT" | head -20
if ! echo "$OUTPUT" | grep -qi 'query'; then
echo "::error::sdk/dist/cli.js did not expose a 'query' subcommand"
exit 1
fi
if echo "$OUTPUT" | grep -qiE 'unknown command|unrecognized'; then
echo "::error::sdk/dist/cli.js rejected 'query' as unknown"
exit 1
fi
echo "==> Also verifying gsd-sdk bin shim resolves ../sdk/dist/cli.js"
SHIM="$EXTRACT_DIR/package/bin/gsd-sdk.js"
if [ ! -f "$SHIM" ]; then
echo "::error::bin/gsd-sdk.js missing from tarball"
exit 1
fi
if ! grep -qE "sdk.*dist.*cli\.js" "$SHIM"; then
echo "::error::bin/gsd-sdk.js does not reference sdk/dist/cli.js"
exit 1
fi
echo "==> Tarball verification passed"

View File

@@ -34,7 +34,7 @@
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build",
"prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration"

View File

@@ -73,6 +73,14 @@ After plans pass the checker (or checker is skipped), verify all phase requireme
3. If gaps found: log as warning, continue (headless mode does not block for coverage gaps)
</step>
<step name="post_planning_gaps">
Unified post-planning gap report (#2493). Gated on `workflow.post_planning_gaps`
(default true). When enabled, scan REQUIREMENTS.md and CONTEXT.md `<decisions>`
against all generated PLAN.md files, then emit one `Source | Item | Status` table.
Skip-gracefully on missing sources. Non-blocking — headless mode reports gaps
via the event stream and continues.
</step>
</process>
<success_criteria>

View File

@@ -341,6 +341,21 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
return;
}
// Multi-repo project-root resolution (issue #2623).
//
// When the user launches `gsd-sdk` from inside a `sub_repos`-listed child repo,
// `projectDir` defaults to `process.cwd()` which points at the child, not the
// parent workspace that owns `.planning/`. Mirror the legacy `gsd-tools.cjs`
// walk-up semantics so handlers see the correct project root.
//
// Idempotent: if `projectDir` already has its own `.planning/` (including an
// explicit `--project-dir` pointing at the workspace root), findProjectRoot
// returns it unchanged.
{
const { findProjectRoot } = await import('./query/helpers.js');
args = { ...args, projectDir: findProjectRoot(args.projectDir) };
}
// ─── Query command ──────────────────────────────────────────────────────
if (args.command === 'query') {
const { createRegistry } = await import('./query/index.js');

View File

@@ -6,16 +6,37 @@ import { tmpdir } from 'node:os';
describe('loadConfig', () => {
let tmpDir: string;
let fakeHome: string;
let prevHome: string | undefined;
let prevGsdHome: string | undefined;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(tmpDir, '.planning'), { recursive: true });
// Isolate ~/.gsd/defaults.json by pointing HOME at an empty tmp dir.
fakeHome = join(tmpdir(), `gsd-home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(fakeHome, { recursive: true });
prevHome = process.env.HOME;
process.env.HOME = fakeHome;
// Also isolate GSD_HOME (loadUserDefaults prefers it over HOME).
prevGsdHome = process.env.GSD_HOME;
delete process.env.GSD_HOME;
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
await rm(fakeHome, { recursive: true, force: true });
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevGsdHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = prevGsdHome;
});
async function writeUserDefaults(defaults: unknown) {
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), JSON.stringify(defaults));
}
it('returns all defaults when config file is missing', async () => {
// No config.json created
await rm(join(tmpDir, '.planning', 'config.json'), { force: true });
@@ -154,6 +175,69 @@ describe('loadConfig', () => {
expect(config.parallelization).toBe(0);
});
// ─── User-level defaults (~/.gsd/defaults.json) ─────────────────────────
// Regression: issue #2652 — SDK loadConfig ignored user-level defaults
// for pre-project Codex installs, so init.quick still emitted Claude
// model aliases from MODEL_PROFILES via resolveModel even when the user
// had `resolve_model_ids: "omit"` in ~/.gsd/defaults.json.
//
// Mirrors CJS behavior in get-shit-done/bin/lib/core.cjs:421 (#1683):
// user-level defaults only apply when no project .planning/config.json
// exists (pre-project context). Once a project is initialized, its
// config.json is authoritative — buildNewProjectConfig baked the user
// defaults in at /gsd:new-project time.
it('pre-project: layers user defaults from ~/.gsd/defaults.json', async () => {
await writeUserDefaults({ resolve_model_ids: 'omit' });
// No project config.json
const config = await loadConfig(tmpDir);
expect((config as Record<string, unknown>).resolve_model_ids).toBe('omit');
// Built-in defaults still present for keys user did not override
expect(config.model_profile).toBe('balanced');
expect(config.workflow.plan_check).toBe(true);
});
it('pre-project: deep-merges nested keys from user defaults', async () => {
await writeUserDefaults({
git: { branching_strategy: 'milestone' },
agent_skills: { planner: 'user-skill' },
});
const config = await loadConfig(tmpDir);
expect(config.git.branching_strategy).toBe('milestone');
expect(config.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}');
expect(config.agent_skills).toEqual({ planner: 'user-skill' });
});
it('project config is authoritative over user defaults (CJS parity)', async () => {
// User defaults set resolve_model_ids: "omit", but project config omits it.
// Per CJS core.cjs loadConfig (#1683): once .planning/config.json exists,
// ~/.gsd/defaults.json is ignored — buildNewProjectConfig already baked
// the user defaults in at project creation time.
await writeUserDefaults({
resolve_model_ids: 'omit',
model_profile: 'fast',
});
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'quality' }),
);
const config = await loadConfig(tmpDir);
expect(config.model_profile).toBe('quality');
// User-defaults not layered when project config present
expect((config as Record<string, unknown>).resolve_model_ids).toBeUndefined();
});
it('ignores malformed ~/.gsd/defaults.json', async () => {
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), '{not json');
const config = await loadConfig(tmpDir);
// Falls back to built-in defaults
expect(config).toEqual(CONFIG_DEFAULTS);
});
it('does not mutate CONFIG_DEFAULTS between calls', async () => {
const before = structuredClone(CONFIG_DEFAULTS);

View File

@@ -6,6 +6,7 @@
*/
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { relPlanningPath } from './workstream-utils.js';
@@ -38,6 +39,13 @@ export interface WorkflowConfig {
max_discuss_passes: number;
/** Subagent timeout in ms (matches `get-shit-done/bin/lib/core.cjs` default 300000). */
subagent_timeout: number;
/**
* Issue #2492. When true (default), enforces that every trackable decision in
* CONTEXT.md `<decisions>` is referenced by at least one plan (translation
* gate, blocking) and reports decisions not honored by shipped artifacts at
* verify-phase (validation gate, non-blocking). Set false to disable both.
*/
context_coverage_gate: boolean;
}
export interface HooksConfig {
@@ -98,6 +106,7 @@ export const CONFIG_DEFAULTS: GSDConfig = {
skip_discuss: false,
max_discuss_passes: 3,
subagent_timeout: 300000,
context_coverage_gate: true,
},
hooks: {
context_warnings: true,
@@ -112,33 +121,76 @@ export const CONFIG_DEFAULTS: GSDConfig = {
/**
* Load project config from `.planning/config.json`, merging with defaults.
* Returns full defaults when file is missing or empty.
* When project config is missing or empty, layers user defaults
* (`~/.gsd/defaults.json`) over built-in defaults.
* Throws on malformed JSON with a helpful error message.
*/
/**
* Read user-level defaults from `~/.gsd/defaults.json` (or `$GSD_HOME/.gsd/`
* when set). Returns `{}` when the file is missing, empty, or malformed —
* matches CJS behavior in `get-shit-done/bin/lib/core.cjs` (#1683, #2652).
*/
async function loadUserDefaults(): Promise<Record<string, unknown>> {
const home = process.env.GSD_HOME || homedir();
const defaultsPath = join(home, '.gsd', 'defaults.json');
let raw: string;
try {
raw = await readFile(defaultsPath, 'utf-8');
} catch {
return {};
}
const trimmed = raw.trim();
if (trimmed === '') return {};
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return {};
}
return parsed as Record<string, unknown>;
} catch {
return {};
}
}
export async function loadConfig(projectDir: string, workstream?: string): Promise<GSDConfig> {
const configPath = join(projectDir, relPlanningPath(workstream), 'config.json');
const rootConfigPath = join(projectDir, '.planning', 'config.json');
let raw: string;
let projectConfigFound = false;
try {
raw = await readFile(configPath, 'utf-8');
projectConfigFound = true;
} catch {
// If workstream config missing, fall back to root config
if (workstream) {
try {
raw = await readFile(rootConfigPath, 'utf-8');
projectConfigFound = true;
} catch {
return structuredClone(CONFIG_DEFAULTS);
raw = '';
}
} else {
// File missing — normal for new projects
return structuredClone(CONFIG_DEFAULTS);
raw = '';
}
}
// Pre-project context: no .planning/config.json exists. Layer user-level
// defaults from ~/.gsd/defaults.json over built-in defaults. Mirrors the
// CJS fall-back branch in get-shit-done/bin/lib/core.cjs:421 (#1683) so
// SDK-dispatched init queries (e.g. resolveModel in Codex installs, #2652)
// honor user-level knobs like `resolve_model_ids: "omit"`.
if (!projectConfigFound) {
const userDefaults = await loadUserDefaults();
return mergeDefaults(userDefaults);
}
const trimmed = raw.trim();
if (trimmed === '') {
return structuredClone(CONFIG_DEFAULTS);
// Empty project config — treat as no project config (CJS core.cjs
// catches JSON.parse on empty and falls through to the pre-project path).
const userDefaults = await loadUserDefaults();
return mergeDefaults(userDefaults);
}
let parsed: Record<string, unknown>;
@@ -153,7 +205,12 @@ export async function loadConfig(projectDir: string, workstream?: string): Promi
throw new Error(`Config at ${configPath} must be a JSON object`);
}
// Three-level deep merge: defaults <- parsed
// Project config exists — user-level defaults are ignored (CJS parity).
// `buildNewProjectConfig` already baked them into config.json at /gsd:new-project.
return mergeDefaults(parsed);
}
function mergeDefaults(parsed: Record<string, unknown>): GSDConfig {
return {
...structuredClone(CONFIG_DEFAULTS),
...parsed,

View File

@@ -62,6 +62,8 @@ No `gsd-tools.cjs` mirror — agents use these instead of shell `ls`/`find`/`gre
Handlers for `**state.signal-waiting`**, `**state.signal-resume**`, `**state.validate**`, `**state.sync**` (supports `--verify` dry-run), and `**state.prune**` live in `state-mutation.ts`, with dotted and `state …` space aliases in `index.ts`.
**`state.add-roadmap-evolution`** (bug #2662) — appends one entry to the `### Roadmap Evolution` subsection under `## Accumulated Context` in STATE.md, creating the subsection if missing. argv: `--phase`, `--action` (`inserted|removed|moved|edited|added`), optional `--note`, `--after` (for `inserted`), and `--urgent` flag. Returns `{ added: true, entry }` or `{ added: false, reason: 'duplicate', entry }`. Throws `GSDError(Validation)` when `--phase` / `--action` are missing or action is not in the allowed set. Canonical replacement for raw `Edit`/`Write` on STATE.md in `insert-phase.md` / `add-phase.md` workflows — required when projects ship a `protect-files.sh` PreToolUse hook that blocks direct STATE.md writes.
**`state.json` vs `state.load` (different CJS commands):**
- **`state.json`** / `state json` — port of **`cmdStateJson`** (`state.ts` `stateJson`): rebuilt STATE.md frontmatter JSON. Read-only golden: `read-only-parity.integration.test.ts` compares to CJS `state json` with **`last_updated`** stripped.

View File

@@ -0,0 +1,519 @@
/**
* Decision-coverage gate tests for issue #2492.
*
* Two gates, two semantics:
*
* - `check.decision-coverage-plan` — translation gate, BLOCKING.
* Each trackable CONTEXT.md decision must appear (by id or text) in at
* least one PLAN.md `must_haves` / `truths` / body.
*
* - `check.decision-coverage-verify` — validation gate, NON-BLOCKING.
* Each trackable decision should appear in shipped artifacts (PLANs,
* SUMMARY.md, files_modified, recent commit messages). Missing items
* are reported as warnings only.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
checkDecisionCoveragePlan,
checkDecisionCoverageVerify,
} from './check-decision-coverage.js';
let tmp: string;
let phaseDir: string;
let contextPath: string;
async function setupPhase(decisionsBlock: string, plans: Record<string, string>, summary?: string) {
await mkdir(phaseDir, { recursive: true });
await writeFile(contextPath, `# Phase 17 Context\n\n${decisionsBlock}\n`, 'utf-8');
for (const [name, content] of Object.entries(plans)) {
await writeFile(join(phaseDir, name), content, 'utf-8');
}
if (summary !== undefined) {
await writeFile(join(phaseDir, '17-SUMMARY.md'), summary, 'utf-8');
}
}
function planFile(mustHavesYaml: string, body = ''): string {
return `---
phase: 17
plan: 1
type: implementation
wave: 1
depends_on: []
files_modified: []
autonomous: true
must_haves:
${mustHavesYaml}
---
${body}
`;
}
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'gsd-deccov-'));
phaseDir = join(tmp, '.planning', 'phases', '17-foo');
contextPath = join(phaseDir, '17-CONTEXT.md');
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
describe('checkDecisionCoveragePlan — translation gate (#2492)', () => {
it('passes when every trackable decision is cited by id in a plan', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-01:** Use bit offsets
- **D-02:** Display TArray element type
</decisions>`,
{
'17-01-PLAN.md': planFile(
` truths:
- "D-01: bit offsets are exposed via API"
artifacts: []
key_links: []`,
// D-02 cited under a designated `## tasks` heading (review F4).
'## tasks\n- Implements D-02: TArray display logic.\n',
),
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(true);
expect(result.data.uncovered).toEqual([]);
expect(result.data.total).toBe(2);
expect(result.data.covered).toBe(2);
});
it('fails when a decision is not covered by any plan and names it', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-01:** Use bit offsets, not byte offsets
- **D-99:** A decision nobody bothered to plan
</decisions>`,
{
'17-01-PLAN.md': planFile(
` truths:
- "D-01: bit offsets are exposed"
artifacts: []
key_links: []`,
),
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(false);
expect(result.data.uncovered.map((u: { id: string }) => u.id)).toEqual(['D-99']);
expect(result.data.message).toMatch(/D-99/);
});
it('honors `truths` AND `must_haves` body bullets', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-01:** First decision
- **D-02:** Second decision
</decisions>`,
{
'17-01-PLAN.md': planFile(
` truths:
- "D-01 honored"
artifacts: []
key_links: []`,
'## must_haves\n- D-02: also honored in body\n',
),
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(true);
});
it('skips when context_coverage_gate is disabled in config', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-01:** Anything
- **D-02:** Anything else
</decisions>`,
{ '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) },
);
await mkdir(join(tmp, '.planning'), { recursive: true });
await writeFile(
join(tmp, '.planning', 'config.json'),
JSON.stringify({ workflow: { context_coverage_gate: false } }),
'utf-8',
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.skipped).toBe(true);
expect(result.data.passed).toBe(true);
});
it('skips cleanly when CONTEXT.md is missing', async () => {
await mkdir(phaseDir, { recursive: true });
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.skipped).toBe(true);
expect(result.data.reason).toMatch(/CONTEXT/);
});
it('skips cleanly when <decisions> block is missing', async () => {
await mkdir(phaseDir, { recursive: true });
await writeFile(contextPath, '# Phase 17\n\nNo decisions block here.\n', 'utf-8');
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.skipped).toBe(true);
});
it('does not flag non-trackable decisions (Discretion / informational / folded)', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-01:** trackable
- **D-02 [informational]:** opt-out
- **D-03 [folded]:** opt-out
### Claude's Discretion
- **D-99:** never tracked
</decisions>`,
{
'17-01-PLAN.md': planFile(
` truths:
- "D-01"
artifacts: []
key_links: []`,
),
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(true);
expect(result.data.total).toBe(1); // only D-01 is trackable
});
});
describe('checkDecisionCoverageVerify — validation gate (#2492)', () => {
it('reports honored decisions when ID appears in shipped artifacts', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-05:** Validate input
</decisions>`,
{ '17-01-PLAN.md': planFile(` truths: ["D-05"]\n artifacts: []\n key_links: []`) },
'## Summary\nImplemented D-05.\nfiles_modified: []\n',
);
const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp);
expect(result.data.honored).toBe(1);
expect(result.data.not_honored).toEqual([]);
expect(result.data.blocking).toBe(false);
});
it('reports decisions not honored when ID appears nowhere', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-50:** Add metrics endpoint
</decisions>`,
{ '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) },
'## Summary\nDid other things.\n',
);
const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp);
expect(result.data.honored).toBe(0);
expect(result.data.not_honored.map((u: { id: string }) => u.id)).toEqual(['D-50']);
expect(result.data.blocking).toBe(false); // non-blocking by spec
expect(result.data.message).toMatch(/D-50/);
});
it('skips when context_coverage_gate is disabled', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-50:** anything
</decisions>`,
{ '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) },
);
await mkdir(join(tmp, '.planning'), { recursive: true });
await writeFile(
join(tmp, '.planning', 'config.json'),
JSON.stringify({ workflow: { context_coverage_gate: false } }),
'utf-8',
);
const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp);
expect(result.data.skipped).toBe(true);
expect(result.data.blocking).toBe(false);
});
it('skips cleanly when CONTEXT.md is missing', async () => {
await mkdir(phaseDir, { recursive: true });
const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp);
expect(result.data.skipped).toBe(true);
});
});
// ─── Adversarial-review regression tests ──────────────────────────────────
describe('translation gate haystack restriction (review F4)', () => {
it('does NOT count a D-NN citation buried in an HTML comment', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-77:** A trackable decision worth six or more words long
</decisions>`,
{
'17-01-PLAN.md': planFile(
` truths: []\n artifacts: []\n key_links: []`,
'<!-- D-77 was here -->\nNothing else mentions the decision.',
),
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(false);
expect(result.data.uncovered.map((u: { id: string }) => u.id)).toContain('D-77');
});
it('does NOT count a D-NN citation buried in a fenced code example', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-78:** A trackable decision worth six or more words long
</decisions>`,
{
'17-01-PLAN.md': planFile(
` truths: []\n artifacts: []\n key_links: []`,
'## Design notes\n\n```text\nExample: D-78 should appear here\n```\n',
),
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(false);
expect(result.data.uncovered.map((u: { id: string }) => u.id)).toContain('D-78');
});
it('counts a citation in front-matter `must_haves`', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-79:** Trackable decision text long enough to soft-match.
</decisions>`,
{
'17-01-PLAN.md': `---
phase: 17
plan: 1
must_haves:
- "D-79 must be honored"
truths: []
artifacts: []
key_links: []
---
`,
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(true);
});
it('counts a citation in front-matter `truths`', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-80:** Trackable decision text long enough to soft-match.
</decisions>`,
{
'17-01-PLAN.md': planFile(` truths: ["D-80 honored"]\n artifacts: []\n key_links: []`),
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(true);
});
});
describe('soft-phrase length gating (review F5)', () => {
it('flags a sub-6-word decision when only the body paraphrases — id citation is required', async () => {
await setupPhase(
// 4 words → cannot soft-match; user must cite the id.
`<decisions>
### Cat
- **D-81:** Use bit offsets always
</decisions>`,
{
'17-01-PLAN.md': planFile(
` truths: ["something else"]\n artifacts: []\n key_links: []`,
// No D-81 citation, paraphrase only.
'## tasks\n- Use bit offsets in storage layer\n',
),
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(false);
expect(result.data.uncovered.map((u: { id: string }) => u.id)).toEqual(['D-81']);
});
it('still passes a sub-6-word decision when the id is cited', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-82:** Disable cache
</decisions>`,
{
'17-01-PLAN.md': planFile(` truths: ["D-82"]\n artifacts: []\n key_links: []`),
},
);
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(result.data.passed).toBe(true);
});
});
describe('verify-phase summary parsing (review F6, F7)', () => {
it('reads files_modified from EVERY summary, not just the first', async () => {
await mkdir(phaseDir, { recursive: true });
await writeFile(
contextPath,
`# Phase 17 Context
<decisions>
### Cat
- **D-83:** A long-enough trackable decision text for soft matching honored elsewhere.
</decisions>
`,
'utf-8',
);
await writeFile(
join(phaseDir, '17-01-PLAN.md'),
planFile(` truths: []\n artifacts: []\n key_links: []`),
'utf-8',
);
// Summary 01 — no files_modified mentioning D-83.
await writeFile(
join(phaseDir, '17-01-SUMMARY.md'),
'files_modified:\n - "src/unrelated.ts"\n',
'utf-8',
);
// Summary 02 — files_modified entry whose content mentions D-83.
await writeFile(
join(phaseDir, '17-02-SUMMARY.md'),
'files_modified:\n - "src/keeper.ts"\n',
'utf-8',
);
await mkdir(join(tmp, 'src'), { recursive: true });
await writeFile(join(tmp, 'src', 'unrelated.ts'), '// nothing relevant\n', 'utf-8');
await writeFile(join(tmp, 'src', 'keeper.ts'), '// honors D-83 in code\n', 'utf-8');
const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp);
// If only the first SUMMARY were parsed, D-83 would be missing.
expect(result.data.honored).toBe(1);
expect(result.data.not_honored).toEqual([]);
});
it('rejects absolute files_modified paths outside projectDir (path traversal guard)', async () => {
await mkdir(phaseDir, { recursive: true });
await writeFile(
contextPath,
`# Phase 17
<decisions>
### Cat
- **D-84:** A trackable decision text spanning enough words to soft-match.
</decisions>
`,
'utf-8',
);
await writeFile(
join(phaseDir, '17-01-PLAN.md'),
planFile(` truths: []\n artifacts: []\n key_links: []`),
'utf-8',
);
// Summary points at /etc/passwd and a parent-traversal path. Both must be skipped.
await writeFile(
join(phaseDir, '17-01-SUMMARY.md'),
'files_modified:\n - "/etc/passwd"\n - "../../../etc/hostname"\n',
'utf-8',
);
const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp);
// Should not honor D-84 from those files (and should not throw).
expect(result.data.honored).toBe(0);
expect(result.data.not_honored.map((u: { id: string }) => u.id)).toEqual(['D-84']);
});
});
describe('workstream-aware config (review F3)', () => {
it('honors workstream-scoped context_coverage_gate=false', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-85:** A trackable decision long enough to potentially soft match.
</decisions>`,
{ '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) },
);
// Root config does NOT disable the gate.
await mkdir(join(tmp, '.planning'), { recursive: true });
await writeFile(
join(tmp, '.planning', 'config.json'),
JSON.stringify({ workflow: { context_coverage_gate: true } }),
'utf-8',
);
// Workstream config DOES disable it.
await mkdir(join(tmp, '.planning', 'workstreams', 'feat-x'), { recursive: true });
await writeFile(
join(tmp, '.planning', 'workstreams', 'feat-x', 'config.json'),
JSON.stringify({ workflow: { context_coverage_gate: false } }),
'utf-8',
);
// Without workstream → enabled → would fail
const rootResult = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
expect(rootResult.data.skipped).toBe(false);
expect(rootResult.data.passed).toBe(false);
// With workstream → workstream config disables → skipped
const wsResult = await checkDecisionCoveragePlan(
[phaseDir, contextPath],
tmp,
'feat-x',
);
expect(wsResult.data.skipped).toBe(true);
expect(wsResult.data.passed).toBe(true);
// Same for verify
const wsVerify = await checkDecisionCoverageVerify(
[phaseDir, contextPath],
tmp,
'feat-x',
);
expect(wsVerify.data.skipped).toBe(true);
});
});
describe('config-type validation (review F16)', () => {
it('warns and defaults to ON when context_coverage_gate is a number', async () => {
await setupPhase(
`<decisions>
### Cat
- **D-86:** A trackable decision text long enough to soft-match.
</decisions>`,
{ '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) },
);
await mkdir(join(tmp, '.planning'), { recursive: true });
await writeFile(
join(tmp, '.planning', 'config.json'),
JSON.stringify({ workflow: { context_coverage_gate: 1 } }),
'utf-8',
);
const warnings: string[] = [];
const origWarn = console.warn;
console.warn = (msg: string) => warnings.push(String(msg));
try {
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
// Defaulted to ON → not skipped, runs the gate (and fails with uncovered D-86).
expect(result.data.skipped).toBe(false);
expect(result.data.passed).toBe(false);
} finally {
console.warn = origWarn;
}
expect(warnings.some((w) => /context_coverage_gate.*invalid type/.test(w))).toBe(true);
});
});

View File

@@ -0,0 +1,554 @@
/**
* Decision-coverage gates — issue #2492.
*
* Two handlers, two semantics:
*
* - `check.decision-coverage-plan` — translation gate, BLOCKING.
* Plan-phase calls this after the existing requirements coverage gate.
* Each trackable CONTEXT.md decision must appear (by id or normalized
* phrase) in at least one PLAN.md `must_haves` / `truths` block or in
* the plan body. A miss returns `passed: false` with a clear message
* naming the missed decision; the workflow surfaces this to the user
* and refuses to mark the phase planned.
*
* - `check.decision-coverage-verify` — validation gate, NON-BLOCKING.
* Verify-phase calls this. Each trackable decision is searched in the
* phase's shipped artifacts (PLAN.md, SUMMARY.md, files_modified, recent
* commit subjects). Misses are reported but do NOT change verification
* status. Rationale: by verification time the work is done; a fuzzy
* "honored" check is a soft signal, not a blocker.
*
* Both gates short-circuit when `workflow.context_coverage_gate` is `false`.
*
* Match strategy (used by both gates):
* 1. Strict id match — `D-NN` appears verbatim somewhere in the searched
* text. This is the path users should aim for.
* 2. Soft phrase match — a normalized 6+-word slice of the decision text
* appears as a substring. Catches plans/summaries that paraphrase but
* forget the id.
*/
import { readdir, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, isAbsolute } from 'node:path';
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { loadConfig } from '../config.js';
import { parseDecisions, type ParsedDecision } from './decisions.js';
import type { QueryHandler } from './utils.js';
const execFile = promisify(execFileCb);
interface GateUncoveredItem {
id: string;
text: string;
category: string;
}
interface PlanGateData {
passed: boolean;
skipped: boolean;
reason?: string;
total: number;
covered: number;
uncovered: GateUncoveredItem[];
message: string;
}
interface VerifyGateData {
skipped: boolean;
blocking: false;
reason?: string;
total: number;
honored: number;
not_honored: GateUncoveredItem[];
message: string;
}
function normalizePhrase(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** Minimum normalized words a decision must have to be soft-matchable. */
const SOFT_PHRASE_MIN_WORDS = 6;
/**
* Build a soft-match phrase: the first 6 normalized words. Six is empirically
* long enough to avoid collisions with common English fragments and short
* enough to survive minor rewordings.
*
* Returns an empty string when the decision text has fewer than
* SOFT_PHRASE_MIN_WORDS words — such decisions are effectively id-only and
* callers must rely on a `D-NN` citation (review F5).
*/
function softPhrase(text: string): string {
const words = normalizePhrase(text).split(' ').filter(Boolean);
if (words.length < SOFT_PHRASE_MIN_WORDS) return '';
return words.slice(0, SOFT_PHRASE_MIN_WORDS).join(' ');
}
/** True when a decision is too short to soft-match — caller must cite by id. */
function requiresIdCitation(decision: ParsedDecision): boolean {
const wordCount = normalizePhrase(decision.text).split(' ').filter(Boolean).length;
return wordCount < SOFT_PHRASE_MIN_WORDS;
}
/** True when decision text or id appears in `haystack`. */
function decisionMentioned(haystack: string, decision: ParsedDecision): boolean {
if (!haystack) return false;
const idRe = new RegExp(`\\b${decision.id}\\b`);
if (idRe.test(haystack)) return true;
const phrase = softPhrase(decision.text);
if (!phrase) return false; // too short to soft-match — id citation required
return normalizePhrase(haystack).includes(phrase);
}
async function readIfExists(path: string): Promise<string> {
try {
return await readFile(path, 'utf-8');
} catch {
return '';
}
}
async function loadPlanContents(phaseDir: string): Promise<string[]> {
if (!existsSync(phaseDir)) return [];
let entries: string[] = [];
try {
entries = await readdir(phaseDir);
} catch {
return [];
}
const planFiles = entries.filter((e) => /-PLAN\.md$/.test(e));
const out: string[] = [];
for (const f of planFiles) {
out.push(await readIfExists(join(phaseDir, f)));
}
return out;
}
/**
* One plan reduced to the sections the BLOCKING translation gate searches.
*
* The plan-phase gate refuses to honor a decision mention buried in a code
* fence, an HTML comment, or arbitrary prose elsewhere on the page. The user
* must put a `D-NN` citation (or a 6+-word phrase) in a designated section
* so they have an unambiguous way to make a decision deliberately uncovered.
*
* Designated sections (review F4):
* - Front-matter `must_haves` block (YAML)
* - Front-matter `truths` block (YAML)
* - Front-matter `objective` field
* - Body section under a heading whose text contains "must_haves",
* "truths", "tasks", or "objective" (case-insensitive)
*
* HTML comments (`<!-- ... -->`) and fenced code blocks are stripped before
* extraction so neither a commented-out citation nor a literal example
* counts as coverage.
*/
interface PlanSections {
/** Concatenation of all designated section text, with HTML comments and code fences stripped. */
designated: string;
}
const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
/** Strip HTML comments AND fenced code blocks from `text`. */
function stripCommentsAndFences(text: string): string {
return text
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/~~~[\s\S]*?~~~/g, ' ');
}
/** Extract a YAML block scalar (key followed by indented continuation lines). */
function extractYamlBlock(frontmatter: string, key: string): string {
const re = new RegExp(`^${key}\\s*:(.*)$`, 'm');
const match = frontmatter.match(re);
if (!match) return '';
const startIdx = (match.index ?? 0) + match[0].length;
const sameLine = match[1] ?? '';
const rest = frontmatter.slice(startIdx + 1).split(/\r?\n/);
const block: string[] = [sameLine];
for (const line of rest) {
// Stop at a non-indented, non-empty line (next top-level key) or end of frontmatter.
if (line === '' || /^\s/.test(line)) {
block.push(line);
} else {
break;
}
}
return block.join('\n');
}
function extractPlanSections(planContent: string): PlanSections {
if (!planContent) return { designated: '' };
const cleaned = stripCommentsAndFences(planContent);
// Split front-matter from body.
const fmMatch = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
const frontmatter = fmMatch ? fmMatch[1] : '';
const body = fmMatch ? fmMatch[2] : cleaned;
const fmParts: string[] = [];
for (const key of ['must_haves', 'truths', 'objective']) {
const block = extractYamlBlock(frontmatter, key);
if (block) fmParts.push(block);
}
// Body sections under designated headings (must_haves, truths, tasks, objective).
const bodyLines = body.split(/\r?\n/);
const bodyParts: string[] = [];
let inDesignated = false;
for (const line of bodyLines) {
const heading = /^#{1,6}\s+/.test(line);
if (heading) {
inDesignated = DESIGNATED_HEADINGS_RE.test(line);
if (inDesignated) bodyParts.push(line);
continue;
}
if (inDesignated) bodyParts.push(line);
}
return { designated: [...fmParts, bodyParts.join('\n')].join('\n\n') };
}
async function loadPlanSections(phaseDir: string): Promise<PlanSections[]> {
const contents = await loadPlanContents(phaseDir);
return contents.map(extractPlanSections);
}
/** True when a decision is mentioned in any plan's designated sections. */
function planSectionsMention(planSections: PlanSections[], decision: ParsedDecision): boolean {
for (const p of planSections) {
if (decisionMentioned(p.designated, decision)) return true;
}
return false;
}
async function loadGateConfig(projectDir: string, workstream?: string): Promise<boolean> {
try {
const cfg = await loadConfig(projectDir, workstream);
const wf = (cfg.workflow ?? {}) as unknown as Record<string, unknown>;
const v = wf.context_coverage_gate;
if (typeof v === 'boolean') return v;
// Tolerate stringified booleans coming from environment-variable-style configs,
// but warn loudly on numeric / other-shaped values so silent type drift surfaces.
// Schema-vs-loadConfig validation gap (review F16, mirror of #2609).
if (typeof v === 'string') {
const lower = v.toLowerCase();
if (lower === 'false' || lower === 'true') return lower !== 'false';
console.warn(
`[gsd] workflow.context_coverage_gate is a string "${v}" — expected boolean. Defaulting to ON.`,
);
return true;
}
if (v !== undefined && v !== null) {
console.warn(
`[gsd] workflow.context_coverage_gate has invalid type ${typeof v} (value: ${JSON.stringify(v)}); expected boolean. Defaulting to ON.`,
);
}
return true; // default ON
} catch {
return true;
}
}
function resolvePath(p: string, projectDir: string): string {
return isAbsolute(p) ? p : join(projectDir, p);
}
function buildPlanMessage(uncovered: GateUncoveredItem[]): string {
if (uncovered.length === 0) return 'All trackable CONTEXT.md decisions are covered by plans.';
const lines = [
`## ⚠ Decision Coverage Gap`,
``,
`${uncovered.length} CONTEXT.md decision(s) are not covered by any plan:`,
``,
];
for (const u of uncovered) {
lines.push(`- **${u.id}** (${u.category || 'uncategorized'}): ${u.text}`);
}
lines.push('');
lines.push(
'Resolve by citing `D-NN:` in a relevant plan\'s `must_haves`/`truths` (or body),',
);
lines.push(
'OR move the decision to `### Claude\'s Discretion` / tag it `[informational]` if it should not be tracked.',
);
return lines.join('\n');
}
function buildVerifyMessage(notHonored: GateUncoveredItem[]): string {
if (notHonored.length === 0)
return 'All trackable CONTEXT.md decisions are honored by shipped artifacts.';
const lines = [
`### Decision Coverage (warning)`,
``,
`${notHonored.length} decision(s) not found in shipped artifacts:`,
``,
];
for (const u of notHonored) {
lines.push(`- **${u.id}** (${u.category || 'uncategorized'}): ${u.text}`);
}
lines.push('');
lines.push('This is a soft warning — verification status is unchanged.');
return lines.join('\n');
}
// ─── Plan-phase gate ──────────────────────────────────────────────────────
export const checkDecisionCoveragePlan: QueryHandler = async (args, projectDir, workstream) => {
const phaseDir = args[0] ? resolvePath(args[0], projectDir) : '';
const contextPath = args[1] ? resolvePath(args[1], projectDir) : '';
const enabled = await loadGateConfig(projectDir, workstream);
if (!enabled) {
const data: PlanGateData = {
passed: true,
skipped: true,
reason: 'workflow.context_coverage_gate is false',
total: 0,
covered: 0,
uncovered: [],
message: 'Decision coverage gate disabled by config.',
};
return { data };
}
if (!contextPath || !existsSync(contextPath)) {
const data: PlanGateData = {
passed: true,
skipped: true,
reason: 'CONTEXT.md missing',
total: 0,
covered: 0,
uncovered: [],
message: 'No CONTEXT.md — nothing to check.',
};
return { data };
}
const contextRaw = await readIfExists(contextPath);
const decisions = parseDecisions(contextRaw).filter((d) => d.trackable);
if (decisions.length === 0) {
const data: PlanGateData = {
passed: true,
skipped: true,
reason: 'no trackable decisions',
total: 0,
covered: 0,
uncovered: [],
message: 'No trackable decisions in CONTEXT.md.',
};
return { data };
}
const planSections = await loadPlanSections(phaseDir);
const uncovered: GateUncoveredItem[] = [];
let covered = 0;
for (const d of decisions) {
if (planSectionsMention(planSections, d)) {
covered++;
} else {
uncovered.push({ id: d.id, text: d.text, category: d.category });
}
}
const passed = uncovered.length === 0;
const data: PlanGateData = {
passed,
skipped: false,
total: decisions.length,
covered,
uncovered,
message: buildPlanMessage(uncovered),
};
return { data };
};
// ─── Verify-phase gate ────────────────────────────────────────────────────
/**
* Recent commit subjects + bodies, capped at 200 to span typical phase boundaries
* even on busy repos. The non-blocking verify gate trades precision for recall —
* a few extra commits in the haystack only inflate "honored" counts harmlessly,
* while too few commits could cause false misses on long-running phases (review F18).
*/
async function recentCommitMessages(projectDir: string, limit = 200): Promise<string> {
try {
const { stdout } = await execFile('git', ['log', `-n`, String(limit), '--pretty=%s%n%b'], {
cwd: projectDir,
maxBuffer: 4 * 1024 * 1024,
});
return stdout;
} catch {
return '';
}
}
/** Per-file size cap when slurping modified-file contents into the verify haystack. */
const MAX_MODIFIED_FILE_BYTES = 256 * 1024;
/** Read a file and truncate to MAX_MODIFIED_FILE_BYTES; returns '' on error. */
async function readBoundedFile(absPath: string): Promise<string> {
try {
const raw = await readFile(absPath, 'utf-8');
return raw.length > MAX_MODIFIED_FILE_BYTES ? raw.slice(0, MAX_MODIFIED_FILE_BYTES) : raw;
} catch {
return '';
}
}
/**
* True when `candidatePath` (after resolution) is contained within `rootDir`.
* Rejects absolute paths outside the root, `..` traversal, and any input
* whose canonical form escapes the project boundary (review F7).
*
* Note: this is a lexical check. Symlink targets are NOT resolved here — we
* intentionally do not follow links, so a symlink inside the project pointing
* outside is not de-referenced (we read the link's target only if it resolves
* within projectDir). For full symlink hardening callers should run on a
* trusted SUMMARY.md.
*/
function isInsideRoot(candidatePath: string, rootDir: string): boolean {
const root = isAbsolute(rootDir) ? rootDir : join(process.cwd(), rootDir);
const target = isAbsolute(candidatePath) ? candidatePath : join(root, candidatePath);
// Normalize both via path.resolve-equivalent (join handles `..`).
const normalizedRoot = root.endsWith('/') ? root : root + '/';
const normalizedTarget = target;
return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
}
async function readModifiedFilesContent(projectDir: string, summaries: string[]): Promise<string> {
// Walk EVERY summary independently and aggregate file paths. The previous
// implementation matched only the first `files_modified:` block in a
// concatenated string — when two summaries shipped in one phase the second
// plan's files were silently dropped (review F6).
const out: string[] = [];
let total = 0;
for (const summary of summaries) {
if (!summary) continue;
// /g so multiple `files_modified:` blocks in a single summary are also captured.
const blockMatches = summary.matchAll(/files_modified:\s*\n((?:[ \t]*-\s+.+\n?)+)/g);
for (const blockMatch of blockMatches) {
const block = blockMatch[1] ?? '';
const files = [...block.matchAll(/-\s+(.+)/g)].map((m) =>
m[1].trim().replace(/^["']|["']$/g, ''),
);
for (const f of files) {
if (!f) continue;
if (total >= 50) break; // cap total files across all summaries
// Reject absolute paths AND any relative path that escapes projectDir.
if (!isInsideRoot(f, projectDir)) {
console.warn(
`[gsd] decision-coverage: skipping files_modified entry "${f}" — outside project root`,
);
continue;
}
out.push(await readBoundedFile(resolvePath(f, projectDir)));
total++;
}
if (total >= 50) break;
}
if (total >= 50) break;
}
return out.join('\n\n');
}
export const checkDecisionCoverageVerify: QueryHandler = async (args, projectDir, workstream) => {
const phaseDir = args[0] ? resolvePath(args[0], projectDir) : '';
const contextPath = args[1] ? resolvePath(args[1], projectDir) : '';
const enabled = await loadGateConfig(projectDir, workstream);
if (!enabled) {
const data: VerifyGateData = {
skipped: true,
blocking: false,
reason: 'workflow.context_coverage_gate is false',
total: 0,
honored: 0,
not_honored: [],
message: 'Decision coverage gate disabled by config.',
};
return { data };
}
if (!contextPath || !existsSync(contextPath)) {
const data: VerifyGateData = {
skipped: true,
blocking: false,
reason: 'CONTEXT.md missing',
total: 0,
honored: 0,
not_honored: [],
message: 'No CONTEXT.md — nothing to check.',
};
return { data };
}
const contextRaw = await readIfExists(contextPath);
const decisions = parseDecisions(contextRaw).filter((d) => d.trackable);
if (decisions.length === 0) {
const data: VerifyGateData = {
skipped: true,
blocking: false,
reason: 'no trackable decisions',
total: 0,
honored: 0,
not_honored: [],
message: 'No trackable decisions in CONTEXT.md.',
};
return { data };
}
// Verify-phase haystack is intentionally broad — this gate is non-blocking and looks
// for honored decisions across all phase artifacts, not just plan front-matter sections.
const planContents = await loadPlanContents(phaseDir);
// Read all *-SUMMARY.md files in phaseDir, capped to keep the haystack bounded.
const summaryParts: string[] = [];
let summaryContent = '';
if (existsSync(phaseDir)) {
try {
const entries = await readdir(phaseDir);
for (const e of entries.filter((x) => /-SUMMARY\.md$/.test(x))) {
summaryParts.push(await readIfExists(join(phaseDir, e)));
}
} catch {
/* ignore */
}
}
summaryContent = summaryParts.join('\n\n');
const filesModifiedContent = await readModifiedFilesContent(projectDir, summaryParts);
const commits = await recentCommitMessages(projectDir);
const haystack = [planContents.join('\n\n'), summaryContent, filesModifiedContent, commits].join(
'\n\n',
);
const notHonored: GateUncoveredItem[] = [];
let honored = 0;
for (const d of decisions) {
if (decisionMentioned(haystack, d)) {
honored++;
} else {
notHonored.push({ id: d.id, text: d.text, category: d.category });
}
}
const data: VerifyGateData = {
skipped: false,
blocking: false,
total: decisions.length,
honored,
not_honored: notHonored,
message: buildVerifyMessage(notHonored),
};
return { data };
};

View File

@@ -63,6 +63,7 @@ export const checkConfigGates: QueryHandler = async (args, projectDir) => {
verifier: workflowBool(wf.verifier, true),
plan_check: workflowBool(planCheckFlag, true),
subagent_timeout: wf.subagent_timeout ?? CONFIG_DEFAULTS.workflow.subagent_timeout,
context_coverage_gate: workflowBool(wf.context_coverage_gate, true),
};
return { data };

View File

@@ -34,6 +34,13 @@ describe('isValidConfigKey', () => {
expect(isValidConfigKey('workflow.auto_advance').valid).toBe(true);
});
it('accepts workflow.context_coverage_gate (#2492)', async () => {
const { isValidConfigKey, parseConfigValue } = await import('./config-mutation.js');
expect(isValidConfigKey('workflow.context_coverage_gate').valid).toBe(true);
expect(parseConfigValue('true')).toBe(true);
expect(parseConfigValue('false')).toBe(false);
});
it('accepts wildcard agent_skills.* patterns', async () => {
const { isValidConfigKey } = await import('./config-mutation.js');
expect(isValidConfigKey('agent_skills.gsd-planner').valid).toBe(true);
@@ -79,6 +86,42 @@ describe('isValidConfigKey', () => {
expect(r2.valid).toBe(false);
expect(r2.suggestion).toBe('workflow.nyquist_validation');
});
// #2653 — SDK/CJS config-schema drift regression.
// Every key accepted by the CJS config-set must also be accepted by
// the SDK config-set. We exercise every entry in the shared schema
// so drift fails this test the moment it is introduced.
it('#2653 — accepts every key in shared VALID_CONFIG_KEYS', async () => {
const { isValidConfigKey } = await import('./config-mutation.js');
const { VALID_CONFIG_KEYS } = await import('./config-schema.js');
const rejected: string[] = [];
for (const key of VALID_CONFIG_KEYS) {
const { valid } = isValidConfigKey(key);
if (!valid) rejected.push(key);
}
expect(rejected).toEqual([]);
});
it('#2653 — accepts sample dynamic keys from every DYNAMIC_KEY_PATTERN', async () => {
const { isValidConfigKey } = await import('./config-mutation.js');
const samples = [
'agent_skills.gsd-planner',
'review.models.claude',
'features.some_feature',
'claude_md_assembly.blocks.intro',
'model_profile_overrides.codex.opus',
'model_profile_overrides.codex.sonnet',
'model_profile_overrides.my-runtime.haiku',
];
for (const key of samples) {
expect(isValidConfigKey(key).valid, `expected ${key} to be accepted`).toBe(true);
}
});
it('#2653 — accepts planning.sub_repos (CJS/docs key, previously rejected by SDK)', async () => {
const { isValidConfigKey } = await import('./config-mutation.js');
expect(isValidConfigKey('planning.sub_repos').valid).toBe(true);
});
});
// ─── parseConfigValue ──────────────────────────────────────────────────────

View File

@@ -23,6 +23,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { VALID_PROFILES, getAgentToModelMapForProfile } from './config-query.js';
import { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } from './config-schema.js';
import { planningPaths } from './helpers.js';
import { acquireStateLock, releaseStateLock } from './state-mutation.js';
import type { QueryHandler } from './utils.js';
@@ -45,41 +46,8 @@ async function atomicWriteConfig(configPath: string, config: Record<string, unkn
}
// ─── VALID_CONFIG_KEYS ────────────────────────────────────────────────────
/**
* Allowlist of valid config key paths.
*
* Ported from config.cjs lines 14-37.
* Dynamic patterns (agent_skills.*, features.*) are handled
* separately in isValidConfigKey.
*/
const VALID_CONFIG_KEYS = new Set([
'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
'search_gitignored', 'brave_search', 'firecrawl', 'exa_search',
'workflow.research', 'workflow.plan_check', 'workflow.verifier',
'workflow.nyquist_validation', 'workflow.ui_phase', 'workflow.ui_safety_gate',
'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget',
'workflow.text_mode',
'workflow.research_before_questions',
'workflow.discuss_mode',
'workflow.skip_discuss',
'workflow._auto_chain_active',
'workflow.use_worktrees',
'workflow.code_review',
'workflow.code_review_depth',
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template',
'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored',
'workflow.subagent_timeout',
'hooks.context_warnings',
'features.thinking_partner',
'features.global_learnings',
'learnings.max_inject',
'context',
'project_code', 'phase_naming',
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
'response_language',
]);
// Imported from ./config-schema.js — single source of truth, kept in sync
// with get-shit-done/bin/lib/config-schema.cjs by a CI parity test (#2653).
// ─── CONFIG_KEY_SUGGESTIONS (D9 — match CJS config.cjs:57-67) ────────────
@@ -94,9 +62,13 @@ const CONFIG_KEY_SUGGESTIONS: Record<string, string> = {
'hooks.research_questions': 'workflow.research_before_questions',
'workflow.research_questions': 'workflow.research_before_questions',
'workflow.codereview': 'workflow.code_review',
'workflow.review_command': 'workflow.code_review_command',
'workflow.review': 'workflow.code_review',
'workflow.code_review_level': 'workflow.code_review_depth',
'workflow.review_depth': 'workflow.code_review_depth',
'review.model': 'review.models.<cli-name>',
'sub_repos': 'planning.sub_repos',
'plan_checker': 'workflow.plan_check',
};
// ─── isValidConfigKey ─────────────────────────────────────────────────────
@@ -114,11 +86,10 @@ const CONFIG_KEY_SUGGESTIONS: Record<string, string> = {
export function isValidConfigKey(keyPath: string): { valid: boolean; suggestion?: string } {
if (VALID_CONFIG_KEYS.has(keyPath)) return { valid: true };
// Dynamic patterns: agent_skills.<agent-type>
if (/^agent_skills\.[a-zA-Z0-9_-]+$/.test(keyPath)) return { valid: true };
// Dynamic patterns: features.<feature_name>
if (/^features\.[a-zA-Z0-9_]+$/.test(keyPath)) return { valid: true };
// Dynamic patterns — all sourced from shared config-schema (#2653).
// Covers agent_skills.*, review.models.*, features.*,
// claude_md_assembly.blocks.*, and model_profile_overrides.*.<tier>.
if (DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath))) return { valid: true };
// D9: Check curated suggestions before LCP fallback
if (CONFIG_KEY_SUGGESTIONS[keyPath]) {

View File

@@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { GSDError } from '../errors.js';
import { GSDError, ErrorClassification, exitCodeFor } from '../errors.js';
// ─── Test setup ─────────────────────────────────────────────────────────────
@@ -58,6 +58,41 @@ describe('configGet', () => {
await expect(configGet(['nonexistent.key'], tmpDir)).rejects.toThrow(GSDError);
});
it('throws GSDError that maps to exit code 1 for missing key (bug #2544)', async () => {
const { configGet } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'quality' }),
);
try {
await configGet(['nonexistent.key'], tmpDir);
throw new Error('expected configGet to throw for missing key');
} catch (err) {
expect(err).toBeInstanceOf(GSDError);
const gsdErr = err as GSDError;
// UNIX convention: missing config key should exit 1 (like `git config --get`).
// Validation (exit 10) is the previous buggy classification — see issue #2544.
expect(gsdErr.classification).toBe(ErrorClassification.Execution);
expect(exitCodeFor(gsdErr.classification)).toBe(1);
}
});
it('throws GSDError that maps to exit code 1 when traversing into non-object (bug #2544)', async () => {
const { configGet } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'quality' }),
);
try {
await configGet(['model_profile.subkey'], tmpDir);
throw new Error('expected configGet to throw');
} catch (err) {
expect(err).toBeInstanceOf(GSDError);
const gsdErr = err as GSDError;
expect(exitCodeFor(gsdErr.classification)).toBe(1);
}
});
it('reads raw config without merging defaults', async () => {
const { configGet } = await import('./config-query.js');
// Write config with only model_profile -- no workflow section

View File

@@ -104,12 +104,14 @@ export const configGet: QueryHandler = async (args, projectDir, _workstream) =>
let current: unknown = config;
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Validation);
// UNIX convention (cf. `git config --get`): missing key exits 1, not 10.
// See issue #2544 — callers use `if ! gsd-sdk query config-get k; then` patterns.
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
}
current = (current as Record<string, unknown>)[key];
}
if (current === undefined) {
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Validation);
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
}
return { data: current };

View File

@@ -0,0 +1,117 @@
/**
* SDK-side mirror of get-shit-done/bin/lib/config-schema.cjs.
*
* Single source of truth for valid config key paths accepted by
* `config-set`. MUST stay in sync with the CJS schema — enforced
* by tests/config-schema-sdk-parity.test.cjs (CI drift guard).
*
* If you add/remove a key here, make the identical change in
* get-shit-done/bin/lib/config-schema.cjs (and vice versa). The
* parity test asserts the two allowlists are set-equal and that
* DYNAMIC_KEY_PATTERN_SOURCES produce identical regex source strings.
*
* See #2653 — CJS/SDK drift caused config-set to reject documented
* keys. #2479 added CJS↔docs parity; #2653 adds CJS↔SDK parity.
*/
/** Exact-match config key paths accepted by config-set. */
export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
'search_gitignored', 'brave_search', 'firecrawl', 'exa_search',
'workflow.research', 'workflow.plan_check', 'workflow.verifier',
'workflow.nyquist_validation', 'workflow.ai_integration_phase', 'workflow.ui_phase', 'workflow.ui_safety_gate',
'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget',
'workflow.tdd_mode',
'workflow.text_mode',
'workflow.research_before_questions',
'workflow.discuss_mode',
'workflow.skip_discuss',
'workflow.auto_prune_state',
'workflow.use_worktrees',
'workflow.code_review',
'workflow.code_review_depth',
'workflow.code_review_command',
'workflow.pattern_mapper',
'workflow.plan_bounce',
'workflow.plan_bounce_script',
'workflow.plan_bounce_passes',
'workflow.plan_chunked',
'workflow.post_planning_gaps',
'workflow.security_enforcement',
'workflow.security_asvs_level',
'workflow.security_block_on',
'workflow.drift_threshold',
'workflow.drift_action',
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
'workflow.cross_ai_execution', 'workflow.cross_ai_command', 'workflow.cross_ai_timeout',
'workflow.subagent_timeout',
'workflow.inline_plan_threshold',
'hooks.context_warnings',
'hooks.workflow_guard',
'workflow.context_coverage_gate',
'statusline.show_last_command',
'workflow.ui_review',
'workflow.max_discuss_passes',
'features.thinking_partner',
'context',
'features.global_learnings',
'learnings.max_inject',
'project_code', 'phase_naming',
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
'response_language',
'context_window',
'intel.enabled',
'graphify.enabled',
'graphify.build_timeout',
'claude_md_path',
'claude_md_assembly.mode',
// #2517 — runtime-aware model profiles
'runtime',
]);
/**
* Dynamic-pattern validators — keys matching these regexes are also accepted.
* Each entry's `source` MUST equal the corresponding CJS regex `.source`
* (the parity test enforces this).
*/
export interface DynamicKeyPattern {
readonly test: (k: string) => boolean;
readonly description: string;
readonly source: string;
}
export const DYNAMIC_KEY_PATTERNS: readonly DynamicKeyPattern[] = [
{
source: '^agent_skills\\.[a-zA-Z0-9_-]+$',
description: 'agent_skills.<agent-type>',
test: (k) => /^agent_skills\.[a-zA-Z0-9_-]+$/.test(k),
},
{
source: '^review\\.models\\.[a-zA-Z0-9_-]+$',
description: 'review.models.<cli-name>',
test: (k) => /^review\.models\.[a-zA-Z0-9_-]+$/.test(k),
},
{
source: '^features\\.[a-zA-Z0-9_]+$',
description: 'features.<feature_name>',
test: (k) => /^features\.[a-zA-Z0-9_]+$/.test(k),
},
{
source: '^claude_md_assembly\\.blocks\\.[a-zA-Z0-9_]+$',
description: 'claude_md_assembly.blocks.<section>',
test: (k) => /^claude_md_assembly\.blocks\.[a-zA-Z0-9_]+$/.test(k),
},
// #2517 — runtime-aware model profile overrides: model_profile_overrides.<runtime>.<tier>
{
source: '^model_profile_overrides\\.[a-zA-Z0-9_-]+\\.(opus|sonnet|haiku)$',
description: 'model_profile_overrides.<runtime>.<opus|sonnet|haiku>',
test: (k) => /^model_profile_overrides\.[a-zA-Z0-9_-]+\.(opus|sonnet|haiku)$/.test(k),
},
];
/** Returns true if keyPath is a valid config key (exact or dynamic pattern). */
export function isValidConfigKeyPath(keyPath: string): boolean {
if (VALID_CONFIG_KEYS.has(keyPath)) return true;
return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));
}

View File

@@ -0,0 +1,215 @@
/**
* Unit tests for CONTEXT.md `<decisions>` parser.
*
* Decision format (from `discuss-phase.md` lines 10351048):
*
* <decisions>
* ## Implementation Decisions
*
* ### Category A
* - **D-01:** First decision text
* - **D-02 [folded]:** Second decision text
*
* ### Claude's Discretion
* - free-form, never tracked
*
* ### Folded Todos
* - **D-03 [folded]:** ...
* </decisions>
*
* Issue #2492.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { parseDecisions } from './decisions.js';
const MINIMAL = `# Phase 17 Context
<decisions>
## Implementation Decisions
### API Surface
- **D-01:** Use bit offsets, not byte offsets
- **D-02:** Display TArray element type alongside count
### Storage
- **D-03 [informational]:** Backing store is on disk
- **D-04:** Persist via SQLite WAL mode
### Claude's Discretion
- Naming of internal helpers is up to the implementer
- **D-99:** This should be ignored — it lives under Discretion
### Folded Todos
- **D-05 [folded]:** Add a CLI flag for verbose mode
</decisions>
`;
describe('parseDecisions (#2492)', () => {
it('extracts D-NN decisions with id, text, and category', () => {
const decisions = parseDecisions(MINIMAL);
const ids = decisions.map((d) => d.id);
expect(ids).toContain('D-01');
expect(ids).toContain('D-02');
expect(ids).toContain('D-04');
const d01 = decisions.find((d) => d.id === 'D-01');
expect(d01?.text).toBe('Use bit offsets, not byte offsets');
expect(d01?.category).toBe('API Surface');
});
it('captures bracketed tags', () => {
const decisions = parseDecisions(MINIMAL);
const d05 = decisions.find((d) => d.id === 'D-05');
expect(d05?.tags).toContain('folded');
const d03 = decisions.find((d) => d.id === 'D-03');
expect(d03?.tags).toContain('informational');
});
it('marks Claude\'s Discretion entries as non-trackable', () => {
const decisions = parseDecisions(MINIMAL);
const d99 = decisions.find((d) => d.id === 'D-99');
expect(d99).toBeDefined();
expect(d99?.trackable).toBe(false);
// And it must NOT appear in the trackable filter
const trackableIds = decisions.filter((d) => d.trackable).map((d) => d.id);
expect(trackableIds).not.toContain('D-99');
});
it('marks [informational] entries as opt-out (excluded from trackable by default)', () => {
const trackable = parseDecisions(MINIMAL).filter((d) => d.trackable);
const ids = trackable.map((d) => d.id);
expect(ids).toContain('D-01');
expect(ids).toContain('D-02');
expect(ids).toContain('D-04');
expect(ids).not.toContain('D-03'); // [informational] tag
expect(ids).not.toContain('D-05'); // [folded] tag — not user-facing decision
});
it('returns empty array when CONTEXT.md has no <decisions> block', () => {
expect(parseDecisions('# Phase 1\n\nNo decisions here.\n')).toEqual([]);
});
it('returns empty array when content is empty', () => {
expect(parseDecisions('')).toEqual([]);
});
it('returns empty array when <decisions> block is empty', () => {
expect(parseDecisions('<decisions>\n</decisions>')).toEqual([]);
});
it('does not crash on malformed bullet lines', () => {
const malformed = `<decisions>
- not a decision (no D-NN)
- **D-bogus:** wrong id format
- **D-7:** single digit allowed
- **D-10:** ten
</decisions>`;
const decisions = parseDecisions(malformed);
const ids = decisions.map((d) => d.id);
expect(ids).toContain('D-7');
expect(ids).toContain('D-10');
expect(ids).not.toContain('D-bogus');
});
it('preserves multi-line decision text continuations', () => {
const multi = `<decisions>
### Cat
- **D-01:** First line
continues here
- **D-02:** Second
</decisions>`;
const decisions = parseDecisions(multi);
const d01 = decisions.find((d) => d.id === 'D-01');
expect(d01?.text).toMatch(/First line/);
});
// ─── Adversarial-review regressions ────────────────────────────────────
it('ignores `<decisions>` blocks inside fenced code (review F11)', () => {
const content = `# Doc
\`\`\`
<decisions>
### Example
- **D-99:** Should not be parsed
</decisions>
\`\`\`
<decisions>
### Real
- **D-01:** Real decision text long enough to soft match
</decisions>`;
const decisions = parseDecisions(content);
const ids = decisions.map((d) => d.id);
expect(ids).toContain('D-01');
expect(ids).not.toContain('D-99');
});
it('captures continuation lines indented with TABS (review F12)', () => {
const content = '<decisions>\n### Cat\n- **D-07:** First line\n\tcontinued via tab\n</decisions>';
const decisions = parseDecisions(content);
const d07 = decisions.find((d) => d.id === 'D-07');
expect(d07?.text).toMatch(/continued via tab/);
});
it('parses ALL `<decisions>` blocks, not just the first (review F13)', () => {
const content = `<decisions>
### One
- **D-01:** First batch
</decisions>
Some prose.
<decisions>
### Two
- **D-02:** Second batch
</decisions>`;
const ids = parseDecisions(content).map((d) => d.id);
expect(ids).toContain('D-01');
expect(ids).toContain('D-02');
});
it('treats curly-quote variants of "Claude\u2019s Discretion" as non-trackable (review F20)', () => {
// U+201B (single high-reversed-9 quotation mark) — uncommon but legal unicode.
const content =
'<decisions>\n### Claude\u201Bs Discretion\n- **D-50:** Should be non-trackable\n</decisions>';
const decisions = parseDecisions(content);
const d50 = decisions.find((d) => d.id === 'D-50');
expect(d50?.trackable).toBe(false);
});
});
// ─── decisions.parse query handler ────────────────────────────────────────
import { decisionsParse } from './decisions.js';
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
describe('decisionsParse handler (review F14 — accepts relative path via projectDir)', () => {
let tmp: string;
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'gsd-decparse-'));
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
it('resolves a relative file path against projectDir', async () => {
await mkdir(join(tmp, '.planning', 'phases', '17'), { recursive: true });
await writeFile(
join(tmp, '.planning', 'phases', '17', '17-CONTEXT.md'),
'<decisions>\n### Cat\n- **D-01:** Hello\n</decisions>',
'utf-8',
);
const result = await decisionsParse(['.planning/phases/17/17-CONTEXT.md'], tmp);
expect((result.data as { trackable: number }).trackable).toBe(1);
expect((result.data as { missing: boolean }).missing).toBe(false);
});
it('still accepts an absolute path', async () => {
const abs = join(tmp, 'CONTEXT.md');
await writeFile(abs, '<decisions>\n### Cat\n- **D-02:** Bye\n</decisions>', 'utf-8');
const result = await decisionsParse([abs], tmp);
expect((result.data as { trackable: number }).trackable).toBe(1);
});
});

192
sdk/src/query/decisions.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* CONTEXT.md `<decisions>` parser — shared helper for issue #2492 (decision
* coverage gates) and #2493 (post-planning gap checker).
*
* Decision format (produced by `discuss-phase.md`):
*
* <decisions>
* ## Implementation Decisions
*
* ### Category Heading
* - **D-01:** Decision text
* - **D-02 [tag1, tag2]:** Tagged decision
*
* ### Claude's Discretion
* - free-form, never tracked
* </decisions>
*
* A decision is "trackable" when:
* - it has a valid D-NN id
* - it is NOT under the "Claude's Discretion" category
* - it is NOT tagged `informational` or `folded`
*
* Trackable decisions are the ones the plan-phase translation gate and the
* verify-phase validation gate enforce.
*/
import { readFile } from 'node:fs/promises';
import { isAbsolute, join } from 'node:path';
import type { QueryHandler } from './utils.js';
export interface ParsedDecision {
/** Stable id: `D-01`, `D-7`, `D-42`. */
id: string;
/** Body text (everything after `**D-NN[ tags]:**` up to next bullet/blank). */
text: string;
/** Most recent `### ` heading inside the decisions block. */
category: string;
/** Bracketed tags from `**D-NN [tag1, tag2]:**`. Lower-cased. */
tags: string[];
/**
* False when under "Claude's Discretion" or tagged `informational` /
* `folded`. Trackable decisions are subject to the coverage gates.
*/
trackable: boolean;
}
const DISCRETION_HEADINGS = new Set([
"claude's discretion",
'claudes discretion',
'claude discretion',
]);
const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
/**
* Strip fenced code blocks from `content` so example `<decisions>` snippets
* inside ```` ``` ```` do not pollute the parser (review F11).
*/
function stripFencedCode(content: string): string {
return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' ');
}
/**
* Extract the inner text of EVERY `<decisions>...</decisions>` block in
* order, concatenated by `\n\n`. Returns null when no block is present.
*
* CONTEXT.md may legitimately contain more than one block (for example, a
* "current decisions" block plus a "carry-over from prior phase" block);
* dropping all-but-the-first silently lost the second batch (review F13).
*/
function extractDecisionsBlock(content: string): string | null {
const cleaned = stripFencedCode(content);
const matches = [...cleaned.matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)];
if (matches.length === 0) return null;
return matches.map((m) => m[1]).join('\n\n');
}
/**
* Parse trackable decisions from CONTEXT.md content.
*
* Returns ALL D-NN decisions found inside `<decisions>` (including
* non-trackable ones, with `trackable: false`). Callers that only want the
* gate-enforced decisions should filter `.filter(d => d.trackable)`.
*/
export function parseDecisions(content: string): ParsedDecision[] {
if (!content || typeof content !== 'string') return [];
const block = extractDecisionsBlock(content);
if (block === null) return [];
const lines = block.split(/\r?\n/);
const out: ParsedDecision[] = [];
let category = '';
let inDiscretion = false;
// Bullet line: `- **D-NN[ [tags]]:** text`
const bulletRe = /^\s*-\s+\*\*D-(\d+)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/;
let current: ParsedDecision | null = null;
const flush = () => {
if (current) {
current.text = current.text.trim();
out.push(current);
current = null;
}
};
for (const line of lines) {
const trimmed = line.trim();
// Track category headings (`### Heading`)
const headingMatch = trimmed.match(/^###\s+(.+?)\s*$/);
if (headingMatch) {
flush();
category = headingMatch[1];
// Strip the full unicode-quote family so any rendering of "Claude's
// Discretion" (ASCII apostrophe, curly U+2019, U+2018, U+201A, U+201B,
// double-quote variants U+201C/D/E/F, etc.) collapses to the same key
// (review F20).
const normalized = category
.toLowerCase()
.replace(/[\u2018\u2019\u201A\u201B\u201C\u201D\u201E\u201F'"`]/g, '')
.trim();
inDiscretion = DISCRETION_HEADINGS.has(normalized);
continue;
}
const bulletMatch = line.match(bulletRe);
if (bulletMatch) {
flush();
const id = `D-${bulletMatch[1]}`;
const tags = bulletMatch[2]
? bulletMatch[2]
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
: [];
const trackable =
!inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
current = { id, text: bulletMatch[3], category, tags, trackable };
continue;
}
// Continuation line for current decision (indented with space OR tab,
// non-bullet, non-empty) — tab indentation must work too (review F12).
if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) {
current.text += ' ' + trimmed;
continue;
}
// Blank line or unrelated content terminates the current decision
if (trimmed === '') {
flush();
}
}
flush();
return out;
}
// ─── Query handler ────────────────────────────────────────────────────────
/**
* `decisions.parse <path>` — parse CONTEXT.md and return decisions array.
*
* Used by workflow shell snippets that need to enumerate decisions without
* spawning a full Node process. Accepts either an absolute path or a path
* relative to `projectDir` — symmetric with the gate handlers (review F14).
*/
export const decisionsParse: QueryHandler = async (args, projectDir) => {
const filePath = args[0];
if (!filePath) {
return { data: { decisions: [], trackable: 0, total: 0, missing: true } };
}
const resolved = isAbsolute(filePath) ? filePath : join(projectDir, filePath);
let raw = '';
try {
raw = await readFile(resolved, 'utf-8');
} catch {
return { data: { decisions: [], trackable: 0, total: 0, missing: true } };
}
const decisions = parseDecisions(raw);
const trackable = decisions.filter((d) => d.trackable);
return {
data: {
decisions,
trackable: trackable.length,
total: decisions.length,
missing: false,
},
};
};

View File

@@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { GSDError } from '../errors.js';
@@ -21,6 +21,7 @@ import {
resolveAgentsDir,
getRuntimeConfigDir,
detectRuntime,
findProjectRoot,
SUPPORTED_RUNTIMES,
type Runtime,
} from './helpers.js';
@@ -424,3 +425,117 @@ describe('resolveAgentsDir (runtime-aware)', () => {
expect(resolveAgentsDir('codex')).toBe(join('/codex', 'agents'));
});
});
// ─── findProjectRoot (issue #2623) ─────────────────────────────────────────
describe('findProjectRoot (multi-repo .planning resolution)', () => {
let workspace: string;
beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), 'gsd-find-root-'));
});
afterEach(async () => {
await rm(workspace, { recursive: true, force: true });
});
it('returns startDir unchanged when startDir has its own .planning/', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
expect(findProjectRoot(workspace)).toBe(workspace);
});
it('returns startDir unchanged when no ancestor has .planning/', () => {
expect(findProjectRoot(workspace)).toBe(workspace);
});
it('walks up to parent .planning/ when config lists the child in sub_repos (#2623)', async () => {
// workspace/.planning/{config.json, PROJECT.md}
// workspace/app/.git/
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['app'] }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('resolves parent root from deeply nested dir inside a sub_repo', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['app'] }),
'utf-8',
);
const nested = join(workspace, 'app', 'src', 'modules');
await mkdir(join(workspace, 'app', '.git'), { recursive: true });
await mkdir(nested, { recursive: true });
expect(findProjectRoot(nested)).toBe(workspace);
});
it('supports planning.sub_repos nested config shape', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ planning: { sub_repos: ['app'] } }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('falls back to .git heuristic when parent has .planning/ but no matching sub_repos', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
// Config doesn't list the child, but child has .git and parent has .planning/.
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ sub_repos: [] }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('swallows unparseable config.json and falls back to .git heuristic', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(join(workspace, '.planning', 'config.json'), '{ not json', 'utf-8');
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('supports legacy multiRepo: true when child is inside a git repo', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ multiRepo: true }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('does not walk up when child has its own .planning/ (#1362 guard)', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['app'] }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.planning'), { recursive: true });
expect(findProjectRoot(app)).toBe(app);
});
});

View File

@@ -17,8 +17,9 @@
* ```
*/
import { join, dirname, relative, resolve, isAbsolute, normalize } from 'node:path';
import { join, dirname, relative, resolve, isAbsolute, normalize, parse as parsePath, sep as pathSep } from 'node:path';
import { realpath } from 'node:fs/promises';
import { existsSync, statSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.js';
import { relPlanningPath } from '../workstream-utils.js';
@@ -428,6 +429,134 @@ export function planningPaths(projectDir: string, workstream?: string): Planning
};
}
// ─── findProjectRoot (multi-repo .planning resolution) ─────────────────────
/**
* Maximum number of parent directories to walk when searching for a
* multi-repo `.planning/` root. Bounded to avoid scanning to the filesystem
* root in pathological cases.
*/
const FIND_PROJECT_ROOT_MAX_DEPTH = 10;
/**
* Walk up from `startDir` to find the project root that owns `.planning/`.
*
* Ported from `get-shit-done/bin/lib/core.cjs:findProjectRoot` so that
* `gsd-sdk query` resolves the same parent `.planning/` root as the legacy
* `gsd-tools.cjs` CLI when invoked inside a `sub_repos`-listed child repo.
*
* Detection strategy (checked in order for each ancestor, up to
* `FIND_PROJECT_ROOT_MAX_DEPTH` levels):
* 1. `startDir` itself has `.planning/` — return it unchanged (#1362).
* 2. Parent has `.planning/config.json` with `sub_repos` listing the
* immediate child segment of the starting directory.
* 3. Parent has `.planning/config.json` with `multiRepo: true` (legacy).
* 4. Parent has `.planning/` AND an ancestor of `startDir` (up to the
* candidate parent) contains `.git` — heuristic fallback.
*
* Returns `startDir` unchanged when no ancestor `.planning/` is found
* (first-run or single-repo projects). Never walks above the user's home
* directory.
*
* All filesystem errors are swallowed — a missing or unparseable
* `config.json` falls back to the `.git` heuristic, and unreadable
* directories terminate the walk at that level.
*/
export function findProjectRoot(startDir: string): string {
let resolvedStart: string;
try {
resolvedStart = resolve(startDir);
} catch {
return startDir;
}
const fsRoot = parsePath(resolvedStart).root;
const home = homedir();
// If startDir already contains .planning/, it IS the project root.
try {
const ownPlanning = join(resolvedStart, '.planning');
if (existsSync(ownPlanning) && statSync(ownPlanning).isDirectory()) {
return startDir;
}
} catch {
// fall through
}
// Walk upward, mirroring isInsideGitRepo from the CJS reference.
function isInsideGitRepo(candidateParent: string): boolean {
let d = resolvedStart;
while (d !== fsRoot) {
try {
if (existsSync(join(d, '.git'))) return true;
} catch {
// ignore
}
if (d === candidateParent) break;
const next = dirname(d);
if (next === d) break;
d = next;
}
return false;
}
let dir = resolvedStart;
let depth = 0;
while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) {
const parent = dirname(dir);
if (parent === dir) break;
if (parent === home) break;
const parentPlanning = join(parent, '.planning');
let parentPlanningIsDir = false;
try {
parentPlanningIsDir = existsSync(parentPlanning) && statSync(parentPlanning).isDirectory();
} catch {
parentPlanningIsDir = false;
}
if (parentPlanningIsDir) {
const configPath = join(parentPlanning, 'config.json');
let matched = false;
try {
const raw = readFileSync(configPath, 'utf-8');
const config = JSON.parse(raw) as {
sub_repos?: unknown;
planning?: { sub_repos?: unknown };
multiRepo?: unknown;
};
const subReposValue =
(config.sub_repos as unknown) ?? (config.planning && config.planning.sub_repos);
const subRepos = Array.isArray(subReposValue) ? (subReposValue as unknown[]) : [];
if (subRepos.length > 0) {
const relPath = relative(parent, resolvedStart);
const topSegment = relPath.split(pathSep)[0];
if (subRepos.includes(topSegment)) {
return parent;
}
}
if (config.multiRepo === true && isInsideGitRepo(parent)) {
matched = true;
}
} catch {
// config.json missing or unparseable — fall through to .git heuristic.
}
if (matched) return parent;
// Heuristic: parent has .planning/ and we're inside a git repo.
if (isInsideGitRepo(parent)) {
return parent;
}
}
dir = parent;
depth += 1;
}
return startDir;
}
// ─── resolvePathUnderProject ───────────────────────────────────────────────
/**

Some files were not shown because too many files have changed in this diff Show More