`init.new-milestone` reported `phase_dir_count: 0` for projects whose
phase directories carry a project_code prefix (`.planning/phases/CK-01-name`)
when the ROADMAP used numeric `### Phase N:` headings. Verified via a
temp-project repro that mirrors the reporter's setup.
Root cause: `getMilestonePhaseFilter` builds an `isDirInMilestone(dirName)`
predicate that tries two paths:
1) Numeric — requires the dir name to START with a digit. `CK-01-name`
starts with `C`, so this skips.
2) Custom-ID — captures the leading kebab token (`CK-01-name` as a
whole) and compares it to the normalised milestone phase IDs
(`{"1"}`). No match.
There was no path that stripped the project_code prefix before retrying
the numeric match. Added a third path that strips the same shape
`normalizePhaseName` already recognises (`^[A-Z]{1,6}-(?=\d)`) and retries
the numeric match. This runs AFTER the custom-ID path so a ROADMAP that
uses `### Phase PROJ-42:` continues to win via the custom-ID match for
a `PROJ-42` directory; the new branch only fires when the milestone is
keyed on the bare numeric form.
The fix lands in both:
- get-shit-done/bin/lib/core.cjs:isDirInMilestone (active CJS runtime)
- sdk/src/query/state.ts:isDirInMilestone (SDK twin)
`getMilestonePhaseFilter` is shared by multiple callers — init.new-milestone,
phase complete, verify-work, validate-health — so the fix benefits every
caller that walks `.planning/phases/` against a numeric ROADMAP.
Regression test
(tests/bug-3600-milestone-phase-filter-project-code-prefix.test.cjs):
1. Reporter's case: CK-01-name + CK-02-build dirs against Phase 1 / 2
headings → phase_dir_count === 2.
2. Existing contract: 01-first dir against Phase 1 heading still counts.
3. Custom-ID contract: PROJ-42 dir against `### Phase PROJ-42:` still
counts via the existing custom-ID match (no regression).
4. Counter-test: CK-99-backlog and CK-100-future dirs MUST NOT count
against a milestone with only Phase 1 — the strip-and-retry must
still respect the milestone's actual phase set.
All assertions go through `init new-milestone --json` (typed payload —
`phase_dir_count`). No raw text matching.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
phase.cjs:updateRoadmapAfterPhaseRemoval renumbers plan references in
ROADMAP.md via a regex that captured `NN-NN` followed by an optional
suffix:
/(?<![0-9-])(\d{2})-(\d{2})(?=(?:-(?:PLAN|SUMMARY)\.md)?(?![0-9-]))/g
The suffix branch was strict: it only accepted `-(PLAN|SUMMARY).md`
directly after the plan number. A slug like
`07-01-cherry-pick-foundation-PLAN.md` placed `-cherry-…` between the
number and the canonical suffix, so both the suffix branch AND the
"bare token" branch (`(?![0-9-])` — fails because the next char is `-`)
failed. Result: the on-disk file got renamed to
`06-01-cherry-pick-foundation-PLAN.md` by the directory-rename pass,
but the ROADMAP entry kept pointing at the stale `07-01-…` prefix —
disk/ROADMAP inconsistency.
Fix: extend the suffix branch to allow an optional kebab-case slug
between the plan number and the PLAN/SUMMARY suffix:
(?:(?:-[A-Za-z][A-Za-z0-9-]*)*-(?:PLAN|SUMMARY)\.md)|(?![0-9-])
Each slug token must start with a letter so `07-01-02-PLAN.md` is not
silently consumed as one slugged token (the `-02` is correctly
unreachable from the slug branch because it starts with a digit).
Regression test exercises three cases via the typed `roadmap get-phase
--json` query (no raw text matching on ROADMAP.md content):
1. Slugged PLAN + SUMMARY filenames get renumbered (#3602 fix).
2. Compact `NN-NN-PLAN.md` filenames still renumber correctly
(#3601 / earlier contracts preserved).
3. Counter-test: ISO dates (`2026-01-01`) and version tags (`v1-2-3`)
in ROADMAP prose are NOT modified — the existing `(?<![0-9-])` /
`(?![0-9-])` boundaries hold against false positives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`phase remove N` for an integer phase silently deleted the adjacent
`### Phase N.1:` decimal section when the decimal was a peer-depth
heading. The bug was in the section-removal regex inside
get-shit-done/bin/lib/phase.cjs:updateRoadmapAfterPhaseRemoval:
(?=\n#{2,4}\s+Phase\s+\d+\s*:|$)
The lookahead required the next header's digits to be followed by
`\s*:` — true for `### Phase 3:` but false for `### Phase 2.1:` because
the `.1` breaks the match. The non-greedy `[\s\S]*?` body then consumed
`Phase 2.1` along with `Phase 2` until it found the next integer
header. The on-disk phase directory `.planning/phases/02.1-*` survived
but its ROADMAP entry was gone — disk/ROADMAP inconsistency.
The fix uses a depth-aware lookahead: capture the hash count of the
header being removed with a named group `(?<h>#{2,4})` and require the
end-of-section lookahead to match the SAME depth via `\k<h>(?!#)`. The
`(?!#)` guards against `###` accidentally matching a deeper `####`
header by anchoring on the captured hash count.
This preserves two contracts simultaneously:
- #3601: removing `### Phase 2:` (depth 3) stops at the next depth-3
header, including `### Phase 2.1:` — the peer-level decimal is
preserved.
- #3355: removing `### Phase 27:` (depth 3) continues past
`#### Phase 27.1:` (depth 4, a child of the integer phase) until it
reaches the next depth-3 header. The child decimal is part of the
integer phase being removed.
The regression test exercises the public CLI via runGsdTools and
asserts on typed JSON output from `roadmap get-phase --json` — no raw
text matching on ROADMAP.md content (per CONTRIBUTING.md
"Prohibited: Raw Text Matching on Test Outputs").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`npx get-shit-done-cc@latest --codex` aborted with
"installer migration blocked pending user choice" listing 12 hooks/gsd-*
files. Those files are part of the GSD npm distribution
(hooks/gsd-prompt-guard.js, hooks/gsd-context-monitor.js, etc.), not
user-owned content, so asking the user to choose between keep/remove for
them was a UX bug, not a real choice. The installer is about to write
the fresh bundled versions in their place.
Root cause: `classifyPromptUserAction` in
get-shit-done/bin/lib/installer-migration-report.cjs knew two
unambiguous categories (`stale-sdk-build-artifact`, `user-facing-skill`)
but had no rule for the bundled GSD hooks. The first-time-baseline scan
classified them as `stale-gsd-looking` prompt-user blockers, and
`assertInstallerMigrationsUnblocked` threw.
A second gate compounded the bug: the safe-default resolver in
bin/install.js was wrapped in `if (!_migrationIsTty)`, so even with a
correct classification rule, TTY runs (every `npx get-shit-done-cc`
invocation) skipped the resolver and went straight to the hard throw.
Fix:
1) Add `hooks/gsd-<name>.(js|sh|cjs|mjs)` to `classifyPromptUserAction`
as `bundled-gsd-hook` → `remove`. The regex is anchored at the
top-level `hooks/` directory so nested paths like
`hooks/gsd-helpers/index.js` (or any user-owned helper directory) do
NOT auto-classify.
2) Remove the `!_migrationIsTty` gate from the resolver call in
bin/install.js. The classifier-based path is unambiguous and must
apply regardless of TTY; the env-override branch
(GSD_INSTALLER_MIGRATION_RESOLVE) still applies only when isTty=false
inside the resolver, preserving the #3541 semantic.
Regression test added
(tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs):
- Positive: hooks/gsd-*.{js,sh} → category=bundled-gsd-hook, choice=remove.
- Counter-test: hooks/my-custom-hook.js → classifier returns null
(user files are preserved).
- Boundary: hooks/gsd-helpers/index.js → classifier returns null
(nested directories don't auto-classify).
- End-to-end: 12 reporter-exact bundled hooks + empty manifest →
resolver clears every blocker, assertInstallerMigrationsUnblocked
does not throw.
Test exercises the real installer-migration code path
(`runInstallerMigrations` + `resolveInstallerMigrationPromptsForNonTty`
+ `assertInstallerMigrationsUnblocked`) — no source-grep, no raw text
matching on outputs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`roadmap get-phase PROJ-42` returned `{found: false}` because
phaseMarkdownRegexSource() unconditionally strips the project-code prefix
(`^[A-Z]{1,6}-(?=\d)`) before matching, building the regex `0*42` which
matches `### Phase 42:` but never `### Phase PROJ-42:`. The function's
own docstring promised a fallback to escapeRegex(phaseNum) for custom
IDs, but the line-680 regex match consumes the stripped-numeric form
before that branch is reachable.
Fix: add phaseMarkdownRegexSourceExact() that returns the exact-escaped
source for project-code-prefixed inputs (or null for un-prefixed). Update
cmdRoadmapGetPhase to do a two-pass search — try the exact-prefixed form
first, only fall back to the existing padding-tolerant numeric form if
the exact heading is not present.
Two-pass at the call site (rather than alternation inside the regex
source) is required: a roadmap containing both `### Phase 42:` and
`### Phase PROJ-42:` cannot be disambiguated by a single alternation
because regex match-position is leftmost-wins, so the bare numeric
heading at line N would always intercept the match intended for the
prefixed sibling at line M.
The #3537 contract is preserved: `roadmap get-phase CK-01` against a
roadmap that uses `### Phase 1:` prose still resolves correctly via the
numeric fallback, because the exact-prefixed pass returns null and the
existing padded-numeric pass runs unchanged.
Tests added (tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs):
1. PROJ-42 query against `### Phase PROJ-42:` heading — found
2. Counter-test: bare `42` query against `### Phase PROJ-42:` — NOT found
3. #3537 contract preserved: CK-01 query → `### Phase 1:` heading
4. Disambiguation: both `### Phase 42:` and `### Phase PROJ-42:` in
one roadmap; each query resolves to its specific match
All assertions go through runGsdTools + JSON parse — typed payload,
no raw text matching on stdout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single 747-line /gsd-help reference with a progressive-disclosure
dispatcher (#2551 pattern). Newcomers get a one-page tour; returning users get
a 10-line refresher with --brief; the complete reference stays available behind
--full; /gsd-help <topic> emits one section; and /gsd-help --brief <topic>
is a compact scoped lookup (signature + one-line summary).
- workflows/help.md becomes a small dispatcher routing on $ARGUMENTS
- workflows/help/modes/{brief,default,full,topic}.md hold the tier bodies
- commands/gsd/help.md passes $ARGUMENTS through, advertises composable form
- docs/COMMANDS.md documents the new flags and topic form
- existing tests that read help.md repointed at help/modes/full.md
(bug-2836, bug-2950, bug-2954, cursor-reviewer, execute-phase-wave)
- new feat-3039-help-tiered test enforces structure, size budgets,
dispatcher routing, shim arg passthrough, topic→section coverage,
orphan-heading detection, conflict-resolution rules, routing preamble,
and compact-scope rule
Trek-e review fixes (PR #3040):
- topic.md output rules split into 5a/5b/5c — explicit handling for single
sections, multi-section "plus" joins, and bold-line sub-block anchors;
each rule also takes scope (full vs compact) into account
- explicit resolved-routing preamble line emitted by topic.md before content
("**Topic:** `<alias>` → `<heading>` *(scope: full | compact)*") so the
user sees which alias matched at which scope (review finding #3)
- composable `--brief <topic>` invokes topic.md in compact scope: heading
+ first `**/gsd:*`** signature line + one-line summary. Dispatcher and
topic.md cooperate via $ARGUMENTS pass-through (review finding #4)
- full.md capped at LARGE-tier budget (FULL_BUDGET = 1500); the non-recursive
workflow-size-budget test does not reach modes/ subdirs
- structural <progressive_disclosure> table parse (5-row assertion) replaces
substring-soup regex matching — 5 rows = 4 base tiers + composable scope
- forward /gsd:* sub-block token coverage + reverse orphan-heading allowlist
catch alias-table drift in both directions
- four conflict-resolution tests guard dispatcher promises (--brief+--full
without topic → --full; --brief <topic> → compact; --full <topic> or bare
→ full; dispatcher retains --brief when delegating to topic.md)
- hardcoded topic lists removed from docs/COMMANDS.md and full.md (drift
surfaces reduced from 5 to 2)
- topic.md alias bloat trimmed (~75 → ~25 rows); cleanup/update split into
distinct sub-block rows under ### Utility Commands
- comment-rot ("~750 lines") removed from default.md and full.md
- dispatcher size guard tightened from < 100 to <= 40 lines
- commands/gsd/help.md <process> block trimmed to one line
- MD040 fence languages added to all plain code blocks across mode files
Main-merge conflict resolution:
- workflows/help.md kept as dispatcher (body lives in help/modes/full.md)
- /gsd-<cmd> → /gsd:<cmd> rename from #3452 reapplied to the mode files
(full.md, default.md, brief.md, topic.md) — the six namespace routers
(/gsd-context, /gsd-ideate, /gsd-manage, /gsd-project, /gsd-quality,
/gsd-workflow) and wildcards (/gsd-*) preserved in hyphen form per
main's convention
- bug-2950 test combines branch's path repointing with main's namespaced
replacement strings
bin/install.js and the SDK already treat Antigravity as a distinct runtime
with config dir ~/.gemini/antigravity, env var ANTIGRAVITY_CONFIG_DIR, and
CLI flag --antigravity. get-shit-done/workflows/update.md did not — so
/gsd-update invoked from an Antigravity install classified the runtime as
base Gemini, because:
- RUNTIME_DIRS listed "gemini:.gemini" with no antigravity entry, so the
scan matched ~/.gemini before ever looking for ~/.gemini/antigravity.
- The PREFERRED_RUNTIME env-var ladder checked GEMINI_CONFIG_DIR but not
ANTIGRAVITY_CONFIG_DIR.
- The local-scope scan loops at lines 101 and 590 listed .gemini with no
.gemini/antigravity sibling.
- The ENV_RUNTIME_DIRS append block ignored ANTIGRAVITY_CONFIG_DIR.
- The path-to-runtime classification bullets only mapped /.gemini/ ->
gemini, with no /.gemini/antigravity/ -> antigravity branch.
Every list is now updated so the more-specific antigravity entry precedes
the base gemini entry, matching the installer at bin/install.js (lines
396-404, 1745-1749, 6175, 6475).
tests/bug-3608-antigravity-update-runtime-classification.test.cjs is the
structural regression guard. It parses the RUNTIME_DIRS bash array out of
update.md and asserts the antigravity entry is present and ordered before
gemini, asserts the env-var ladder checks ANTIGRAVITY_CONFIG_DIR before
GEMINI_CONFIG_DIR, asserts every `for dir in ...` scan loop that mentions
.gemini also lists .gemini/antigravity ordered before it, and asserts the
path-classification prose bullet lists antigravity before gemini.
Per CONTRIBUTING.md: the test uses readFileSync on a .md file annotated
`// allow-test-rule: source-text-is-the-product` because the bash blocks
inside update.md ARE the deployed program — the agent loads update.md and
runs them as-written.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new `phase_status` field to the `init.plan-phase` SDK + CJS query
output and a §1.5 "Closed-Phase Gate" in workflows/plan-phase.md that
short-circuits on closed phases instead of silently replanning over
shipped code.
## What was broken
`gsd-sdk query init.plan-phase <N>` returned the same "ready to plan"
payload for a closed phase (REQUIREMENTS Met, VERIFICATION.md status:
passed, ROADMAP flipped) as for an open one. No field signaled closure,
so `/gsd:plan-phase --reviews` happily replanned over closed phases —
risking documentation drift on already-shipped code.
## Fix
- Export `determinePhaseStatus` from `commands.cjs` (already present, was
module-private).
- Both `cmdInitPlanPhase` (CJS) and `initPlanPhase` (TS SDK) now compute
`phase_status` from plan/summary counts + VERIFICATION.md status using
the existing `determinePhaseStatus` helper — the project-wide phase
lifecycle vocabulary (Pending | Planned | In Progress | Executed |
Complete | Needs Review). No directory yet → Pending.
- Workflow `plan-phase.md` adds §1.5 "Closed-Phase Gate":
- `phase_status == "Complete"` with `--reviews` → hard-stop, no
override (replanning a closed phase via review feedback is never
legitimate; concerns belong in a follow-up phase or new issue).
- `phase_status == "Complete"` without `--force` → exit with a clear
notice pointing at VERIFICATION.md.
- `phase_status == "Complete"` with `--force` → continue with a
transcript banner so the deliberate replan is visible.
`Executed` and `Needs Review` are intentionally not gated — those mean
planning finished but verification did not pass, and replanning is the
correct next step.
## Tests
- SDK: 4 new `phase_status` cases in init.test.ts covering Pending /
Planned / Executed / Complete transitions.
- Existing init.plan-phase golden parity test continues to pass (the
`researcher_model: '' vs sonnet` drift in that test predates this
change and is unrelated).
- Full Mac+Docker suite: 9323 / 9323 passed (Mac), 9318 / 9323 passed
(Docker, 5 skipped).
Fixes#3569
Phase 5.1 of the CJS↔SDK hard-seam migration (parent #3524). Migrates
the bin/lib/state-command-router.cjs handlers map to delegate every
canonical state subcommand through the executeForCjs synchronous
primitive (shipped in Phase 5.0, PR #3558).
## Bundled fix for Phase 5.0 worker defect
Discovered during Phase 5.1 implementation that the Phase 5.0
worker drops projectDir and workstream from
RuntimeBridgeExecuteInput. The dispatch closure at
sdk/src/runtime-bridge-sync/worker.ts:41-42 hardcoded projectDir
to '', so registry handlers that read .planning/ from projectDir
(every state.* handler) saw an empty path and failed. Phase 5.0's
pinning tests passed because they exercised commands that don't
depend on projectDir (generate-slug takes its arg directly;
unknown_command doesn't dispatch). Maintainer authorized bundling
the fix into this PR.
Fix: moved QueryNativeDirectAdapter construction inside the
dispatchNative lambda so request.projectDir and request.workstream
close over the per-request values. Per-request adapter construction
adds <1ms overhead; correctness wins. Regression test at
sdk/src/runtime-bridge-sync/projectdir-regression.test.ts demonstrates
RED before fix → GREEN after.
Phase 5.0's index.test.ts native_failure fixture was passing
because of the bug — it relied on projectDir = '' producing a
specific error path. Updated to use a /nonexistent-... path that
triggers ENOENT under realpath, producing native_failure as intended.
## What landed for Phase 5.1
- bin/lib/state-command-router.cjs migrated. Every subcommand
entry in the handlers map dispatches via executeForCjs when SDK
is available, with transparent fallback to the existing CJS
handlers in state.cjs if (a) SDK is not built / not present, or
(b) GSD_WORKSTREAM is set (the sync-bridge worker cannot serve
workstream-scoped commands per the SDK transport architecture).
- Special cases preserved:
- load --raw: SDK data formatted into key=value lines matching
cmdStateLoad's exact format.
- complete-phase: CJS-only (no SDK counterpart yet).
- add-roadmap-evolution: stays on the unsupported list (SDK-only).
- Golden parity tests added for 12 previously-uncovered state
subcommands: advance-plan, record-metric, update-progress,
add-decision, add-blocker, resolve-blocker, record-session,
signal-waiting, signal-resume, planned-phase, milestone-switch,
prune.
## Design decisions worth reviewer visibility
1. Lazy SDK loading with CJS fallback. The migration routes via
executeForCjs only when the SDK is loadable; otherwise falls
back to the existing CJS handlers. Conservative for rollback —
if the SDK build is broken on a deploy, state commands keep
working via the CJS path. Trade-off: drift surface is not
structurally eliminated yet — the CJS handlers remain reachable.
2. Workstream → CJS fallback. The SDK transport forces subprocess
for workstream commands, but subprocess is disabled in the sync
bridge. When GSD_WORKSTREAM is set, the entire state command
falls back to CJS rather than failing. Workstream users continue
running the CJS handlers; the SDK path is exercised only in the
default (no workstream) case.
3. Two documented parity divergences. state.record-metric: CJS
auto-creates ## Performance Metrics section when absent; SDK
returns {recorded: false, reason}. Test requires fixture with
the section present. state.prune: CJS counts phases from disk;
SDK reads from frontmatter fields. Test asserts structural shape
rather than exact equality.
## Numbers
- Full CJS suite: 9323/9323 pass (baseline 9323; +0 net because
the 12 new parity tests are SDK-side vitest, not CJS-side).
- SDK vitest sync-bridge: 10/10 pass.
- Regression test: 3/3 pass (proved RED before fix, GREEN after).
- tests/state.test.cjs (the safety net): 104/104 pass unchanged.
## Performance
gsd-tools state load via the SDK path: 49ms first call (Worker
startup), 43-44ms steady-state median. Slower than the
Phase 5.0-measured 0.1ms because state.load does fs reads on top
of the bridge overhead. Still well within the budget for CJS
dispatcher overhead.
Closes#3567.
agents/gsd-planner.md was 49,316 chars after the initial PR; the
planner-decomposition <48K test was passing on main at 49,150 chars (just under
the 49152 limit). My addition pushed it over.
Restructure: instead of teaching the planner agent to read .last-build-status.json
directly, fold the auto-build state into graphifyStatus()'s existing `stale: true`
signal. The planner's existing rule ("if stale: true, treat as approximate") fires
correctly for failed and in-flight auto-builds — no new planner-side prompt content
needed. The full state is exposed under `last_build_auto_update` for callers that
want exit_code / duration_ms / commit-sha context.
- get-shit-done/bin/lib/graphify.cjs: graphifyStatus() reads
.planning/graphs/.last-build-status.json; OR-folds status in {failed, running}
into the existing stale signal; exposes last_build_auto_update field
- agents/gsd-planner.md: revert the auto-update awareness paragraph (49,524 → 49,150)
- agents/gsd-phase-researcher.md: revert the parallel paragraph for consistency
- get-shit-done/references/planner-graphify-auto-update.md: rewrite to document
the graphifyStatus seam instead of planner-side prompt instructions
- tests/feat-3347-graphify-auto-update-config.test.cjs: 4 new graphifyStatus
tests pinning the failed/running/ok/missing matrix
- tests/feat-3347-graphify-auto-update-hook.test.cjs: bump per-spawn timeout
5s → 30s and wait-deadline 5s → 15s to absorb cold-start latency under
parallel-test-file load (full suite runs many *.test.cjs concurrently)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs/CONFIGURATION.md: document graphify.auto_update key (config-schema-docs-parity)
- hooks/gsd-check-update-worker.js: add gsd-graphify-update.sh to MANAGED_HOOKS (managed-hooks)
- agents/gsd-planner.md + gsd-phase-researcher.md: slim auto-update awareness block to
a one-line @-reference; extract full instructions to a new reference file
- get-shit-done/references/planner-graphify-auto-update.md: new reference with the
status-file schema, the four annotation cases (running/failed/ok-current/ok-stale),
and interaction with the existing stale-mtime annotation
- docs/INVENTORY.md: References (60 → 61 shipped) + row for new reference; regenerate
docs/INVENTORY-MANIFEST.json via gen-inventory-manifest.cjs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of the CJS↔SDK hard-seam migration (parent #3524).
Eliminates the `findProjectRoot` duplication that lived at
bin/lib/core.cjs:74-140 and sdk/src/query/helpers.ts:497-590,
the drift carrier behind historical bugs #1362 and #2561.
- sdk/src/project-root/index.ts — source of truth (120 lines,
pure-with-sync-fs). Exports findProjectRoot(startDir: string)
and FIND_PROJECT_ROOT_MAX_DEPTH constant.
- sdk/src/project-root/index.test.ts — 13 vitest pinning fixtures
covering all four heuristics, the #1362 guard, malformed
config fallback, empty sub_repos, deep nesting, and depth-limit
enforcement.
- sdk/scripts/gen-project-root.mjs — generator. Captures
function body via Function.prototype.toString() from compiled
sdk/dist/. Emits CJS preamble for destructured node:fs /
node:path / node:os imports.
- sdk/scripts/check-project-root-fresh.mjs — freshness check.
Imports the generator function directly (Phase 3's cleaner
pattern).
- get-shit-done/bin/lib/project-root.generated.cjs — generator-
emitted CJS mirror.
- tests/project-root-generator.test.cjs — 11 parity assertions
comparing SDK source and generated CJS for every fixture.
- sdk/src/query/helpers.ts: -127 lines. The 94-line inline
findProjectRoot plus the FIND_PROJECT_ROOT_MAX_DEPTH constant
(originally at line 471) replaced by a single re-export:
`export { findProjectRoot } from '../project-root/index.js';`
Removed unused `parse as parsePath` import.
- get-shit-done/bin/lib/core.cjs: -83 lines net. The 67-line
inline findProjectRoot replaced by a single
`require('./project-root.generated.cjs')`. The detectSubRepos
helper at lines 40-56 stays (used by loadConfig migration).
- sdk/package.json: gen:project-root + check:project-root-fresh
scripts.
- package.json: proxy for the freshness check.
- .githooks/pre-commit: drift block.
- .github/workflows/test.yml: drift check step after the
state-document drift step.
- CONTEXT.md: Project-Root Resolution Module entry.
- docs/INVENTORY.md, docs/INVENTORY-MANIFEST.json:
+1 module count, +1 row.
- Full suite: 9226/9226 pass (baseline 9215 + 11 new parity
fixtures).
- SDK vitest: 1804/1804 pass.
- Reader shrink: -127 SDK + -83 CJS = 210 lines of duplication
deleted across the two Readers. New shared Module is 120 lines.
1. Depth limit canonicalization. CJS findProjectRoot previously
had no explicit walk-up bound (walked until dir === root or
homedir). The new Module uses FIND_PROJECT_ROOT_MAX_DEPTH = 10,
matching the SDK's pre-existing value. Only affects paths
nested more than 10 levels deep from a .planning/ root — a
pathological case in practice. None of the existing 22 CJS
findProjectRoot tests covered this; the new parity test does.
2. platformReadSync → readFileSync. The old CJS findProjectRoot
used the platformReadSync wrapper from
shell-command-projection.cjs for reading .planning/config.json,
which returns null on read failure. The Module uses raw
readFileSync, which throws — caught by the surrounding
try/catch that already swallowed errors. Functionally
equivalent for the existing code path; no test exercises the
null-return semantic.
Closes#3553.
Phase 3 of the CJS↔SDK hard-seam migration (parent #3524).
Introduces the Builder/Reader pattern for paired Modules with
mixed pure-and-I/O concerns — the template for Phase 4 and
follow-up enhancements that migrate other paired Modules.
Phase 1 and Phase 2 migrated Modules where both sides used
character-equivalent logic. Phase 3 introduces the case where
the pure logic is shareable but the I/O is legitimately per-side.
The Builder/Reader split resolves this:
- The Builder is pure — accepts pre-collected data
(BuilderInputs struct), returns the typed projection. One
source of truth; one generator-emitted CJS mirror. Drift
is structurally impossible.
- The Readers are per-side hand-authored Adapters that do the
fs reads in their native idiom (currently both sync; either
side can go async later without touching the Builder), then
delegate to the Builder.
- sdk/src/workstream-inventory/builder.ts — Builder source.
170 lines. Pure. Exports buildWorkstreamInventory(inputs),
isCompletedInventory(status), plus the three typed inventory
interfaces (WorkstreamPhaseInventory, WorkstreamInventory,
WorkstreamInventoryList).
- sdk/src/workstream-inventory/builder.test.ts — 18 vitest
pinning fixtures across all status branches, progress-percent
clamping, active-marker projection, and isCompletedInventory
classifier.
- sdk/scripts/gen-workstream-inventory-builder.mjs — generator.
Captures function bodies via Function.prototype.toString();
emits with the standard GENERATED FILE banner. Includes a
small `const relative = path.relative;` preamble in the
output to handle ESM destructured imports in the compiled
source.
- sdk/scripts/check-workstream-inventory-builder-fresh.mjs —
freshness check. Imports the generator function directly
(rather than duplicating logic) — a cleaner pattern than
Phase 1/2's approach.
- get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs —
generator-emitted CJS mirror.
- tests/workstream-inventory-builder-generator.test.cjs — 16
parity assertions confirming CJS-generated output ==
SDK source output for every fixture.
- bin/lib/workstream-inventory.cjs: 159 → 132 lines.
Projection logic gone. `inspectWorkstream` and
`listWorkstreamInventories` collect BuilderInputs via the
existing sync fs functions and delegate to the Builder.
`isCompletedInventory` re-exported from the Builder (its
signature changed from object→string, but no external
callers exist so the change is safe).
- sdk/src/query/workstream-inventory.ts: 196 → 143 lines.
Same shape, sync fs (the SDK was already sync — surprise from
recon). Types re-exported from the Builder.
- sdk/package.json: gen:workstream-inventory-builder and
check:workstream-inventory-builder-fresh scripts.
- package.json: proxy for the freshness check.
- .githooks/pre-commit: drift block.
- .github/workflows/test.yml: drift check step.
- CONTEXT.md: amended "Workstream Inventory Module" entry
to document the Builder/Reader split.
- docs/INVENTORY.md, docs/INVENTORY-MANIFEST.json:
+1 module count, +1 row for the generated builder.
- Full suite: 9229/9229 pass (baseline 9215 + 14 net new from
the parity assertions).
- Vitest: 18 Builder fixtures pass.
- Reader shrink: -27 lines on CJS, -53 lines on SDK.
- Net diff (modified files only): +68 / -133 = 65-line
reduction. New files (Builder, generator, freshness check,
parity test) add ~600 lines of new structured code.
1. `isCompletedInventory` signature changed from
isCompletedInventory(inventory: object) to
isCompletedInventory(status: string). Original CJS exported
the object form but no external caller passed an object —
they all passed inventory.status. Verified by grep before
committing.
2. Generator preamble. The compiled ESM uses
`import { relative } from 'node:path'`, making `relative`
a free variable in `buildWorkstreamInventory`. The generator
emits `const relative = path.relative;` so the captured
function body works in CJS.
3. Freshness check imports the generator. The freshness check
imports the generator's buildWorkstreamInventoryBuilderCjs()
function directly rather than duplicating generation logic.
Cleaner than Phase 1/2; future generators should follow this.
Shareable via the Builder/Reader pattern in future enhancements:
- frontmatter (pure YAML/markdown parsing)
- plan-scan (pure PLAN.md structure parsing)
- decisions (pure decision-record parsing)
- secrets (regex-based detection in text)
- uat (UAT-criteria parsing)
Structural divergence — different approach needed:
- state — sync vs async file ops; mutation paths differ.
- workstream — lifecycle ops; per-side API surface differs.
- phase, roadmap, init, profile-output, template — large
surfaces; each its own potential enhancement.
None of these is in scope for Phase 3.
Closes#3544.
Phase 2 of the CJS↔SDK hard-seam migration (parent #3524).
Eliminates the structural drift surface that produced bug class
After this phase, neither bin/lib/ nor sdk/src/ defines
CONFIG_DEFAULTS, VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, or the
four legacy-key normalizations inline. All come from one canonical
source: the Configuration Module (sdk/src/configuration/index.ts)
+ two JSON manifests (sdk/shared/config-{defaults,schema}.manifest.json).
The CJS mirror is generator-emitted (get-shit-done/bin/lib/configuration.generated.cjs)
with a CI freshness check (sdk/scripts/check-configuration-fresh.mjs).
- sdk/shared/config-defaults.manifest.json — canonical nested defaults,
union of CJS + SDK keys (includes security_*, post_planning_gaps,
agent_skills, mode, every git/workflow/hooks sub-section).
- sdk/shared/config-schema.manifest.json — VALID_CONFIG_KEYS array,
RUNTIME_STATE_KEYS array, DYNAMIC_KEY_PATTERNS array with source
strings (regex reconstructed at runtime).
- sdk/src/configuration/index.ts — source of truth. Exports
loadConfig (pure read), normalizeLegacyKeys (pure, idempotent,
returns Normalization[]), mergeDefaults (deep-merge), migrateOnDisk
(explicit opt-in disk writeback), plus CONFIG_DEFAULTS,
VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS.
- sdk/src/configuration/index.test.ts — 29 vitest pinning tests.
- sdk/scripts/gen-configuration.mjs — generator (Function.prototype.toString()
inspection of compiled SDK dist, plus brace-balanced text scan for
internal helpers, matching the Phase 1 pattern).
- sdk/scripts/check-configuration-fresh.mjs — CI freshness gate.
- tests/configuration-generator.test.cjs — 27 parity assertions
(CJS-generated == SDK source).
- tests/configuration-migrate-config.test.cjs — 3 cases for the new
gsd-tools migrate-config subcommand.
- bin/lib/core.cjs: CONFIG_DEFAULTS literal now sources values from
CANONICAL_CONFIG_DEFAULTS (the manifest), with a thin flat
projection at the load boundary to preserve the existing
flat-shape return contract for the ~21 CJS test files and 100+
consumers. All four legacy-key migration blocks (branching_strategy,
sub_repos, multiRepo, depth — historically lines 351-358, 388-397,
401-408, 416-423) collapse to a single normalizeLegacyKeys call
in each code path. The inline platformWriteSync writeback stays
for now to preserve sync loadConfig semantics; the new async
migrateOnDisk is reachable via gsd-tools migrate-config.
- bin/lib/config-schema.cjs: 135 → 31 lines. Re-exports from the
generated Module.
- bin/lib/config.cjs: adds cmdMigrateConfig handler (calls
migrateOnDisk on the explicit user-driven path).
- bin/gsd-tools.cjs: wires migrate-config into command dispatch.
- sdk/src/config.ts: re-exports CONFIG_DEFAULTS and mergeDefaults
from the Module. loadConfig now calls normalizeLegacyKeys before
mergeDefaults (replaces the inline branching_strategy graft).
- sdk/src/query/config-schema.ts: 160 → 36 lines. Re-exports from
the Module.
- tests/config-schema-sdk-parity.test.cjs: refactored from
"CJS Set equals SDK Set" (trivially true post-migration) to
"both sides source from the manifest" — structural plus runtime
invariant.
- Four other tests that text-grepped source files for valid keys
(plan-review-convergence, bug-3212, bug-2492, feat-3210) are
updated to use runtime VALID_CONFIG_KEYS.has() or manifest JSON
lookups.
- CONTEXT.md: new Configuration Module entry with full Interface
contract.
- Root package.json: check:configuration-fresh proxy script.
- sdk/package.json: gen:configuration + check:configuration-fresh.
- .githooks/pre-commit: configuration drift block.
- .github/workflows/test.yml: configuration drift step after the
alias drift check.
- 9201 CJS tests pass (baseline pre-cycle: 9195; +6 net new tests
across migrate-config + parity refactor)
- 1872 SDK vitest tests pass
- 29 Configuration Module vitest fixtures
- 27 CJS/SDK parity fixtures
- Net diff: +388 / −519 = 131-line reduction across the seven cycles,
despite adding the new Module, manifests, generator, freshness
check, and two new test files.
1. SDK CONFIG_DEFAULTS now includes manifest-canonical keys
(resolve_model_ids: false, context_window: 200000, phase_naming,
claude_md_path, git.create_tag, workflow.security_*,
workflow.code_review_*, planning.*, hooks.workflow_guard, ship.*).
Consumers accessing via [key: string]: unknown index get
the manifest default instead of undefined.
2. SDK mergeDefaults is now proper recursive deep-merge instead of
spread-per-section. Overlay { workflow: { research: false } }
now preserves sibling workflow keys; previously it replaced
the entire workflow section with only research + the section's
defaults. Semantically identical for the common case;
strictly better for partial nested overrides.
3. New gsd-tools migrate-config CLI subcommand for the explicit,
opt-in on-disk migration path.
Closes#3536.
* fix(3537): route every phase-number ROADMAP regex through phaseMarkdownRegexSource
v1.42.1 added the padding-tolerant `phaseMarkdownRegexSource()` helper but
wired it into only 1 of 8 call sites that build phase-number regexes against
ROADMAP/STATE prose. The other 7 used raw `escapeRegex(phaseNum)` or partial
`0*${escapeRegex(...)}` (tolerated extra padding, not missing), so when
skills passed the resolved padded form (`02.7`) against un-padded ROADMAP
prose (`### Phase 2.7:`, `- [ ] **Phase 2.7:**`), the verbs silently no-op'd
while reporting success.
This consolidates every phase-number ROADMAP/STATE regex through the
canonical helper:
- Promote `phaseMarkdownRegexSource` from `roadmap.cjs` to `core.cjs` so
`phase.cjs` and `core.cjs` itself can consume it (no circular dep —
both already import `core.cjs`).
- Wire the helper into the 7 remaining sites:
- `core.cjs:getRoadmapPhaseInternal` (replaces hand-rolled `isNumeric`
branch that only padded integers, not decimals).
- `roadmap.cjs:cmdRoadmapGetPhase` (searchPhaseInContent escapedPhase).
- `roadmap.cjs:cmdRoadmapAnalyze` checkbox lookup.
- `roadmap.cjs:cmdRoadmapAnnotateDependencies` phase header lookup.
- `phase.cjs:cmdPhaseNextDecimal` ROADMAP prose scan.
- `phase.cjs:cmdPhaseInsert` target anchor + decimal scan + header.
- `phase.cjs:cmdPhaseComplete` (3 regexes: checkbox, plan-count,
REQUIREMENTS extraction).
Adds `tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs` — a
parity-style regression matching CONTEXT.md DEFECT.GENERATIVE-FIX: for
each user-facing verb, asserts that the padded form (`02.7`) and the
un-padded form (`2.7`) produce identical ROADMAP.md against an identical
fixture. Includes one control case (`update-plan-progress`, already wired
in 1.42.1) to prove the parity assertion is non-vacuous.
Closes#3537
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(3537): add changeset fragment (pr: placeholder, amended post-create)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(3537): pin changeset pr: field to #3538
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(3530): STATE.md Document Module via generator (Phase 1 of #3524)
Phase 1 of the CJS↔SDK hard-seam migration (parent #3524).
Converts the hand-synced state-document.cjs/state-document.ts pair
into a generator-driven seam, modeled on the existing
command-aliases.generated.* precedent.
What landed:
- sdk/src/query/state-document.ts is the source of truth.
- sdk/scripts/gen-state-document.ts emits
get-shit-done/bin/lib/state-document.generated.cjs from the
compiled SDK dist via Function.prototype.toString() inspection
for the 7 public exports and 3 internal helpers.
- sdk/scripts/check-state-document-fresh.mjs is the CI freshness
gate; pre-commit hook also runs it when relevant files change.
- get-shit-done/bin/lib/state-document.cjs is reduced to a one-line
re-export from state-document.generated.cjs so existing callers
(state.cjs, workstream-inventory.cjs, init.cjs) need no changes.
- New CI step in .github/workflows/test.yml after the existing alias
drift check.
- sdk/package.json: gen:state-document, check:state-document-fresh
scripts. tsx added as devDep.
- Root package.json: proxy script for the freshness check.
- CONTEXT.md: one-sentence amendment on STATE.md Document Module
recording the source-of-truth file path.
Tests:
- sdk/src/query/state-document.test.ts: 34 vitest fixtures across
the 7 public exports (TDD pinning safety net).
- tests/state-document-generator.test.cjs: 31 node:test parity
assertions comparing SDK source vs generated CJS for every
fixture.
- Full suite: 9177/9177 pass (baseline was 9146; +31 new tests).
One subtle behavior change worth flagging: the old hand-written
state-document.cjs used String(str) coercion inside escapeRegex,
which the SDK source does not. The generator faithfully matches
the SDK (the source of truth per ADR-3524), so the new CJS no
longer coerces non-string input to string before regex-escaping.
No current caller passes non-string input, so no observable
regression in the test suite. Flagged in the PR body for
reviewers.
Closes#3530.
* fix(3530): address state-document review findings
Three-part fix for the false "unknown config key(s)" warning fired for
top-level `branching_strategy` in .planning/config.json:
1. On-disk migration (option 3, mirroring multiRepo → planning.sub_repos):
When loadConfig reads a config.json with top-level `branching_strategy`
set and `git.branching_strategy` unset, it grafts the value into
`git.branching_strategy` and deletes the top-level key, then persists.
If `git.branching_strategy` is already set, the nested value wins
(matches SDK mergeDefaults precedence, PR #3116).
2. KNOWN_TOP_LEVEL safety net: 'branching_strategy' added to the deprecated-
keys bucket so the warning never fires even on the first read of a root
config that feeds a workstream merge (where `parsed` may still carry it).
3. Double-emission guard: a module-level `_warnedUnknownConfigKeys` Set
deduplicates the unknown-key warning across multiple loadConfig calls
within a single CLI invocation (init phase-op N called it twice).
Closes#3523
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The post-merge worktree-cleanup loop in quick.md issued bare `git diff`,
`git merge`, and related commands relying on CWD = project root. An LLM
orchestrator that reformats bash across separate tool calls can leak CWD
into a worktree, causing the merge to silently no-op.
At the top of each iteration body, resolve PROJECT_ROOT via
`git -C "$WT" rev-parse --git-common-dir` and `cd "$PROJECT_ROOT"`.
If the root cannot be resolved or reached, log a skip message and
continue to the next manifest entry. All existing guards (pre-merge
deletion guard #1756, STATE.md/ROADMAP.md backup/restore, resurrection
guard #2501/#3195) remain intact after the CWD pin.
Closes#3521 (bug 1 — CWD safety only; bug 2 / resurrection guard was
already fixed in a6beac40 / PR #3201 and is pending reporter retest).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The grep -v alternation in the git-enhanced two-way merge step was missing
the 'gsd-update' arm after the slash-command rename from /gsd:update to
/gsd-update. Commits authored by the current update flow fell through the
filter and were misclassified as user customizations, causing spurious merge
prompts during /gsd-update --reapply.
Adds 'gsd-update' between 'gsd:update' and 'GSD update'. The legacy
'gsd:update' arm is preserved for back-compat; 'GSD update' and 'gsd-install'
exclusions are unchanged.
Extract composeStatusline() helper from duplicated inline template logic in
runStatusline() and renderStatusline(). Both call sites now route through the
helper, which accepts a position param ('end' | 'front', default 'end').
- 'end' (default) preserves byte-identical output to v1.38.x and earlier
- 'front' renders ctx immediately after model name, before the first │
- Invalid values silently coerce to 'end' at runtime (belt-and-suspenders;
config-set rejects invalid values upfront via enum validator)
Adds statusline.context_position to VALID_CONFIG_KEYS in both CJS and TS
schemas, enum validator in config.cjs, docs row in CONFIGURATION.md,
and a changeset. Closes#2937.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>