Commit Graph

1095 Commits

Author SHA1 Message Date
Tom Boucher
ca2644a71a fix(worktree): unlock-retry on locked cleanup + startup orphan sweep (#3707) (#3719)
* fix(worktree): unlock-retry on locked cleanup + startup orphan sweep (#3707)

Two root causes fixed:

1. **In-session cleanup blocked**: `executeWorktreeWaveCleanupPlan` now attempts
   `git worktree unlock <path>` then retries `git worktree remove --force` when the
   initial single-force remove fails on a locked worktree. Previously every cleanup
   after a successful merge was silently blocked.

2. **Cross-session orphan accumulation**: new `reapOrphanWorktrees` helper sweeps
   `.git/worktrees/*/locked` at startup. It reaps entries where the pid is dead,
   the branch tip is an ancestor of the default branch (ancestry guard prevents data
   loss on squash-merge repos), and the lock mtime is older than 5 minutes (race
   guard). Wired into `quick.md` and `execute-phase.md` startup blocks guarded by
   `USE_WORKTREES != false`.

SDK: adds `worktree.reap-orphans` query command (routes through gsd-tools.cjs).
Tests: 11 real-fs tests covering unlock-retry, dead-pid reap, live-pid skip,
unmerged skip, fresh-mtime skip, idempotent double-call, and structural wiring.

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

* chore(changeset): add Fixed fragment for PR #3707 (worktree orphan cleanup)

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

* fix(worktree): fix test portability on Windows + macOS for bug-3707 reap tests

- worktreeMeta helper: replace /\/\.git$/ with /[/\\]\.git$/ so the
  gitdir path suffix is stripped on both Windows (backslash) and Unix.
- worktreeMeta helper: normalize CRLF→LF before splitting porcelain
  blocks, fixing block parsing when git emits CRLF on Windows.
- reapOrphanWorktrees: replace single 'main' rev-parse with a
  [defaultBranch, 'main', 'master'] candidate loop so test fixtures
  without a remote origin (where branch may be 'master') don't bail
  early. Intentionally excludes 'HEAD' to prevent false reaping when
  HEAD is detached or on a feature branch (Codex adversarial finding).

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

* fix(worktree): CI green — macOS symlink path, Windows test helper, pid portability, EPERM liveness

Four fixes to get macOS + Windows CI from red to green:

1. **macOS symlink mismatch** (worktree-safety.cjs): `reapOrphanWorktrees` now
   builds a canonical→listed path map from `git worktree list --porcelain` using
   `fs.realpathSync.native`. Uses the listed path (as git knows it) for
   `git worktree unlock/remove`, not the gitdir-derived path.  Fixes the
   `/var/folders` vs `/private/var/folders` discrepancy on GitHub macOS runners
   where `git worktree unlock <realpath>` was silently failing because git's
   list stored the unresolved symlink path.

2. **Windows path separator in test helper** (test file): `worktreeMeta`
   `.replace(/\/\.git$/, '')` → `.replace(/[/\\]\.git$/, '')`. On Windows,
   git writes backslash separators in the gitdir file; the Unix-only regex was
   causing `Cannot find .git/worktrees/<name>` for all Suite 2 tests.

3. **Non-portable PID in tests** (test file): All `'999999'` dead-PID literals
   replaced with `deadPid()` helper that spawns a real short-lived child, captures
   its PID, and returns it after exit. Eliminates flakiness on Linux systems where
   `pid_max` can reach 4194304, making 999999 a live PID.

4. **EPERM fail-closed in isPidAlive** (worktree-safety.cjs): `catch { return false }`
   → checks `err.code === 'EPERM'` and returns `true` (alive). On Windows and
   cross-user scenarios, `process.kill(pid, 0)` throws EPERM for live but
   inaccessible processes; treating that as dead would reap a live worktree.

Adversarial review via codex confirmed:
- Squash-merge repos: fail-closed (CONCERN, not BUG — by design, not data-loss)
- canonicalToListed map: SAFE (fail-closed on realpathSync error)
- Concurrent reapers: SAFE (both prune; second gets skipped: remove_failed)
- Startup blocking: CONCERN (no global cap, 10s/call × N worktrees) — tracked,
  not fixed here (requires separate perf work)
- gsd-sdk missing: SAFE (quick.md checks and fails fast with guidance)

All 27 local tests + Docker (holodeck) green.

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

* fix(worktree): address codex adversarial findings — fail-closed default branch + CRLF map

Two fixes from codex adversarial review of PR 3718:

1. **Default branch resolution (data-loss risk)**: `reapOrphanWorktrees` now
   uses `refs/remotes/origin/<branch>` exclusively when a remote is configured.
   If `origin/HEAD` is absent but a remote exists, we bail out (fail-closed)
   rather than falling back to a local `main`/`master` that may not be the
   real integration branch.  The `main`/`master` fallback is only used when
   there is provably no remote (local-only test fixtures).

2. **CRLF normalization in canonical-path mapper**: The `worktree list
   --porcelain` output was split on '\n\n' without normalizing CRLF first.
   On Windows, git emits CRLF, which caused block-splitting to fail and
   left the canonicalToListed map only partially populated, weakening the
   symlink/path-mismatch fix introduced earlier.

3. **Windows 8.3 short-path fix (test helper)**: Both `beforeEach` blocks
   now call `resolvedTmpDir()` which pre-resolves `os.tmpdir()` via
   `fs.realpathSync.native` so temp paths avoid RUNNER~1-style short names
   that git stores in long form, causing worktreeMeta path comparisons to
   fail on Windows CI.

All 11 real-fs + 16 unit tests green locally.

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

* fix(worktree): adversarial findings + macOS CI path-mismatch fix

## Root cause (macOS CI fail)
`reapOrphanWorktrees` stored `worktreePath` (gitdir-derived, real path via
git's symlink resolution, e.g. `/private/var/folders/…`) in results, while
the test's `wtDir` used the unresolved symlink form (`/var/folders/…`).  After
reaping, `canonicalPath(wtDir)` can no longer call `realpathSync.native`
(directory gone), so it falls back to `path.resolve` — which returns the
symlink form — causing the `result.find()` comparison to miss.

## Fixes applied

### Source — worktree-safety.cjs
1. **Finding 1 (fail-closed PID check)**: Non-parseable lock content (e.g.
   `"Locked by claude-code agent-xxx"`) is now treated as ALIVE with reason
   `lock_owner_unknown`, not as dead.  Previously it fell through as dead.
2. **Finding 1b (EPERM safe)**: `isPidAlive` call wrapped in try/catch; any
   thrown error (EPERM = process exists but cross-user on Windows) → ALIVE.
3. **Finding 2 (startup warning)**: `cmdWorktreeReapOrphans` now writes a
   one-line stderr warning when ≥1 entry is skipped or when reaper throws,
   while keeping exit-zero so workflows don't break.
4. **Finding 3 (default-branch discovery)**: Local-only fallback now tries
   `init.defaultBranch` config and HEAD symref before `main`/`master`, so
   repos configured with `trunk`, `dev`, etc. get correct orphan detection.
5. **macOS path fix**: Result entry for reaped worktrees now uses `gitKnownPath`
   (from `git worktree list`) instead of `worktreePath` (from gitdir file),
   ensuring the caller always sees the path git uses for the worktree.

### Test — bug-3707-locked-worktree-cleanup.test.cjs
6. **macOS CI fix**: Pre-compute `wtDirCanonical = canonicalPath(wtDir)` before
   calling `reapOrphanWorktrees` so the comparison works after removal.
7. **Gap 1**: New test — Claude Code lock format (`"Locked by claude-code …"`)
   must not be reaped; asserts `status=skipped, reason=lock_owner_unknown`.
8. **Gap 2**: New test — `isPidAlive` throwing EPERM → must not reap.
9. **Gap 3**: New test — repo with `init.defaultBranch=trunk`; merged worktree
   must be reaped (verifies trunk is discovered as the integration branch).

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

* fix(test): raise waitForStoppedAt timeout 2 s → 5 s for Windows/Node22 CI load

Subprocess write latency exceeds 2 s on loaded windows-latest/Node22 runners
(test duration was 6181 ms); 5 s gives sufficient headroom without changing
any production behaviour.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:22:12 -04:00
Tom Boucher
ab24d80b68 fix(state): acquireStateLock throws on non-EEXIST openSync errors (#3773)
* fix(state): acquireStateLock throws on non-EEXIST openSync errors

Closes #3772

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

* chore(changeset): update pr reference to #3773

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

* fix(state): retry on EPERM/EBUSY in acquireStateLock and withPlanningLock

Resolves state update TOCTOU failure on macos-22 and config-set
concurrency failure on windows-24.
Root cause: openSync(O_CREAT|O_EXCL) can return EPERM or EBUSY
transiently on some CI OS+AV combinations when the lock file is
briefly held open by the deleting process; the new throw-on-non-EEXIST
guard from #3772 propagated these transient errors, killing child
processes and causing lost updates in the concurrency tests.
Fix: guard EPERM/EBUSY with an explicit continue before the
throw-on-non-EEXIST line in both acquireStateLock and withPlanningLock;
the C1 source-audit test still passes because the throw pattern is
preserved for all other non-EEXIST codes.

Refs #3772

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:59:20 -04:00
Tom Boucher
6a5fa59129 feat(3081): auto-trim review prompts for small-context model reviewers (#3708)
* feat(3081): auto-trim review prompts for small-context model reviewers

Adds review.max_prompt_tokens and review.max_prompt_tokens_per_reviewer
config keys. When configured, the /gsd-review workflow deterministically
trims the assembled prompt before sending to each reviewer (drop CONTEXT
→ RESEARCH → REQUIREMENTS; head-shrink PROJECT.md; tail-truncate PLANs
proportionally; reserve disclosure-note tokens upfront). Trim metadata
is recorded in REVIEWS.md frontmatter. Reviewer is skipped with a
warning if even the minimum review set exceeds the budget.

Closes #3081

* fix(3081): register prompt-budget in SDK query registry and update inventory manifest

review.md references `gsd-sdk query prompt-budget` at three call sites, but the
command had no handler in the SDK registry — failing the registry-integration
drift-guard test on all 6 CI matrix legs. Added a native TypeScript SDK handler
(sdk/src/query/prompt-budget.ts) that ports the applyBudget logic from the CJS
module, registered it in DOMAIN_STATIC_CATALOG, and regenerated
docs/INVENTORY-MANIFEST.json to include the new cli_modules/prompt-budget.cjs entry.

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

* fix(3081): bump ws to 8.20.1 and allowlist prompt-budget sibling pair

Two additional CI failures after the registry fix:

1. ws moderate CVE (GHSA-58qx-3vcg-4xpx, uninitialized memory disclosure):
   The advisory covers ws >=8.0.0 <8.20.1. Both root and sdk/package.json
   pinned ^8.20.0 which resolved to 8.20.0. Bumped both to 8.20.1 to clear
   the npm audit drift-guard test (bug-3588-npm-audit-clean.test.cjs).

2. lint-shared-module-handsync detected the new prompt-budget.ts / prompt-budget.cjs
   sibling pair without an allowlist entry. Added a cooperatingSiblings entry
   to scripts/shared-module-handsync-allowlist.json with classification and
   justification matching the established pattern.

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

* fix(3081): align prompt-budget skip semantics across CJS and SDK dispatch paths

Replace brittle `[ $EXIT -eq 2 ]` guards with `[ $EXIT -ne 0 ]` in all three
local-reviewer blocks (Ollama, LM Studio, llama.cpp) in workflows/review.md.
Any non-zero exit from prompt-budget now triggers a skip with a descriptive
warning — exit 2/11 prints "budget too small", any other non-zero prints
"unexpected exit code". This ensures the SDK bridge dispatch path (exit 11
via GSDError(Blocked)) triggers the same skip as the CJS path (exit 2).

The SDK handler (sdk/src/query/prompt-budget.ts) already writes both metadata
and prompt files before throwing, so no change needed there.

The Ollama block also gains the missing OLLAMA_SKIP guard so the reviewer
invocation is actually skipped (previously the block only suppressed the
OLLAMA_PROMPT_FILE update but still ran the curl invocation).

SDK integration path (hardFailed via GSDError(Blocked) → exit 11) is covered
by handler unit tests in tests/prompt-budget.test.cjs; no gsd-sdk-*.test.cjs
exercising the full bridge dispatch for this command exists yet — that gap
remains and is documented here.

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

* fix prompt-budget trim ordering and review guard follow-ups

* perf: optimize prompt-budget and dedup reviewer trim workflow

* fix(3708): drop source-grep theater tests to satisfy lint-no-source-grep

All four test files added in commit 2df566ed were pure source-grep theater:
they read .cjs / .ts / .md source files and asserted that specific string
literals were present or absent. None exercised runtime behaviour.

Deleted:
- tests/gsd-tools-memory-optimizer.test.cjs   — 7 includes() on gsd-tools.cjs
- tests/prompt-budget-hotpath-optimizer.test.cjs — includes() on prompt-budget.cjs + .ts
- tests/prompt-budget-io-optimizer.test.cjs   — includes() on prompt-budget.ts + gsd-tools.cjs
- tests/review-workflow-budget-dedup.test.cjs — includes() on review.md

Behavioural coverage for the prompt-budget feature already exists in
tests/prompt-budget.test.cjs and tests/prompt-budget-cli.test.cjs (also
added by this PR). No replacement tests needed.

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

* fix(3708): correct budget-pressure threshold and minSet accounting

Two bugs in applyBudget caused premature trimming and false hard-fails:

1. UNNEEDED_TRIM: budgetUnderPressure compared baseTokens against
   effectiveBudget - NOTE_RESERVE_TOKENS, triggering trim pressure 80
   tokens before the budget was actually exceeded. Fix: compare against
   effectiveBudget directly; NOTE_RESERVE_TOKENS are still reserved in
   contentBudget once real pressure is confirmed.

2. FALSE_HARDFAIL: minSet included NOTE_RESERVE_TOKENS unconditionally,
   treating the note as mandatory even when no trim would occur and no
   note would be injected. Fix: exclude NOTE_RESERVE_TOKENS from minSet;
   a prompt that fits untrimmed needs no note and must not hard-fail.

Both fixes applied in CJS and TypeScript implementations. Two regression
tests added (cycles 11 and 12) that reproduce each case behaviorally.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:13:09 -04:00
Tom Boucher
f1a61620bc fix(ci): bump coverage heap budget + Windows npm timeout in runNpm helper (#3704)
Set NODE_OPTIONS=--max-old-space-size=6144 on the Unit coverage step so the
c8 report phase has 6 GB instead of Node's default ~4 GB heap; the Linux
Node 24 runner has 7 GB available so this leaves 1 GB headroom. Raise the
runNpm default timeout from 55 000 ms to 180 000 ms so cold-cache Windows
npm install -g runs (which take 60-90 s on NTFS + Defender) complete before
the child process is killed.

Refs #3703

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:14:52 -04:00
Cristian Uibar
463eb26544 Match gsd-sdk query commit in graphify auto-update hook (#3653) (#3658)
* Match `gsd-sdk query commit` in graphify auto-update hook (#3653)

The PostToolUse Bash hook only substring-matched direct shell git ops in
tool_input.command. `gsd-sdk query commit` invokes git via spawnSync,
so the literal "git commit" never appears in the Bash tool's command
string and the hook silently skipped every SDK-issued commit. Result:
.planning/graphs/ drifted stale after every phase that closed via
gsd-sdk query commit, with no error and no log.

Gate 2 now also matches `gsd-sdk query commit`. Other SDK verbs
(phase.complete, roadmap.update-plan-progress, state.begin-phase) do
not invoke git themselves and remain non-matching to avoid spurious
rebuilds per state mutation. Adds positive + negative matcher tests.

* Fix changeset frontmatter for #3658

`type: Bug Fix` rejected by scripts/changeset/parse.cjs ALLOWED_TYPES
(Keep a Changelog values: Added/Changed/Deprecated/Removed/Fixed/Security).
Switch to `type: Fixed` and add `pr: 3658` required by MISSING_PR check.

docs-lint now reports `ok_no_triggering_fragments` locally.

* fix(#3658): bound graphify SDK commit matcher

* fix(#3658): exempt release note docs lint
2026-05-18 13:15:15 -04:00
Tom Boucher
ef951098a6 chore(3686): add release-tarball lifecycle smoke to install-smoke workflow (#3692)
* chore(3686): add release-tarball lifecycle smoke to install-smoke workflow

Closes #3686.

Adds a non-interactive lifecycle smoke that runs against the installed
tarball (not the working tree). Catches the two recent release-time bug
classes that the working-tree test suite cannot see:

  * #3684 — symbol mismatches between init.cjs imports and secrets.cjs
    exports that landed in v1.42.3 (closed/fixed-pending-release).
  * #3668 — bare `gsd-sdk` invocations in 75 of 78 workflow files with no
    `command -v gsd-sdk … elif node "$GSD_TOOLS"` fallback (open).

Shape:

  * `scripts/release-tarball-smoke.cjs` — pure CJS module exporting a
    frozen `SMOKE` enum and a `runSmoke({ tarballPath, installPrefix,
    expectedVersion, fixtureDir, lifecycleCommands })` function. CLI
    `--json` mode prints `JSON.stringify(result)` and exits 0 iff
    `result.code === SMOKE.OK`. Install is `--prefix <tmpdir>` so it
    does not pollute global node_modules.

  * `tests/release-tarball-smoke.test.cjs` — 6 tests covering happy
    path, version mismatch, lifecycle command file resolution,
    missing-command detection, sdk binary callability, and structural
    workflow-body checks. Tests assert on the SMOKE enum directly; no
    `assert.match` on rendered prose, no try/finally in test bodies,
    no source-grep theater. Uses `before`/`after` to pack+install
    once across the test file.

  * `tests/release-tarball-smoke-workflow.test.cjs` — 7 structural
    assertions on the parsed install-smoke.yml IR (workflow_call
    trigger preserved, lifecycle step calls release-tarball-smoke.cjs
    with --json, jq check enforces result.code === "ok", path filter
    includes the new files, artifact-on-failure step present).

  * `.github/workflows/install-smoke.yml` — extended (not duplicated).
    New "Lifecycle smoke" step after the existing version check, on the
    same matrix. Artifact upload on failure for debugging. Path filter
    now triggers on changes to the new script + test.

Per CONTRIBUTING.md §"Prohibited: Raw Text Matching on Test Outputs"
this PR avoids the same anti-pattern that caused PR #3666 to be
reverted (PR #3688) — the script returns frozen enum codes, tests
assert on the enum, never on stdout strings.

Workflow-body checks in Cycle 3 are INFORMATIONAL (count returned,
not enforced) on this PR. After #3668's fix lands and the 75 missing
fallbacks are added, the lane can be tightened to enforce zero.

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

* fix(3686): harden release smoke workflow and query scanner

* fix(3686): move tarball-smoke test to install suite to fix Windows ETIMEDOUT + coverage OOM

Windows (Node 22/24/26, jobs 76565215694/76565215710/76565215812):
release-tarball-smoke.test.cjs had no suite marker so run-tests.cjs
classified it as 'unit', running it on Windows PR CI. The before() hook
calls execFileSync(npm.cmd install -g ...) with a 55 s timeout; on
Windows GHA runners this npm global install consistently hits ETIMEDOUT
(~62 s observed), causing all 3 Windows lanes to fail.

Coverage (job 76565215085):
c8 ran test:coverage:unit (unit suite only) with V8 coverage tracking
active across child processes. The tarball-smoke test's before() hook
spawned npm install subprocesses while c8 held V8 coverage descriptors
open, driving the Node heap to 4 GB+ and triggering an OOM abort during
report generation (exit code 134, all 5682 tests had already passed).

Fix: rename to tests/release-tarball-smoke.install.test.cjs so
run-tests.cjs routes it to the 'install' suite. The install suite is
already skipped on PR CI by design (test.yml lines 179-181: only runs on
main push). The dedicated install-smoke.yml workflow continues to exercise
this test on its own matrix. Also update the install-smoke.yml PR path
filter and the structural wiring test assertion to match the new filename.

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

* refactor(test): route tarball-smoke install test through tests/helpers.cjs

Replaces direct fs.mkdtempSync and execFileSync calls in tests/release-tarball-smoke.install.test.cjs with createTempDir() and a new runNpm() helper in tests/helpers.cjs. Cleanup is now automatic via the helper. Addresses CodeRabbit Major refactor at https://github.com/gsd-build/get-shit-done/pull/3692#discussion_r3260433892.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:05:02 -04:00
Fernando Castillo
b01089c05a fix(#3689): use find instead of chained ls for continue-here scan (#3693)
* fix(#3689): use find instead of chained ls for continue-here scan

The check_incomplete_work step in resume-project.md chained six bare-glob
ls arguments to discover .planning/.continue-here*.md handoff files.
Under zsh's default NOMATCH option (macOS default shell), the first
non-matching glob aborts the entire command during word-expansion,
silently dropping every pattern after it — including
.planning/.continue-here*.md, which holds the canonical pause checkpoint
for default-context handoffs. The 2>/dev/null || true guard suppresses
ls's own stderr / exit code but has no effect on the shell's pre-exec
glob-abort.

Replace the chain with two find invocations:
  find .planning -maxdepth 3 -name '.continue-here*.md' -print 2>/dev/null
  find . -maxdepth 1 -name '.continue-here*.md' -print 2>/dev/null

find does not use shell glob expansion and tolerates absent directories
on both bash and zsh.

Adds tests/bug-3689-resume-glob-nomatch.test.cjs covering:
  - zsh -o nomatch with .planning/.continue-here-AT-1234.md present and
    no spike/sketch/deliberation subdirs (the regression scenario)
  - bash default, same layout
  - zsh -o nomatch with no checkpoints anywhere (clean exit, no output)
  - text invariant: resume-project.md no longer carries the chained-ls
    pattern and does carry the find-based scan

Closes #3689

* fix(#3689): guard find commands with || true for Windows safety

Restore the trailing '|| true' guard that the original chained ls had,
per the windows-robustness.test.cjs invariant (informational bash
commands in critical workflows must not let an exit-1 from find on
exotic platforms tank the resume-project.md flow).

* fix(#3689): address CodeRabbit review feedback

- Set changeset frontmatter pr: 3693 (was 3690 placeholder); the release-
  note link now points at the right PR.
- Use createTempDir / cleanup from tests/helpers.cjs instead of local
  tmpdir/cleanup wrappers, per repo test standards.

* fix(#3689): update bug-3446 discovery contract to new find-based scan

bug-3446 enforced three text invariants on the chained-ls implementation
that this PR replaces with find. Update the assertions to verify the
same three discovery paths are still covered:

- .planning/.continue-here*.md at depth 1 -> find .planning -maxdepth 3
- .planning/sketches/SKETCH-NNN/.continue-here*.md at depth 3 -> same
  find with -maxdepth >= 3 (assertion now reads the actual depth and
  enforces a lower bound so future changes can deepen but not shallow it)
- repo-root .continue-here*.md legacy fallback -> find . -maxdepth 1

The discovery contract is preserved; only the implementation under
inspection changes.

* test(#3689): convert bug-3446 from source-grep to behavioral assertions

CodeRabbit flagged the text-regex assertions on the workflow source as a
violation of the no-source-grep testing standard. Replace them with a
behavioral integration test that:

  1. Extracts the actual check_incomplete_work bash block from
     resume-project.md (so the test stays in sync with whatever the
     workflow does, no string match required).
  2. Plants three handoff files in a temp dir covering the three
     discovery surfaces bug #3446 originally filed:
     - .planning/.continue-here.md (depth 1 under .planning)
     - .planning/sketches/SKETCH-001/.continue-here.md (depth 3)
     - ./.continue-here.md (legacy repo root)
  3. Runs the snippet under bash and asserts each planted file appears
     in stdout.

Same contract; now validated through runtime behavior instead of
regex-on-file.

* fix(#3689): use \\r?\\n in bash-fence regex for Windows CRLF parity

The Windows test-parity guard at tests/windows-test-parity-guard.test.cjs:106
flags any new fence-extraction regex that uses a literal \\n after the
language tag — on Windows CRLF the byte after `bash` is \\r, the regex
silently fails to match, and the extracted snippet is empty. Switch to
the canonical /```(?:bash|sh)\\r?\\n([\\s\\S]*?)```/ pattern.

* fix(#3689): harden extractCheckBlock against missing </step> and CRLF closing fence

Per CodeRabbit review: assert that the closing </step> tag is present before
slicing (otherwise slice(stepStart, -1) silently grabs the wrong block and
the test fails misleadingly), and add \r?\n to the closing fence as well so
Windows CRLF doesn't sneak a stray carriage return into the captured snippet.
2026-05-18 12:42:43 -04:00
Tom Boucher
e50ad8127f fix(3696): pr-template enforcer recognises CI/tooling carve-out (#3697)
* fix(3696): pr-template enforcer recognises CI/tooling carve-out

The enforcer in scripts/pr-template-policy.cjs only validated against
three typed templates and three hard-coded DEFAULT_TEMPLATE_MARKERS.
It had zero awareness of the documented CI/tooling/dep/doc-only exception
in .github/pull_request_template.md, meaning external contributors who
followed the documented escape hatch still had their PRs auto-closed.

Fix:
1. Path-scope auto-skip: if every changed file matches a tooling glob
   allowlist (.github/**, scripts/**, docs/**, *.md, .changeset/**,
   dependency manifests), skip enforcement and exit success with no
   comment posted.
2. Explicit exemption marker: if the PR body contains
   <!-- pr-template-exempt: <non-empty reason> -->, skip enforcement.
3. Workflow updated to fetch changed file paths via gh and pass them
   as CHANGED_FILES env to the policy script.
4. Pull request template updated to document both mechanisms and retire
   the old "delete this file" prose carve-out.
5. 14 new tests (TDD red→green); all 25 tests pass.

Closes #3696

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

* fix(3696): allow hyphenated pr-template exemption reasons

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:06:46 -04:00
Tom Boucher
cb154569cf revert: fix(surface) #3666 — CONTRIBUTING.md test-pattern violations (#3688)
Reverts c5657fcbfd (squash of PR #3666).

The production change was correct in intent. The new test file
tests/surface-state.test.cjs violates two CONTRIBUTING.md rules
that the project enforces specifically because they produce
passing-but-useless tests:

  1. ~15 try { ... } finally { cleanup(dir); } blocks in test bodies
     — CONTRIBUTING.md "Never use try/finally inside test bodies."
     Approved forms are beforeEach/afterEach hooks or t.after(...).

  2. 17 assert.match calls on rendered console.warn prose
     — CONTRIBUTING.md "Prohibited: Raw Text Matching on Test
     Outputs." Tests must assert on a typed structured surface
     (frozen reason enum) instead of the rendered prose, otherwise
     they pass-but-rot the moment a warning string is reworded.

The .changeset/witty-birds-gather.md fragment is also reverted so
the v1.43 changelog does not advertise a fix that is not on main.

A reworked PR addressing items 1-4 from the re-review (linked on
the closed PR thread) is welcome — the intent of the fix is right.

Refs #3666 #3662

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:20:36 -04:00
Cristian Uibar
c2dc9e2532 Fix W002 false positives on archived phase references in STATE.md body (#3655)
* W002 health check: cross-reference milestones archive for STATE.md phase refs

After /gsd-complete-milestone, phase dirs move into milestones/vX.Y-phases/
and their `#### Phase N:` headings in ROADMAP.md are collapsed inside
<details> blocks. The ROADMAP heading scan misses them, so W002 fired for
every archived phase number mentioned in STATE.md's historical narrative body
("Recent", "Decisions", "Deferred Items") — leaving every project that ever
ran /gsd-complete-milestone permanently degraded with proportional W002 noise.

Union archived milestone phase directories into the validPhases set used by
the W002 check, mirroring the same milestone-archive lookup that W006
already uses (sdk/src/query/validate.ts:723-736).

Closes #3652

* Address review: use shared regex constants and mirror W002 archive fix into CJS path

CodeRabbit (PR #3655 review 1): the ad-hoc /^(\d+[A-Z]?(?:\.\d+)*)/i used to
extract phase tokens from archived phase dirs would skip project-code-prefixed
names like `CK-64-...`. Switch the new SDK block to the shared
PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE constants (defined at
sdk/src/query/validate.ts:32-33) so prefixed archives are recognised. Added a
companion regression test using `CK-`-prefixed dirs.

Codex review: the shipped CJS health command path (get-shit-done/bin/lib/verify.cjs
cmdValidateHealth, routed by validate-command-router.cjs) only unions
collectDiskPhases (active archive only) plus ROADMAP heading scan — same bug as
the SDK had. Port the all-archive scan into the CJS path via listMilestoneArchiveDirs
+ PHASE_TOKEN_FROM_DIR_RE (already declared at verify.cjs:401-402). Added a CJS
regression test covering the multi-sub-milestone (v1.3a + v1.3b) scenario from
the issue report.

Adds .changeset/lucky-lynx-wave.md.

* Address Gemini findings: shared regex + helper reuse + cross-platform path

P1 #2 — refactor the new W002 archive-scan block to reuse the existing
listMilestoneArchiveDirs helper (sdk/src/query/validate.ts:40) instead of
re-implementing readdir + filter inline. Eliminates duplication and prevents
the two call sites from drifting apart.

P2 #4 — listMilestoneArchiveDirs sorted by `a.slice(a.lastIndexOf('/') + 1)`,
which returns the full path on Windows where path.join produces backslashes.
Switch to path.basename(a) so the numeric version sort works cross-platform.
Brings the SDK helper in line with the CJS sibling at
get-shit-done/bin/lib/verify.cjs:411 which already uses path.basename.

P1 #1 / P2 #5 — the pre-existing W006/W007 archive + active phase scans
(Check 8) used an ad-hoc `^(\d+[A-Z]?(?:\.\d+)*)` regex that silently skipped
project-code-prefixed phase dirs like `CK-64-foo`, so W006 fired for a
correctly-archived phase and W007 fired for a correctly-on-disk phase. Switch
both scans to the shared PHASE_TOKEN_FROM_DIR_RE constant declared at
sdk/src/query/validate.ts:32. The W006 archive loop also now reuses
listMilestoneArchiveDirs for consistency.

P2 #6 — strengthen the CK-prefix regression test to also assert no W006
fires for `#### Phase 64: Prior shipped` (placed inside <details> so the
heading scan picks it up while the on-disk scan does not), pinning the
shared-regex behaviour in the W006 path.

* CI: switch retired /gsd-<cmd> comment syntax to canonical /gsd:<cmd>

The bug-2543 slash-namespace invariant lint scans get-shit-done/bin/lib/**
for /gsd-<cmd> patterns and fails CI when one slips into a comment. Use the
canonical /gsd:complete-milestone form in the new verify.cjs comment (and
mirror the change in the SDK + tests + changeset entry so all docstrings
referencing the milestone-completion command share one spelling).

Also: extract a small forEachArchivedPhaseToken helper in validate.ts (Gemini
P3 finding from review pass 2) so Check 4 (W002) and Check 8 (W006) share the
archive-walking loop instead of inlining it twice.

* Address rev3 review: shared regex parity, numeric sort, drop dead try/catch

Gemini P1 — Check 4's flat phases/ scan still used the ad-hoc
/^(\d+[A-Z]?(?:\.\d+)*)/ regex while the archive scan used the shared
PHASE_TOKEN_FROM_DIR_RE. Project-code-prefixed dirs (e.g. CK-65-current) on
the flat layout would have slipped past validity, so the W002 check could
still mis-classify them. Use PHASE_TOKEN_FROM_DIR_RE here too.

Gemini P3 — `[...validPhases].sort()` ordered tokens alphabetically, producing
error messages like "phases 1, 10, 19, 2, 20" instead of "1, 2, 10, 19, 20".
Switch both SDK and CJS to numeric localeCompare so the displayed list is
human-readable. Mirrored in both paths.

Grok P3 — the CJS Check 4 archive block wrapped listMilestoneArchiveDirs in
an outer try/catch even though the helper already swallows ENOENT/EACCES into
[]. The outer catch was unreachable. Removed; only the per-archive readdir
needs a catch.

Grok P2 / Gemini P1 (CJS Check 8 archive scan) — the assertion that CJS
Check 8 needs the same archive union as Check 4 was repeatedly raised across
review passes. It is incorrect: Check 8 filters ROADMAP.md through
extractCurrentMilestone() before scanning headings, which strips shipped
milestones (collapsed in <details> or not) so archived phase numbers never
reach `roadmapPhases`. Added an inline note documenting this and a positive
regression assertion in the CJS test that W006 does NOT fire for the
archived phases in the multi-sub-milestone fixture. (Skipped a parallel
W007 assertion because the active-archive fallback in
getActiveMilestoneArchiveDir is pre-existing behavior unrelated to #3652.)

* Port forEachArchivedPhaseToken helper to verify.cjs for SDK parity

Gemini rev5 P2 — the CJS Check 4 inlined the archive-walking loop while
the SDK already factored it into forEachArchivedPhaseToken(). Add a
mirror helper in verify.cjs so both seams use the same primitive,
matching the cooperating-sibling pattern documented in
scripts/shared-module-handsync-allowlist.json.

* ci: retrigger to clear unrelated TOCTOU flake

The previous CI run failed at the pre-existing #1925 concurrency test
(state add-blocker concurrent calls) on macos-24 and ubuntu-22 but
passed on ubuntu-24 — and the same test passed on the prior CI run of
this branch (commit 8a246916). The state add-blocker code path is
completely independent of the W002 archive-union changes in this PR.
2026-05-18 00:14:10 -04:00
Cristian Uibar
c5657fcbfd fix(surface): default missing optional fields in readSurface, normalize writeSurface input (#3666)
* surface: default missing optional fields in readSurface, normalize writeSurface input

readSurface used to reject any .gsd-surface.json missing one of its four fields
and return null with no diagnostic, so the active surface silently degraded to
the 'full' profile. Optional array fields (disabledClusters, explicitAdds,
explicitRemoves) now default to [] when missing or wrong-typed; hard failures
(malformed JSON, non-object root, missing/non-string baseProfile) still return
null but emit a console.warn naming the file + reason.

writeSurface now normalizes its input to the full SurfaceState shape and
throws on missing baseProfile, so partial writes can no longer land on disk
and trip readSurface later. Tests extended to cover the new lenient and
warn-on-hard-fail behavior plus the writer guard.

Fixes #3662

* surface: reject whitespace-only baseProfile + migrate new tests to helpers.cjs

Applies CodeRabbit findings on PR #3666:

1. readSurface and writeSurface now reject baseProfile values that are blank
   after trim() (e.g. "   "), not only the empty string. Whitespace-only
   strings would split-by-comma to [''] downstream and silently produce an
   unresolvable profile mode. Both guards updated symmetrically; warn/error
   messages reworded to "missing, non-string, or blank".

2. tests/surface-state.test.cjs now uses createTempDir + cleanup from
   tests/helpers.cjs instead of local mkdtempSync + fs.rmSync, aligning with
   the repo coding guideline for root-level tests. The local tmpDir() helper
   delegates to createTempDir for backward-compat with the existing test
   bodies. Per-test cleanup calls swapped to cleanup(dir).

3. Added regression tests:
   - readSurface rejects whitespace-only baseProfile and warns
   - writeSurface rejects whitespace-only baseProfile, non-string baseProfile,
     and null surfaceState

Refs #3662

* surface: warn on unknown baseProfile mode names in read and write

Applies a Codex review finding on PR #3666:

readSurface and writeSurface used to accept any non-blank string as
baseProfile. A typo like {"baseProfile":"standrad"} would pass validation,
then resolveProfile() in install-profiles.cjs would silently fall back to
'full' with no diagnostic — the same silent-degradation symptom that #3662
was filed to fix, just through a different code path.

Both functions now split baseProfile by comma, validate each mode against
the registered PROFILES set ('core', 'standard', 'full'), and emit a single
[gsd] console.warn line that names the unknown modes and lists the valid
ones. The state is still parsed/written — resolveProfile() decides the
actual resolution fallback. Composed profiles where some modes are valid
and some are not warn only about the unknown subset.

Side note: the pre-existing 'round-trips composed base profile' test used
'core,audit' as a stand-in composed string. 'audit' is not a registered
profile (the three known profiles are 'core', 'standard', 'full'), so the
test was relying on the old lack of validation. Switched to 'core,standard'
to preserve the round-trip intent without producing diagnostic noise.

Refs #3662

* changeset: include blank/typo baseProfile in documented read failure cases

CodeRabbit minor finding on PR #3666 — the changeset wording only mentioned
"missing/non-string baseProfile" but the implementation also rejects blank
(including whitespace-only) baseProfile values, and warns on typo'd /
unknown profile mode names. Updated to match actual behavior.

* changeset: pr field should be PR number, not issue number

Codex review finding on PR #3666. The changeset's `pr: 3662` was the linked
issue (#3662), but the convention across other .changeset/*.md files is that
`pr:` carries the PR number. Verified by spot-checking other changesets
(2937 → pr: 3515, 3298 → pr: 3306, 3541 → pr: 3547 — all PR numbers).
Updated to `pr: 3666`. The body text still references the issue.

* surface: reject comma-only baseProfile + warn on wrong-typed optional fields

Three Gemini review findings on PR #3666 — all spirit-of-#3662 edge cases:

1. Comma-only baseProfile bypasses validation. readSurface used to accept
   baseProfile: ", ," because trim() returned "," (non-empty). Downstream
   resolveProfile() would split-and-filter to [] and silently fall back to
   'full'. Added effectiveProfileModes() helper that splits, trims, filters
   empty — both readSurface and writeSurface now reject when the result is
   empty. Same silent-degradation symptom as the original bug.

2. Wrong-typed optional fields silently coerced. readSurface used to coerce
   {disabledClusters: 42} to {disabledClusters: []} with no diagnostic.
   Now warns via mistypedOptionalFields() before normalizeSurfaceState()
   does the coercion, in both reader and writer.

3. Missing test coverage for the EACCES branch in readSurface. Added a
   chmod-000 unreadable-file test, skipped on Windows and root accounts
   (mode bits are ignored on those platforms).

48/48 surface tests pass. Final codex pass returned LGTM on the prior
state; these three additions strengthen the same lenient/loud contract.

Refs #3662
2026-05-18 00:14:07 -04:00
Tom Boucher
7e6ba56985 fix(3678): executor must respect commit_docs:false; teach SDK skip envelope (#3679)
* fix(3678): executor must respect commit_docs:false; teach SDK skip envelope

Closes #3678

When `commit_docs: false` in `.planning/config.json`, the SDK's
`cmdCommit` correctly short-circuits and returns
`{committed: false, hash: null, reason: 'skipped_commit_docs_false'}`
without staging or committing anything. The agent prompt at
`agents/gsd-executor.md:710-720` (final_commit block) tells the executor
to call `gsd-sdk query commit "docs(...)" --files .planning/...` but says
NOTHING about how to interpret a skipped return. With no explicit
instruction, the LLM improvises raw `git add` / `git add -f` / `git commit`
to "fulfill" the per-plan commit step it was told to make, which leaks
gitignored `.planning/` artifacts into the user's git history (exactly
what the reporter observed).

Three coordinated fixes:

1. **agents/gsd-executor.md final_commit block** — adds explicit handling
   text for all three SDK return envelopes (`committed:true`, `skipped:true
   commit_docs`, `skipped:true gitignored`, `committed:false other reasons`).
   States plainly: "Do not fall back to raw `git add` / `git commit` /
   `git add -f` when the SDK returns `skipped: true`."

2. **get-shit-done/bin/lib/commands.cjs cmdCommit** — adds `skipped: true`
   to both skip-path envelopes so agents see "skipped" as a first-class
   success signal rather than inferring "no commit happened, I must
   improvise" from absent `hash` / `committed:false`. Backward-compatible:
   existing callers reading `committed` / `hash` / `reason` are unaffected.

3. **tests/bug-3678-executor-commit-docs-respect.test.cjs** — 7-test
   regression covering:
   - A1/A2: agent prompt mentions the skip envelope AND explicitly forbids
     raw-git fallback (`source-text-is-the-product` exception)
   - B1: SDK envelope carries `committed:false`, `skipped:true`, canonical
     `reason: 'skipped_commit_docs_false'` (frozen enum)
   - B2: git index empty after commit_docs:false skip (no `.planning/` staged)
   - B3: HEAD unchanged after commit_docs:false skip
   - C1/C2: structural ban on `git add -f` / `git add --force` in any agent
     or workflow body (prohibition-sentence exception preserves audit prose)

Verification:
- node --test tests/bug-3678-*: 7/7 pass
- Targeted regression (10 commit/executor-adjacent files): 135/135 pass
- Full docker suite (gsd-test-summary): 11751/11740 pass / 0 fail
  (the 11 added are this test plus a few collateral pickups)

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

* chore(changeset): add fragment for #3678 fix (Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>)

* chore(changeset): set PR number 3679 (Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>)

* fix(3678): preserve skip-aware carve-out in executor completion checklist

The new `final_commit` prose at lines 717-741 teaches the executor to
treat `skipped:true` as success and forbids raw-git fallback, but the
downstream completion checklist still contained an unconditional
"Final metadata commit made" checkbox. An LLM executor reading an
unchecked mandatory box may attempt to satisfy it via raw `git add`,
re-introducing the exact regression this PR is meant to prevent.

Update the checklist line to carve out the intentional-skip case and
add a regression test asserting the carve-out remains present.

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-05-18 00:14:03 -04:00
Tom Boucher
09ab16f9e5 fix(3683): normalize /gsd:<cmd> → /gsd-<cmd> in command, workflow, and reference bodies (#3685)
* fix(3683): normalize /gsd:<cmd> → /gsd-<cmd> in command, workflow, and reference bodies

Extends #3677's agent-body normalizer to all body text staged through
copyWithPathReplacement (commands, workflows, references). The initial
isCommand guard was structurally redundant — normalizeAgentBodyForRuntime
already self-gates on shouldNormalizeHyphenNamespaceInAgentBody(runtime),
so dropping it covers all hyphen-name runtimes (Claude / Qwen / Hermes)
without affecting colon-canonical runtimes (Gemini).

Addresses the user-visible symptom in #3683: workflows like
get-shit-done/workflows/discuss-phase.md (7 colon refs) leaked /gsd:<cmd>
markers to the model context, which the model echoed at the end of
/gsd-discuss-phase runs.

Source-prose drift caught by the new cross-reference invariant test:

  - commands/gsd/plan-phase.md: removed a slash-form mention of the
    deleted /gsd-research-phase command (#3042)
  - commands/gsd/profile-user.md: replaced a slash-form artifact
    reference with a backticked bare name (the referenced item is a
    skill config, not a user-callable slash command)

Tests:

  - tests/bug-3683-command-colon-namespace-leak.test.cjs — runtime-form
    regression for commands/gsd/*.md staging
  - tests/bug-3683-command-cross-reference-invariant.test.cjs — locks
    cross-reference coherence: every /gsd-X / /gsd:X reference in a
    command body must resolve to commands/gsd/X.md (so a future rename
    forces every cross-reference to update)
  - tests/bug-3683-workflow-colon-namespace-leak.test.cjs — runtime-form
    regression for workflows + references; negative test for gemini
    asserting the colon form is preserved

Fixes #3683

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

* docs(changeset): remove undefined cycle reference

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:13:59 -04:00
Tom Boucher
80d6306e99 fix(3677): normalize /gsd:<cmd> → /gsd-<cmd> in agent bodies for hyphen-name runtimes (#3680)
* fix(3677): normalize /gsd:<cmd> → /gsd-<cmd> in agent bodies for hyphen-name runtimes

Closes #3677

The executor agent bodies installed to `~/.claude/agents/gsd-*.md` (and
the Qwen / Hermes equivalents) still contained retired `/gsd:<cmd>`
colon-form references in their prose. Every GSD skill / agent has
registered under the canonical hyphen `name:` form since #2808, so the
colon form is unroutable — Claude Code rejects it with `Unknown command:
/gsd:execute-phase. Did you mean /gsd-execute-phase?`. Reporter measured
~28 agent files / ~96 leaked refs on a full Claude global install.

This is the agent-body surface of the same class of bug as the two
already-fixed sibling surfaces:

- #3583 (SKILL.md skill bodies) — fixed via #3629
- #3584 (user-facing runtime "Next step: /gsd:…" emissions) — fixed via #3606

The agent-body surface in `bin/install.js`'s agent install loop was
never covered: the Claude-default / Qwen / Hermes branches register
hyphen `name:` but copy bodies verbatim (Qwen/Hermes do branding-only
swaps; Claude-default falls through with no body conversion at all), so
the colon refs leak.

Fix:

1. Add a pure predicate `shouldNormalizeHyphenNamespaceInAgentBody(runtime)`
   backed by an explicit allow-list `HYPHEN_NAME_AGENT_RUNTIMES =
   {claude, qwen, hermes}`. Unknown / future runtimes default to false
   (better to leak than to mangle).

2. Add `normalizeAgentBodyForRuntime(content, runtime, cmdNames)` that
   conditionally applies the shared `transformContentToHyphen` from
   `scripts/fix-slash-commands.cjs` (same transform #3629 used for
   SKILL.md bodies).

3. Call `normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames())`
   in the agent install loop right before `fs.writeFileSync`, so it
   composes with all the existing runtime branches. For Gemini and
   self-converting runtimes the predicate short-circuits, so their
   convertClaudeAgentToXAgent output is not re-rewritten.

4. Export both functions from `bin/install.js` for the regression test.

Regression test (`tests/bug-3677-agent-colon-namespace-leak.test.cjs`):
24 tests across 4 groups — A (exports exist), B (predicate matrix
covering all 15 runtimes in the layout table + an unknown-runtime case),
C (normalize helper applies/skips correctly for claude/qwen/hermes/
gemini/copilot), D (sanity check of the underlying transform).

Verification:
- node --test tests/bug-3677-*: 24/24 pass
- Sibling-regression (6 slash-namespace test files): 76/76 pass
- All install-minimal-all-runtimes suites: 54/54 pass after `npm run
  build:sdk` (the prior 27 fails were pre-existing — missing local
  sdk/dist build, not introduced by this change)
- Full docker suite (gsd-test-summary): 11769/0 fail
  (11751 baseline + 18 new = my 24 tests with some collateral pickups)

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

* chore(changeset): set PR number 3680 (Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>)

* test(3677): port real-source efficacy + idempotence tests from #3681

Adds describe group E with 5 behavioral tests credited to John Turner
(johnzilla, PR #3681 — closed in favor of this PR by its author):

  E0: command roster is populated and includes symptom commands
  E1: every agents/gsd-*.md transforms clean — real-source efficacy
  E2: idempotent — repeat transform on hyphenated input is a no-op
  E3: word boundary — /gsd:plan-phase-extra is not a roster match
  E4: rewrites bare gsd:<cmd> shorthand (no leading slash)

E1 is the test that would have caught the original bug — pure-function
tests can pass while the install.js wiring silently bypasses the
transform. E2 guards against double-rewrite mangling during reinstall.

29/29 tests pass (24 original + 5 ported).

Co-Authored-By: John Turner <johnzilla@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: John Turner <johnzilla@users.noreply.github.com>
2026-05-17 19:31:31 -04:00
Tom Boucher
f970c09bf3 refactor(3664): migrate bin/install.js install/uninstall to Runtime Artifact Layout Module (#3674)
Phase 2 of #3660 / ADR-3660. Routes both lifecycle verbs through the
Runtime Artifact Layout Module landed in Phase 1 (#3663):

- Add installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)
  and uninstallRuntimeArtifacts(runtime, configDir, scope) as the public
  orchestrators. Both pre-prune stale gsd-* entries before staged copy;
  installRuntimeArtifacts brackets the prune+copy with preserveUserArtifacts /
  restoreUserArtifacts so user-owned content (e.g. gsd-dev-preferences) survives
  wipe-and-replace for claude/qwen/hermes runtimes.
- Add applyRuntimeContentRewritesInPlace as the per-runtime path/branding
  post-stage step (preserves byte-output equivalence with the legacy
  copyCommandsAs* pipeline, including Qwen/Hermes branding rewrites).
- Add _copyStaged, _removeGsdEntries kind-aware filesystem helpers.
- Add _runLegacyInstallMigrations, _runLegacyUninstallCleanup as thin
  dispatchers over existing ADR-0008 legacy migrations (Hermes flat->nested
  per #2841, dev-preferences-as-skill per #2973). For Hermes, also clean up
  the intermediate skills/gsd/gsd-*/ layout that pre-Phase-2 installs left
  on disk.
- Delete the 9 copyCommandsAs*Skills functions (Codex / Cursor / Windsurf /
  Trae / CodeBuddy / Copilot / Claude / Antigravity / Augment) and the
  _copyCommandsAsSkillsViaConverter helper. All test entry points migrated
  to call installRuntimeArtifacts directly through the unified seam.
- Collapse the 9-branch uninstall ladder to one uninstallRuntimeArtifacts
  call plus preserved non-layout side-effects (Codex TOML, Copilot
  instructions, hooks).
- Unify install dispatcher: a single _isSkillsRuntime gate routes all 11
  skills runtimes through installRuntimeArtifacts for both full and core/
  minimal profiles. Removes 11 per-runtime if-else branches (3 minimal-mode
  shim branches + 8 dead after-the-gate branches).

Net delta on bin/install.js: 11,495 -> 11,174 (-321 LOC).

New tests:
- tests/install-uninstall-layout-loop.test.cjs (34 tests) - per-runtime
  fixture assertions on install/uninstall/legacy-migration ordering.
- tests/install-hermes-regressions.test.cjs (6 tests) - covers the six
  defects surfaced by iterative review: Hermes upgrade leaves stale dirs,
  --hermes --profile=core fall-through, --qwen --profile=core fall-through,
  minimal-mode dev-preferences migration skipped (Hermes/Qwen/Claude-global),
  and ordering bug in _runLegacyInstallMigrations.

Existing tests (10,038 prior + 40 new) all green: 10,078/10,078 pass.

Refs #3664

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:00:59 -04:00
Tom Boucher
812f9258e4 fix(3597): pin shell:bash on npm ci/build steps in test.yml + regression test (#3673)
After PR #3649 (merge 40a442b2), test (windows-latest, 24) failed at npm ci with zero stdout/stderr (16s opaque exit). Identical code passed on the PR. Root cause: the npm ci and npm run build:sdk steps in .github/workflows/test.yml (jobs test and coverage) lacked an effective shell — neither step-level nor via defaults.run.shell. On windows-latest, Actions defaults to pwsh; npm.cmd → node.exe → npm-cli.js child-process chain under pwsh can swallow stderr.

Pin shell: bash on the 4 outlier steps. Add tests/workflow-shell-pinning.test.cjs that scans every workflow file referencing windows-latest, computes effective shell with proper workflow- and job-level defaults.run.shell inheritance, and fails CI if any run: npm|npx … step lacks an effective shell.

Closes #3672
2026-05-17 11:16:54 -04:00
Tom Boucher
40a442b21f Merge pull request #3649 from gsd-build/feat/3597-split-suites-node-matrix
feat(3597): split test suites and add Node 22/24/26 OS matrix
2026-05-17 01:51:25 -04:00
Tom Boucher
b8fa89b5b6 fix(3663): address CodeRabbit surface/layout follow-ups 2026-05-17 01:39:10 -04:00
Tom Boucher
06cf085bfb fix(3663): Hermes empty-prefix uses manifest membership for stale-skill prune (Codex P1-2)
Replace the blunt kindPrefix !== '' guard in _syncGsdDir with a
manifest-membership discriminator. For non-empty prefix runtimes, behavior
is unchanged (prefix match). For Hermes (empty prefix), a directory is
GSD-owned iff its stem appears in the canonical manifest; only those dirs
are removal candidates when absent from the staged set. Dirs not in the
manifest (user-owned) are preserved unconditionally. When no manifest is
provided (legacy callers), the removal pass is skipped (conservative fallback).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
043cfd97b3 fix(3663): applySurface creates missing dest dirs (Codex P1-1)
Remove the fs.existsSync(dest) guard in applySurface so _syncGsdDir is
always called. _syncGsdDir already does mkdirSync(..., { recursive: true })
so the destination is created when absent — recovering partially-initialized
or user-deleted runtime config dirs. Also threads manifest through to
_syncGsdDir as optional 4th arg (used by P1-2 Hermes fix).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
925908039c refactor(3663): replace try/finally with t.after() in runtime-artifact-layout-stage tests
Remove the 2 empty try/finally wrappers (finally bodies contained only
comments, no cleanup actions). Inline the assertions directly; add a
comment noting stagedDir lifecycle ownership. No behavior change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
f929e4a7bd refactor(3663): replace try/finally with t.after() in surface-apply tests
Replace all 6 try/finally blocks with t.after() per-test cleanup hooks.
Import createTempDir/cleanup from tests/helpers.cjs; use createTempDir
inside createFixtureRuntime (replaces inline tmpDir helper). Remove os
import (now unused).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
51776b15c3 refactor(3663): replace try/finally with t.after() in install-profiles-stage tests
Replace all 13 try/finally blocks with t.after() per-test cleanup hooks.
Import createTempDir/cleanup from tests/helpers.cjs; use createTempDir
inside createFixtureSkillsDir and createFixtureAgentsDir. Remove os import
(now unused at top level).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
8c16b1d338 fix(3663): preserve Hermes user-skill dirs under skills/gsd/ namespace
When kindPrefix === '' (Hermes: destSubpath=skills/gsd, no per-skill prefix),
startsWith('') always returns true so the prior removal loop would delete any
dir not in the staged set — including user-owned skill dirs.  Guard the entire
removal block behind kindPrefix !== '' so non-staged dirs are never pruned when
there is no prefix to distinguish GSD-owned from user-owned entries.

TDD: failing test added first asserting user-custom-skill is preserved through
a _syncGsdDir call with kindPrefix=''.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
4d578394eb refactor(3663): migrate applySurface to layout-driven loop; add skills kind to _syncGsdDir
- applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap) → applySurface(runtimeConfigDir, layout, manifest, clusterMap)
- _syncGsdDir extended to handle skills kind (dirs not files, prefix-gated removal)
- _findInstallSource/_findAgentsSource deleted; listSurface now uses findInstallSourceRoot() from runtime-artifact-layout.cjs
- findInstallSourceRoot exported from runtime-artifact-layout.cjs
- test(3663): update surface-apply.test.cjs to new layout-passing shape + add skills kind test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
a48683e93e test(3663): add stage invocation tests for commands, agents, skills kinds
Tests verify that kind.stage(resolvedProfile) produces correct directory
structure: .md files for commands, agent .md files for agents, and
gsd-<stem>/SKILL.md dirs for skills kinds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
8239fe0567 test(3663): add edge-case tests for runtime-artifact-layout
Covers hermes nested subpath, cline empty kinds, gemini commands kind,
claude scope variants, grok/unknown runtime TypeError, empty configDir,
and bad scope validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
dc2a6278e5 feat(3663): add runtime-artifact-layout.cjs + resolve tests for all 15 runtimes
- New module: get-shit-done/bin/lib/runtime-artifact-layout.cjs
  - resolveRuntimeArtifactLayout(runtime, configDir, scope) → Layout
  - 15-runtime table (grok intentionally excluded, throws TypeError)
  - findInstallSourceRoot / findAgentsSourceRoot walk-up-only (no .gsd-source marker in Phase 1)
  - Loads bin/install.js converters via GSD_TEST_MODE guard
- Tests: 16 fixtures covering all runtimes + both claude scopes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
808b40d319 test(3663): add STAGED_DIRS registration test for stageSkillsForRuntimeAsSkills
RED-7/GREEN-7: verifies stagedDir is added to STAGED_DIRS after staging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
725dbe2441 test(3663): add missing-srcDir guard test for stageSkillsForRuntimeAsSkills
RED-6/GREEN-6: non-existent srcCommandsDir returns path unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
82111a9e5e test(3663): add empty-prefix test for stageSkillsForRuntimeAsSkills
RED-5/GREEN-5: empty prefix produces <stem>/SKILL.md (Hermes case).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
6e0d3e8bd1 test(3663): add converter invocation test for stageSkillsForRuntimeAsSkills
RED-4/GREEN-4: verifies converter called with (content, skillName) per kept skill.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
151e4aa2c6 test(3663): add Set filtering test for stageSkillsForRuntimeAsSkills
RED-3/GREEN-3: filter already present from GREEN-2 implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
235f527635 feat(3663): implement stageSkillsForRuntimeAsSkills iteration+write loop
RED-2/GREEN-2: wildcard skills='*' stages all *.md as <prefix><stem>/SKILL.md
with converter applied. Includes existence guard and error cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
11e74f9a25 test(3663): add export check for stageSkillsForRuntimeAsSkills
RED-1/GREEN-1: export assertion + stub function + STAGED_DIRS export.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:12:00 -04:00
Tom Boucher
2e81610f58 test(windows): normalize EOL in generator drift assertion 2026-05-17 00:56:14 -04:00
Tom Boucher
8703ab0948 test(windows): add rmSync retries in security prompt teardown 2026-05-17 00:46:59 -04:00
Tom Boucher
e7f7465778 Merge remote-tracking branch 'origin/main' into fix/pr3649-review 2026-05-17 00:45:09 -04:00
Tom Boucher
835dd6ab44 test(3596): adversarial security/prompt-injection abuse suite (#3654)
* test(3596): adversarial security/prompt-injection abuse suite

Adds `tests/security-prompt-injection.test.cjs` and a fixtures
directory at `tests/fixtures/adversarial/security/` covering the
attack classes enumerated in #3596:

  - Command substitution / backticks / heredoc payloads in workstream
    names — sentinel-file probes prove no shell is spawned, slugifier
    neutralises the input.
  - Path traversal through `--ws` and slash-bearing workstream names —
    rejected with structured `--json-errors` payload, no stack trace,
    no filesystem mutation outside the project root.
  - Fake `<system>` / `[SYSTEM]` / `<<SYS>>` / `[INST]` boundary tags —
    sanitizeForPrompt neutralises every form; structural negative
    property locked across all six styles in one place.
  - Zero-width / bidi-override codepoints — stripped per the documented
    codepoint set; asserted via codePoint inspection, not regex
    literals.
  - Hostile read of CONTEXT.md / PLAN.md / ROADMAP.md fixtures —
    `gsd-read-injection-scanner.js` surfaces the advisory; excluded
    paths and non-Read tools stay silent; malformed JSON does not
    crash the hook.
  - Hostile write of `.planning/` files — `gsd-prompt-guard.js` emits
    a `PreToolUse` advisory; non-Write/Edit tools stay silent.
  - Fake `ghp_*` / `sk-*` env tokens — never echoed in CLI stdout or
    stderr under hostile inputs; covered under
    `// allow-test-rule: structural-regression-guard` because the only
    way to assert byte-level absence is `.includes(token)` against the
    captured streams.
  - `validatePath`, `validateShellArg`, `validatePhaseNumber`,
    `validateFieldName` — focused negative-input contract pins.

Pinned behavior gaps (documented, NOT fixed in this PR):

  - `<instructions>` is intentionally whitelisted by both the scanner
    and the sanitiser (GSD's own prompt scaffolding). Two REGRESSION
    GUARD tests lock that contract.
  - The current `scanForInjection` does NOT flag malicious markdown
    links (javascript:/data:/embedded-credentials URLs). PINNED with
    negative-proof so any future scope extension fails the assertion
    and forces a deliberate update to the acceptance map.
  - `prompt-builder.ts` does not yet wrap plan/context markdown in an
    "untrusted data" envelope. That seam lives on the TS side and is
    covered by `sdk/src/prompt-builder.test.ts`; out of scope for a
    CJS test file. Mentioned in the file header.

Verification:

  - `node --test tests/security-prompt-injection.test.cjs` → 73 tests
    pass.
  - `node scripts/lint-no-source-grep.cjs` → 0 violations across
    546 test files (one `allow-test-rule: structural-regression-guard`
    annotation on this file for the token-absence assertions).
  - `node scripts/run-tests.cjs` → 9730 tests pass, 0 fail.

Refs #3596

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

* fix(3596): allow adversarial fixtures in scan + harden graphify status parse

* fix(3596): skip adversarial security fixtures in secret scan

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:34:53 -04:00
Tom Boucher
4956e5e5c6 test(3598): generator correctness, parity, and atomicity (#3656)
Adds tests/feat-3598-generator-correctness.test.cjs covering the
gaps the existing generator/parity test surface does not exercise:

  Suite 1 — Stale-but-timestamp-valid detection: for each of the 9
  generators that export a build*Cjs() function, asserts the fresh
  in-memory output is byte-equal to the committed .generated.cjs.
  Catches manual edits and partial-write drift that timestamp-only
  freshness checks miss.

  Suite 2 — Determinism: calls each build*Cjs() twice and asserts the
  outputs are identical. Catches time/random/iteration-order regressions.

  Suite 3 — Runtime/SDK alias parity: asserts canonical command names
  and alias sets are identical between
  get-shit-done/bin/lib/command-aliases.generated.cjs (runtime) and
  sdk/src/query/command-aliases.generated.ts (SDK). Beyond timestamp
  freshness.

  Suite 4 — No duplicate aliases in the live registry: behavioral
  equivalent of the issue's example test. The generator has no
  fixture/--source seam (it reads in-memory COMMAND_DEFINITIONS_BY_FAMILY),
  so the structural invariant is asserted on the deployed surface; a
  collision fails with both colliding canonicals named.

  Suite 5 — build-hooks.js atomicity: runs the build twice, snapshots
  hooks/dist/ each time, asserts byte-identical output. Also asserts
  no orphaned .dist-staging-* sibling directories remain and that every
  .js file shipped to dist parses (positive proof of the vm.Script
  syntax guard).

26 tests pass on macOS / Node 24.

Closes #3598
2026-05-17 00:34:50 -04:00
Tom Boucher
3c51e760f8 fix(windows): harden nested-root and archive-warning assertions 2026-05-17 00:17:31 -04:00
Tom Boucher
5ee2a148ca fix(windows): stabilize init/workflow tests across path and npm edge cases 2026-05-16 14:00:34 -04:00
Tom Boucher
02d3cf3033 fix(3597): add Windows-safe rmSync retry budgets in lint/security tests 2026-05-16 13:33:24 -04:00
Tom Boucher
1a1ad2ec87 Merge origin/main into feat/3597-split-suites-node-matrix
Resolves conflict in .github/workflows/test.yml: keep the 6 new drift-check
steps from main (plan-scan, secrets, schema-detect, decisions,
workstream-name-policy, Shared Module hand-sync) before the split-lane test
runs from this PR. PR's dedicated `coverage` job replaces main's per-matrix
`Run tests with coverage` step.

Other conflicting files (CONTRIBUTING.md, get-shit-done/bin/lib/init.cjs,
package.json) auto-merged cleanly. Changeset files (.changeset/*) brought in
from main as adds.
2026-05-16 13:22:07 -04:00
Tom Boucher
32dea2034b fix(3597): bug-1974 windows EPERM — retry rmSync on tmpDir teardown
Test spawns a fire-and-forget subprocess; on Windows the child may still
hold a handle on tmpDir when afterEach runs, producing EPERM from
fs.rmSync. Match helpers.cleanup() retry budget (20 × 250ms = 5s) to
absorb the deferred-handle window without pulling in the helper (this
suite predates it).

Closes the Windows test (windows-latest, 24/26) lane failure on #3649.
2026-05-16 13:18:34 -04:00
Tom Boucher
ae63cbe557 feat(3575): Phase 6 — CJS↔SDK seam migration end-to-end complete (#3524) (#3577)
* feat(3575): Phase 6 enforcement hardening + retrospective (#3524 feature-complete)

Phase 6 of the CJS↔SDK hard-seam migration (parent #3524). Final
phase per the PRD. After this lands the migration is feature-complete:
shared Modules from Phases 1-4 are in place, the runtime-bridge
primitive from Phase 5.0 is wired with the state.* family proof in
Phase 5.1 (PR #3574), and Phase 6 hardens the seam against future
drift via lint, CODEOWNERS, and retrospective documentation.

## What landed

- scripts/lint-shared-module-handsync.cjs (274 lines) — the
  drift-prevention gate. Scans bin/lib/*.cjs and looks for same-named
  sdk/src/<name>.ts or sdk/src/query/<name>.ts (excluding generated
  artifacts). Pairs not on the allowlist fail the lint with a clear
  message: either add to allowlist with justification, or migrate to
  a shared Module. Supports --root, --allowlist, --cjs-dir, --sdk-src,
  --warn-all flags for testability.
- scripts/shared-module-handsync-allowlist.json (148 lines) — two
  categories:
  - cooperatingSiblings (14 pairs) — legitimate Readers/Adapters
    that consume shared Modules or run structurally-different
    runtime paths.
  - migrateMeBacklog (8 pairs) — known drift anti-patterns that ARE
    on main today (config, decisions, intel, model-catalog, plan-scan,
    schema-detect, secrets, workstream-name-policy). Lint warns but
    does not fail on these; documented in the retrospective as
    candidate Shared Module migrations.
- tests/lint-shared-module-handsync.test.cjs (285 lines, 11 cases)
  — proves the lint catches new drift, honors the allowlist, and
  exits 0 on the current tree.
- .github/workflows/test.yml — new "Shared Module hand-sync drift
  check" step after the freshness checks.
- .github/CODEOWNERS — appended 11 architecture-owned path rules for
  source-of-truth files (Shared Module dirs, manifest JSONs, runtime
  bridge, lint script, allowlist). Existing blanket rule preserved.
- docs/agents/cjs-sdk-seam.md (280 lines) — full retrospective +
  guide:
  - Migration overview table linking Phases 1-6 with PR numbers.
  - 15 historical drift bugs (#1535 ... #3523) each mapped to the
    Phase 6 enforcement layer that would have blocked them.
  - "Guide: Adding a new Shared Module" — step-by-step using Phase 1
    (state-document) as the worked example.
  - "Guide: Adding a new canonical command" — step-by-step using
    Phase 5.1 (state.update) as the worked example.
  - "Open follow-ups" listing the 8 MIGRATE_ME pairs, per-family
    Phase 5.2+ candidates pending maintainer authorization, sync
    bridge workstream support, and Phase 5.1's parity divergences.
- CONTRIBUTING.md — short cross-reference paragraph in the
  Architecture & Domain Standards section.

## Audit findings

All 5 freshness checks from Phases 0-4 are already wired in CI:
command-aliases, state-document, configuration,
workstream-inventory-builder, project-root. Phase 6 adds the 6th
(hand-sync drift check) for total enforcement coverage.

## Numbers

- Full CJS suite: 9335/9335 pass (baseline 9323 + 11 new lint
  tests + 1 cooperating).
- Lint passes on current tree: 14 cooperating siblings + 8 backlog
  pairs accounted for, 0 unauthorized drift pairs.
- Lint exits 1 (fails CI) on an intentional new hand-synced pair
  added to a fixture — verified by the test suite.

Closes #3575. Closes the structural drift surface of #3524.

* chore(3577): add changeset fragment for Phase 6

* feat(3575): Phase 6 end-to-end completion — CJS↔SDK seam migration done

Per maintainer correction: Phase 6 is THE final phase and must
complete the migration end-to-end. This commit absorbs Phase 5.1's
work (state.* router + worker fix), finishes the remaining per-family
router migrations, completes all five resolvable Shared Module
extractions, resolves the parity divergences, lands native workstream
support in the sync bridge, and ships the lint + CODEOWNERS +
retrospective from the original Phase 6 scope.

After this commit the CJS↔SDK seam migration started in #3524 is
feature-complete. No follow-up "Phase 5.x" or "Phase 7" should be
needed — the only documented carve-outs are three pairs that
intentionally cannot be migrated (config CLI handlers, intel async
wrapper, model-catalog already on the shared-JSON pattern).

Cherry-picked state.* from Phase 5.1 (PR #3574 absorbed). Migrated
verify.*, init.*, phase.*, phases.*, validate.*, roadmap.* via the
same executeForCjs delegation pattern. Migrated the inline
gsd-tools.cjs cases for frontmatter.*, config-* CLI, and non-family
commands (generate-slug, current-timestamp, find-phase, docs-init)
with shared _dispatchNonFamily helper + _tryLoadSdkBridge loader.

CJS-native carve-outs documented: config-path, migrate-config,
detect-custom-files (no SDK counterpart yet); state.complete-phase
(no SDK counterpart yet); validate.context (CJS-only inline logic
with no clean SDK port); phases.archive (SDK-only).

- plan-scan (Module-via-generator from sdk/src/query/plan-scan.ts)
- secrets (Module-via-generator)
- schema-detect (Module-via-generator)
- decisions (Module-via-generator; SDK regex aligned to CJS
  alphanumeric IDs to preserve project compatibility)
- workstream-name-policy (Module-via-generator; SDK extended with
  hasInvalidPathSegment and isValidActiveWorkstreamName that CJS
  callers depend on)

Each ships with: SDK source-of-truth, generator at
sdk/scripts/gen-<name>.mjs, freshness check at
sdk/scripts/check-<name>-fresh.mjs, parity test at
tests/<name>-generator.test.cjs, CJS shim at
get-shit-done/bin/lib/<name>.cjs, scripts in sdk and root
package.json, pre-commit drift block, CI workflow step, CODEOWNERS
rule, INVENTORY.md row.

- config (config.cjs vs sdk/src/config.ts) — CJS file is CLI-handler
  surface (cmdConfigGet/Set/etc.); SDK file is loadConfig wrapper
  (already migrated in Phase 2). Zero logical overlap. Classified
  as CJS-CLI-ONLY in the allowlist.
- intel (intel.cjs vs sdk/src/query/intel.ts) — SDK is the async
  QueryHandler wrapper of the CJS module; intentional split per the
  SDK file's own docstring. Classified as cooperating-sibling.
- model-catalog (model-catalog.cjs vs sdk/src/model-catalog.ts) —
  both already consume sdk/shared/model-catalog.json (ADR-0003).
  No constants duplicated. Classified as ADAPTER-OVER-MODULE.

- state.record-metric: SDK aligned to CJS auto-create of
  ## Performance Metrics section when absent. Parity assertion now
  exact equality.
- state.prune: SDK aligned to CJS disk-based phase counting via
  stateExtractField. Parity assertion now exact equality. SDK unit
  tests updated to match.

GSDTransport.shouldUseNative no longer forces subprocess when
request.workstream is set — the Phase 5.0 worker fix already threaded
workstream through dispatchNative + registry.dispatch, making the
subprocess force unnecessary. state-command-router.cjs's workstream
fallback guard removed. cjs-sdk-seam.md and the regression test
updated to document the resolution.

Unchanged from the previous commit on this branch. The lint now
reports 22 cooperating siblings, 0 backlog pairs. The retrospective
section "Open follow-ups" is reduced to the three intentional
carve-outs above; the four stale subsections (8 MIGRATE_ME pairs,
per-family Phase 5.x candidates, workstream support, parity
divergences) are gone because they're all resolved in this commit.

- Full CJS suite: 9441/9441 pass (baseline pre-Phase-6 was 9323;
  +118 from the Phase 6 work — 11 lint tests + 12 state-router
  parity + 6 verify parity + 3 phase parity + 1 roadmap parity +
  24 plan-scan parity + 20 secrets parity + 18 schema-detect parity
  + 15 decisions parity + 19 workstream-name-policy parity).
- SDK vitest unit: 1863/1863 pass.
- Hand-sync lint: 22 cooperating siblings, 0 backlog pairs.
- All freshness checks: fresh.

Closes #3575. Closes the migration the CJS↔SDK seam was designed
to eliminate (#3524).

* fix(3575): lint-shared-module-handsync emits typed JSON; tests assert on IR

The lint-no-source-grep CI step rejected the original Phase 6 test
file (tests/lint-shared-module-handsync.test.cjs) because it
substring-matched on .stdout/.stderr from the lint script output —
prohibited per CONTRIBUTING.md "Raw Text Matching on Test Outputs".
Fix: add --json mode to the production lint script and assert on
typed IR fields.

## Changes

scripts/lint-shared-module-handsync.cjs:
- New --json flag. When set:
  - Success: emits { ok: true, cooperatingCount, backlogCount, warnings }
  - Unauthorized pairs: emits { ok: false, reason: 'unauthorized_pairs',
    errors: [{ relCjs, tsPaths }], warnings, cooperatingCount }
  - Missing CJS/SDK dir: emits { ok: false, reason: 'cjs_dir_missing'
    | 'sdk_src_missing', path }
- Default (human-readable) output unchanged.
- Warnings section is suppressed in --json mode (still surfaced in the
  IR's `warnings` field for tests to inspect).

tests/lint-shared-module-handsync.test.cjs:
- runLintJson() helper replaces runLint(), invoking the script with
  --json and parsing the IR.
- Every assertion now reads typed fields (payload.ok, payload.reason,
  payload.errors, payload.warnings, payload.cooperatingCount) instead
  of substring-matching stdout/stderr.
- Test count unchanged at 9 cases across 3 describe blocks.
- All pass.

## Verification

- node scripts/lint-no-source-grep.cjs → exit 0, 529 test files
  checked, 0 violations (was: 1 violation in this test file).
- node --test tests/lint-shared-module-handsync.test.cjs → 9/9 pass.
- node scripts/lint-shared-module-handsync.cjs → unchanged
  human-readable output, 22 cooperating siblings, 0 backlog pairs.
- node scripts/run-tests.cjs → 9449/9449 pass.

Addresses CI failure on PR #3577 (Phase 6 of #3524).

* fix(3575): address CodeRabbit review on PR #3577

Six findings resolved:

1. scripts/lint-shared-module-handsync.cjs — allowlist matching now
   pair-aware. Keys composite ${cjs}::${ts} instead of cjs-only, so
   an entry covering one (cjs, ts) pair no longer silently passes a
   sibling at a different ts path with the same module name.
   Header doc-comment also corrected: removed the stale claim about
   GSD_LINT_CHANGED_FILES filtering (no such code existed).

2. sdk/src/gsd-transport.ts — removed dead 'workstream_forced' member
   from the TransportDecision.reason union (no longer assigned after
   Phase 5.0 workstream-native refactor).

3. sdk/src/gsd-transport.ts — removed stale workstream interpolation
   from the subprocess-reason Error message; the field is no longer
   load-bearing for that decision path.

4. All eight generator scripts (sdk/scripts/gen-*.mjs and
   gen-state-document.ts) — replaced the manual entry-point check
   that used `new URL(process.argv[1], 'file://')`. On Windows that
   misparses `C:\…\gen-*.mjs` as scheme "c:" and breaks the check.
   Replaced with the cross-platform-safe direct comparison
   `fileURLToPath(import.meta.url) === process.argv[1]`. (Not using
   `import.meta.main` — that's only stable in Node 24+ and the
   project supports Node 22+.)

5. docs/agents/cjs-sdk-seam.md — added explicit `text` language
   specifier to the four file-path fenced blocks (lines 157, 165,
   173, 181). Closing fences correctly remain bare.

Verification

- node scripts/lint-no-source-grep.cjs → 0 violations
- node scripts/lint-shared-module-handsync.cjs → 22 cooperating
  siblings, 0 backlog (counts unchanged after pair-aware refactor)
- node scripts/lint-shared-module-handsync.cjs --json → typed IR
  unchanged
- All 9 generator freshness checks → fresh
- node scripts/run-tests.cjs → 9449/9449 pass
- sdk vitest src/gsd-transport.test.ts → 10/10 pass

Tests for pair-aware matching: the existing 9 cases in
tests/lint-shared-module-handsync.test.cjs already build fixture
allowlist entries with both `cjs` and `ts` fields, so they
implicitly exercise the new pair-aware lookup; all 9 pass.

* fix(3575): address second CodeRabbit review on PR #3577

Five new findings resolved.

1. Shared SDK bridge loader (`get-shit-done/bin/lib/cjs-sdk-bridge.cjs`)
   Eliminates seven-fold duplication of `tryLoadSdk` / `_executeForCjs`
   that lived verbatim in every `*-command-router.cjs` plus a near-identical
   variant in `gsd-tools.cjs`. The new module exposes `tryLoadSdk()`,
   `getExecuteForCjs()`, and `getSdkModule()` (the last for routers that
   pull additional named exports, e.g. state's `formatStateLoadRawStdout`).
   All eight call sites refactored to consume it. As a side benefit
   `gsd-tools.cjs` no longer imports from the private
   `@gsd-build/sdk/dist/runtime-bridge-sync/index.js` subpath; everyone now
   uses the public package entry consistently.

2. `phase remove` accepts zero positional args (#3577 review)
   `phase remove --force` previously passed validation with no phase number
   and invoked `cmdPhaseRemove(cwd, undefined, ...)`. Tightened to
   `positional.length !== 1` and added the early `return` so the handler
   never receives an undefined phase id.

3. Decisions parser regex hardened (#3577 review)
   `D-[A-Za-z0-9_-]+` allowed malformed IDs like `D--foo` and `D-_bar`.
   Tightened to `D-[A-Za-z0-9][A-Za-z0-9_-]*` so the first character after
   `D-` must be alphanumeric; internal `_`/`-` still permitted.
   Decisions generated CJS mirror regenerated.

4. plan-scan-generator test no longer uses hardcoded `/tmp` paths
   `/tmp/__gsd_test_nonexistent_dir_xyz__` and
   `/tmp/__nonexistent_gsd_test__` could collide with prior runs on shared
   CI runners. Replaced with `uniqueMissingPath()` helper that synthesizes
   `os.tmpdir()/<prefix>-<pid>-<ms>-<random>` and force-removes the path
   before returning.

5. lint-shared-module-handsync test now validates pair-aware TS matching
   Added `rejects pair when TS path differs from allowlist entry` — a
   regression guard that creates an on-disk pair at `sdk/src/query/<name>.ts`
   but allowlists the (cjs, sdk/src/<name>.ts) shape. The lint must reject
   because the (cjs, ts) tuple does not match. Demonstrates the pair-aware
   matching added in the previous commit and locks it in.

## Wiring

`cjs-sdk-bridge.cjs` added to `docs/INVENTORY.md` (count 68→69) and
`docs/INVENTORY-MANIFEST.json` regenerated.

## Verification

- node scripts/lint-no-source-grep.cjs → 0 violations (529 files)
- node scripts/lint-shared-module-handsync.cjs → 22 cooperating, 0 backlog
- node scripts/run-tests.cjs → 9452/9452 pass (was 9449 + 1 lint-test + 1
  changed plan-scan path test)
- node sdk/scripts/check-decisions-fresh.mjs → fresh
- sdk vitest src/query/decisions.test.ts → 15/15 pass

* docs(3575): correct PR/issue refs in cjs-sdk-seam.md

CodeRabbit caught two stale references that conflated the issue
number (#3575) with the PR number (#3577). Phase 6 ships as PR
#3577 closing issue #3575. Migration overview table row and the
Final Completion Summary updated accordingly.

* fix(3575): cjs-sdk-bridge actually loads the SDK (was dead-code since Phase 5.0)

## The bug

`cjs-sdk-bridge.cjs:tryLoadSdk()` resolved `require('@gsd-build/sdk')`,
but that package name is not installed in the root `node_modules`
(the SDK lives as `./sdk/` — a sibling workspace, not a dependency)
and the SDK's public entry doesn't re-export `executeForCjs` or
`formatStateLoadRawStdout` anyway. `tryLoadSdk()` always returned
false, the `_loadFailed = true` cache made every subsequent call
return false for the lifetime of the process, and every CJS router
silently fell through to the CJS handler.

The pattern shipped in Phase 5.0 (PR #3558, merged) via
`require('@gsd-build/sdk/dist/runtime-bridge-sync/index.js')` and
was inherited into the routers via `require('@gsd-build/sdk')` in
Phase 5.1 (PR #3574, merged). Both subpaths/imports failed in the
same way. CI passed for the whole CJS↔SDK migration because the
CJS fallback handlers kept running — meaning the entire claimed
"state.* delegation" never actually executed via the SDK in any
shipped run.

This is exactly the silent-drift class the Phase 6 lint and
retrospective are supposed to prevent. Catching it here closes the
loop.

## The fix

Resolve the bundled SDK by **package-relative filesystem path**:

  <root>/sdk/dist/runtime-bridge-sync/index.js
  <root>/sdk/dist/query/state-project-load.js

The `files` array in `package.json` keeps `sdk/dist` at the same
relative location inside the published tarball, so the path works
in both dev and post-install. The two-file split is necessary
because `formatStateLoadRawStdout` lives in the state handler,
not the runtime-bridge entry.

## Integration test

`tests/cjs-sdk-bridge-integration.test.cjs` proves four things and
locks the load-success invariant so this regression cannot recur:

  1. tryLoadSdk() returns true on the current checkout
  2. getExecuteForCjs() returns a function (not null)
  3. getFormatStateLoadRawStdout() returns a function (not null)
  4. executeForCjs() actually dispatches a canonical registry
     command (generate-slug) and returns an ok:true result — proving
     real SDK execution, not a silent CJS-fallback

## State-router formatter wiring

The state command router was reaching into `getSdkModule()` to pluck
`formatStateLoadRawStdout`. Replaced with the explicit
`getFormatStateLoadRawStdout()` getter so the bridge module owns
all SDK-export resolution.

## state.load --raw output mode

While the bridge was broken, the state.load --raw test happened to
pass via CJS fallback. The first SDK execution exposed a contract
mismatch: passing `mode: 'raw'` to the bridge tells the SDK to
pre-render result.data to a JSON string, but the router was also
calling `formatStateLoadRawStdout(result.data)` to project to
key=value lines — the formatter saw a string and no-op'd.

Fix: when a CJS-side rawFormatter is supplied, the router requests
`mode: 'json'` from the bridge (always get typed data) and runs the
formatter itself. When no rawFormatter, the user's --raw flag flows
through to the bridge as usual.

## Surfaced pre-existing parity gaps (NOT yet fixed)

With the bridge now actually executing the SDK, 8 `tests/state.test.cjs`
cases reveal pre-existing CJS↔SDK behavioral drift that Phase 5.1's
"104/104 pass" report could not see because the SDK was never running:

  - `state load returns error when STATE.md missing`
  - `state get returns error when STATE.md missing`
  - `state update returns error when STATE.md missing`
  - `state update reports field not found`
  - `state patch / record-metric / update-progress /
     resolve-blocker / record-session — error when STATE.md missing`
  - `add-decision --summary-file` / `add-blocker --text-file`
    (file-input path rejected by SDK security check)

Each is a real CJS↔SDK divergence that needs explicit alignment in
the SDK handler. Listed here so the next commit can address them
honestly rather than letting the broken bridge mask them again.

* fix(3575): align SDK with CJS contract — bridge-exposed divergences

The Phase 5.1 bridge fix (0fc60b0c) made executeForCjs() actually load and
dispatch. With routers now hitting the SDK in normal layouts, six CJS↔SDK
behavioral divergences became visible. This commit aligns the SDK to match
the canonical CJS contract test-by-test.

ROUTER CHANGES (mode: raw → mode: json)
All 7 CJS routers were passing `mode: raw ? 'raw' : 'json'`. With the bridge
active, `mode: 'raw'` makes the bridge pre-render result.data to a JSON string,
which CJS output() then re-stringifies — producing a JSON string of a JSON
string. Routers now always request typed JSON; CJS output() handles user-
facing rendering. Affected: gsd-tools, init, phase, phases, roadmap, state,
validate, verify routers.

SDK STATE MUTATION HANDLERS (sdk/src/query/state-mutation.ts)
state.update / record-metric / update-progress / resolve-blocker / record-
session no longer auto-create STATE.md via readModifyWriteStateMd. CJS errors
out when STATE.md is missing; SDK now does the same via an upfront existsSync
check returning {updated: false, reason: 'STATE.md not found'}. Also fixes:

  • resolve-blocker semantic: SDK returned resolved:false when no blocker
    line matched. CJS returns resolved:true whenever the Blockers section
    exists. Aligned.
  • readTextArgOrFile path validation: rejected /var/folders paths on macOS
    because /var → /private/var is a symlink. Now resolves both base and
    target via realpathSync before the prefix check.

STATE.MD STOPPED_AT SCOPING (sdk/src/query/state.ts)
buildStateFrontmatter extracted `Stopped At` from the entire body; CJS scopes
it to the ## Session section. Bug-2444 parity restored — the field no longer
bleeds in from unrelated sections of STATE.md.

PHASE_DIR_COUNT MILESTONE FILTER (sdk/src/query/init.ts)
initNewMilestone counted every directory under phases/ regardless of which
milestone it belonged to. CJS uses getMilestonePhaseFilter to count only
current-milestone phase dirs. Bug-2445 parity restored.

ARCHIVED PHASE GUARD (sdk/src/query/init.ts)
shouldDropArchivedPhaseMatch had an extra `archivedTag === milestone.version`
escape hatch that doesn't exist in CJS. CJS unconditionally drops the
archived match when the phase appears in the current ROADMAP. Removed the
escape hatch — fixes the bug #2391 regression where `init plan-phase 03`
returned the archived v1.0 phase instead of the current ROADMAP phase.

PADDING-TOLERANT ROADMAP PHASE LOOKUP (sdk/src/query/roadmap.ts)
searchPhaseInContent used `escapeRegex(phaseNum)` as the phase-number
fragment — `03` failed to match `Phase 3:` headings. CJS uses
phaseMarkdownRegexSource which emits `0*<integer>` for padding tolerance.
Restored same helper inline in roadmap.ts. Fixes bug #2391 / #3537 parity
in zero-padded phase lookups.

STATE COMMAND ROUTER STATE.MD-MISSING ERROR SURFACE
(get-shit-done/bin/lib/state-command-router.cjs)
state.get must surface "STATE.md not found" as an error (matching CJS exit
behavior); other state mutations must surface {updated: false, reason: ...}
as data. Added EXIT_ON_STATE_MD_MISSING discriminator with STATE_MD_MISSING_
MESSAGE constant.

VERIFICATION
  • init.test.cjs        — 93/93 pass (was 91/2 fail)
  • state.test.cjs       — 104/104 pass (was 95/9 fail)
  • core.test.cjs        — pass
  • roadmap.test.cjs     — pass
  • cjs-sdk-bridge-integration.test.cjs — 4/4 pass (bridge load locked in)

The 13 phase.test.cjs failures (next-decimal 999.x backlog skip, add-batch
JSON validation, insert dry-run rejection, find-phase non-canonical
warnings) are pre-existing SDK gaps from the broken-bridge era and will be
addressed in a follow-up commit on this same PR.

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

* fix(3575): align SDK phase handlers with CJS (wave 2 — phase.test.cjs)

The bridge-fix (0fc60b0c) exposed 13 more CJS↔SDK behavioral divergences
inside the phase command family. All are now aligned to the canonical CJS
contract, with per-test verification.

phase.ts:
  • Centralised isCanonicalPlanFile / looksLikePlanFile / describeNonCanonical
    Plans helpers mirroring phase.cjs:17–52. Exported for reuse from
    phase-lifecycle.ts (phasesList) so the warning shape never drifts between
    read sites.
  • searchPhaseInDir now emits result.warning (singular) with the canonical
    message when a plan-shaped file would be skipped by the canonical filter.
    Bug #2893 parity for find-phase.
  • phasePlanIndex moved its non-canonical warning to the singular result.warning
    field (was a generic entry in result.warnings) so consumers see the same
    field name and message format as find-phase / phases-list. Other
    diagnostics (unresolved deps, wave-declaration mismatches) still flow
    through the warnings array unchanged.
  • Added PhaseInfo.warning to the type. getPhaseFileStats now also returns
    allFiles so the caller can compute the diagnostic without re-reading the
    directory.

phase-lifecycle.ts:
  • phasesList (phases list --type plans) emits per-dir prefixed warnings
    matching phase.cjs:120 (`${dir}: ${describeNonCanonicalPlans(...)}`).
  • phaseAdd now matches the CJS router contract for arg parsing:
    accepts --raw (ignored), --dry-run, --id <value>; rejects every other
    --flag with "phase add does not support <flag>"; rejects dangling
    --id with "--id requires a value"; joins all positional tokens with
    space so `phase add User Dashboard` produces description "User
    Dashboard". customId comes from --id, never from positional[1].
  • phaseInsert now mirrors phaseAdd's arg parsing: rejects --dry-run
    with "does not support --dry-run", strips --raw, joins
    positional.slice(1) for the description. Also reports the bug-3098
    placeholder error ("Phase N exists in roadmap summary but is missing
    a detail section") when the ROADMAP has only a checklist entry but no
    detail section.
  • phaseAddBatch dangling --descriptions or --descriptions followed by
    another flag now surface "--descriptions must be a JSON array"
    instead of silently falling through to positional parsing or throwing
    "--descriptions must be a valid JSON array".
  • renameIntegerPhases now skips backlog phases (dirInt >= 999) — bug-2434
    parity. Without this, removing phase 3 in a project with 999.1-backlog-*
    on disk would rename the backlog dir to 998.1-backlog-*.
  • updateRoadmapAfterPhaseRemoval rewritten to mirror phase.cjs:880-922
    exactly: 5 targeted regex passes (not a loop), driven by three
    decrement helpers (decrementRoadmapPhaseNumber, decrementRoadmapPhase
    Token, decrementRoadmapPaddedPhaseNumber) that guard against
    `num >= 999`. The padded-prefix replace uses negative lookbehind/
    lookahead to skip YYYY-MM-DD substrings. Fixes:
      - bug-2435: integer phase remove no longer corrupts dates in ROADMAP
        (e.g. `(Shipped: 2025-04-15)` is left alone when removing phase 4).
      - bug-3355: integer phase remove no longer renumbers the same phase
        more than once (loop overlap removed).
      - Backlog phases stay frozen during renumbering.
  • phaseComplete next-phase scan skips backlog dirs (999.x). Without
    this, `phase complete 2` in a project with 999.1-backlog/ on disk
    would emit next_phase: '999.1' even though Phase 3 exists in
    ROADMAP.md. Bug #2129 parity.

VERIFICATION (per-test, targeted runs — full suite not exercised due to
prior 89GB OOM with concurrent runs):
  • phase.test.cjs        — 108/108 pass (was 13 fail)
  • init.test.cjs         — 93/93 pass (no regression)
  • state.test.cjs        — 104/104 pass (no regression)
  • validate.test.cjs     — pass (no regression)
  • verify.test.cjs       — pass (no regression)
  • core.test.cjs         — pass (no regression)
  • roadmap.test.cjs      — pass (no regression)
  • cjs-sdk-bridge-integration.test.cjs — 4/4 pass (bridge intact)

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

* fix(3575): align SDK roadmap-mutation helpers with CJS — bug-2005

Three CJS↔SDK divergences in the phase.complete write path were hiding
behind the broken bridge:

1. replaceInCurrentMilestone (sdk/src/query/phase-roadmap-mutation.ts)
   The SDK port carried an extra fallback that doesn't exist in the CJS
   (core.cjs:1013-1022): if the "after last </details>" slice didn't match
   the pattern, the SDK silently retried inside the last <details> block.
   That fallback corrupts the current milestone when it is itself wrapped
   in <details open>...</details> and there's no content after the close
   tag — the supposed-to-be-skipped scope is the only place the match
   exists. Aligned to CJS: split at the last </details>, replace only in
   the after-slice, return. No fallback. Documented with a "do not
   re-add" warning since this fallback has been added back twice in
   prior porting passes.

2. phase complete checkbox update (sdk/src/query/phase-lifecycle.ts)
   The SDK was scoping the `- [ ] Phase N:` → `- [x] Phase N:`
   replacement through replaceInCurrentMilestone. The CJS
   (phase.cjs:1057) uses a direct roadmapContent.replace(...) call. When
   the current milestone is wrapped in <details>, the scoped variant
   never reaches the checkbox; direct replace finds it. Aligned with
   CJS.

3. phase complete plan-count update (sdk/src/query/phase-lifecycle.ts)
   Same pattern — the SDK was scoping the `**Plans:** X/Y` update
   through replaceInCurrentMilestone. CJS (phase.cjs:1080) uses direct
   replace. Aligned.

VERIFICATION
  • bug-2005-phase-complete-details.test.cjs — 2/2 pass (was 1 fail)
  • phase.test.cjs                            — 108/108 pass (no regression)

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

* fix(3575): align SDK with CJS — add-decision DWIM + frontmatter paths

Two more CJS↔SDK divergences exposed by the bridge fix:

state.add-decision / state.add-blocker DWIM (sdk/src/query/state-mutation.ts)
  CJS state.cjs:481-498 + 532-548 auto-create the canonical Decisions /
  Blockers section when it's absent from STATE.md. The SDK was returning
  `{added: false, reason: '<Section> section not found in STATE.md'}`
  even when STATE.md was writable. Bug #3286 (parity for both verbs):

    • If section header pattern matches → append entry (existing path).
    • If section is absent → scaffold `## Decisions` (or `### Blockers`)
      and append the entry, then set `created: true` on the result.

  Matches the begin-phase / advance-plan DWIM behavior. Callers can now
  treat `state add-decision` as idempotent — first call creates the
  scaffold, subsequent calls append to it.

frontmatter get/set/merge/validate (helpers.ts + frontmatter.ts +
                                     frontmatter-mutation.ts)
  CJS frontmatter.cjs:323/340/354/369 resolves user paths with the
  simple `path.isAbsolute(p) ? p : path.join(cwd, p)`. The SDK port had
  promoted this to `resolvePathUnderProject` which adds a real-path
  prefix check against the project root.

  That check rejects absolute paths outside the project — including
  macOS tmpdir paths whose names contain spaces, the exact regression
  cited in bug #3509. Frontmatter verbs are deliberately path-flexible
  in CJS because they're called against external files (plan paths
  from other repos, scratch markdown, tmpdir fixtures).

  Introduced `resolveFrontmatterPath()` mirroring the CJS one-liner. The
  project-scoped `resolvePathUnderProject()` is unchanged — still used
  for template output, decision artifacts, etc.

VERIFICATION
  • bug-3286-state-write-routing.test.cjs — 13/13 pass (was 6 fail)
  • bug-3509-path-spaces.test.cjs         — 6/6 pass (was 3 fail)
  • phase.test.cjs / init.test.cjs / state.test.cjs / validate.test.cjs /
    verify.test.cjs / core.test.cjs / roadmap.test.cjs — all pass (no
    regression — 566 total tests).

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

* fix(3575): route SDK state handlers through scanPhasePlans — bug-3257

The SDK port of buildStateFrontmatter / stateValidate / stateSync was
using a naive top-level filter (`files.filter(/-PLAN\.md$/i)`) instead
of the canonical scanPhasePlans helper. The naive filter undercounts
every phase that uses the nested layout `phases/NN-name/plans/<NN>-PLAN-MM-slug.md`,
which is the default the planner agent produces.

CJS routes all three sites through scanPhasePlans (state.cjs:408, 824,
1427). scanPhasePlans is already a Shared Module — generated CJS at
plan-scan.generated.cjs from sdk/src/query/plan-scan.ts. The fix is
just to consume it.

CHANGES
  • buildStateFrontmatter (sdk/src/query/state.ts): replaced the
    inline `-PLAN.md` / `-SUMMARY.md` regex filters with scanPhasePlans;
    use the helper's `completed` flag for diskCompletedPhases.
  • stateValidate (sdk/src/query/state-mutation.ts): same swap on the
    current-phase plan-count drift check.
  • stateSync (sdk/src/query/state-mutation.ts): same swap on the
    rollup loop. Also routes the Progress percent through
    computeProgressPercent(completedPlans, totalPlans, diskCompletedPhases,
    syncTotalPhases) so the min(plan_fraction, phase_fraction) cap from
    bug #3242 Bug B is applied — without this, sync emitted 60% when the
    real progress was capped at 50% by phase-fraction.

VERIFICATION
  • bug-3257-nested-plans-undercount.test.cjs — 14/14 pass (was 12 fail)
  • phase.test.cjs / init.test.cjs / state.test.cjs / validate.test.cjs /
    verify.test.cjs / core.test.cjs / roadmap.test.cjs / bug-3286 /
    bug-2005 / bug-3509 — all pass (no regression — 580 total).

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

* test(3575): phase 6 CJS↔SDK seam behavioral contracts — TDD-found worker bug

Adds tests/phase-6-cjs-sdk-seam-contracts.test.cjs — a behavioral contract
suite for everything Phase 6 of #3524 introduced.  Written under the
issue #3592 test rewrite discipline:

  • No source-grep on .cjs files
  • No assert.match / .includes on free-form child-process stdout/stderr
  • Every assertion is on a parsed JSON object, a filesystem fact, an
    exit code, or a frozen enum value (SYNC_ERROR_KIND, BRIDGE_EXPORTS,
    TRANSPORT_MODE)
  • Helpers come from tests/helpers.cjs (runGsdTools, createTempProject,
    cleanup) — no inline fs.mkdtempSync
  • Fixture content built with array.join('\n'), never template literals
  • beforeEach/afterEach for shared setup; no try/finally inside tests

COVERAGE
  1. Bridge module surface — exports lock against BRIDGE_EXPORTS
  2. Bridge load lifecycle — tryLoadSdk, getters return cached refs,
     pre-load returns null
  3. executeForCjs RuntimeBridgeSyncResult shape — ok:true vs ok:false
     discriminated union; mode:"json" never double-stringifies
  4. CLI family-router dispatch — one structured-JSON assertion per
     family (roadmap, phase, phases, state, init, validate, find-phase)
  5. mode:"json" regression guard — stdout parses to object, not to
     JSON-encoded string (the Wave-1 double-stringify bug shape)
  6. GSD_WORKSTREAM gate — SDK path and CJS fallback produce identical
     structured fields for the same fixture
  7. Validation error taxonomy — empty arg → ok:false +
     errorKind: SYNC_ERROR_KIND.VALIDATION_ERROR
  8. phase.add filesystem facts — directory exists, ROADMAP file grew
     (asserted via fs.statSync, never by reading content back)

TDD-FOUND BUG (RED → GREEN)
  Suite §7 (validation_error taxonomy) failed in the RED phase:

    expected: 'validation_error'
    actual:   'native_failure'

  Root cause in sdk/src/runtime-bridge-sync/worker.ts: when an SDK
  handler throws a GSDError(Validation), the native direct adapter
  wraps it in a GSDToolsError via createNativeFailureError, preserving
  the original on `.cause`.  classifyError only checked for TypeError
  causes — every GSDError cause fell through to `native_failure`,
  breaking the documented SyncErrorKind contract.

  Fix: classifyError now unwraps the cause once.  When the cause is a
  GSDError with ErrorClassification.Validation or .Blocked, the result
  is errorKind: 'validation_error' (exit 10) — matching the direct
  branch a few lines below for unwrapped GSDError.

VERIFICATION (per-test, before and after the worker fix)
  • Phase 6 contract suite          — 21/21 pass (was 20/1 fail at RED)
  • phase.test.cjs                  — 108/108 pass
  • init.test.cjs                   — 93/93 pass
  • state.test.cjs                  — 104/104 pass
  • validate.test.cjs / verify.test.cjs / core.test.cjs / roadmap.test.cjs
                                    — all pass
  • cjs-sdk-bridge-integration.test.cjs — 4/4 pass (bridge intact)
  • npm run lint:tests              — 0 violations (no source-grep)

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

* fix(3575): SDK config-get/set parity + reason-code propagation — bugs #2943 #3086 #3212

Three CJS↔SDK divergences in config dispatch exposed when Phase 6 routes
`config-get` / `config-set` through `executeForCjs`:

1. SDK config-get was missing the SCHEMA_DEFAULTS map.
   CJS config.cjs:505-510 hard-codes documented defaults for
   `context_window` (200000), `executor.stall_detect_interval_minutes` (5),
   `executor.stall_threshold_minutes` (10), `git.create_tag` (true).  When a
   config.json omits the key, CJS returns the documented default with
   exit 0.  SDK threw `Key not found` for all four — every skill that
   reads `context_window`, executor stall thresholds, or the tag toggle
   broke under SDK dispatch.  Ported the table verbatim into
   sdk/src/query/config-query.ts and consult it at every "not found"
   exit point (matching the three CJS branches: missing file, traversal
   collapse, terminal undefined).

2. SDK config-set was missing the `git.create_tag` boolean-only guard.
   CJS rejects `config-set git.create_tag maybe` because the schema is
   boolean.  SDK silently accepted it and wrote "maybe" to disk under
   Phase 6 dispatch.  Added the matching guard + the missing
   `workflow.post_planning_gaps` boolean guard.

3. SDK errors lost their structured reason code at the bridge boundary.
   `--json-errors` callers expect `reason: 'config_key_not_found'` etc.
   from a frozen `ERROR_REASON` taxonomy; the bridge dispatcher in
   gsd-tools.cjs was calling `error(message)` without the second
   argument, so every SDK-routed error surfaced as `reason: 'unknown'`.
   Fix is end-to-end:
     • config handlers tag the GSDError with `.reason = 'config_*'`.
     • worker.ts:classifyError reads `.reason` off the cause (or off
       the direct error) and forwards it via `errorDetails.reason`.
     • `_dispatchNonFamily` in gsd-tools.cjs passes that reason as the
       second arg to `error()` when present.
     • Also added the `--raw` scalar pass-through here, so
       `output(data, raw, String(data))` is called for primitive
       results — without it, `config-get context_window --raw` emitted
       the JSON shape '200000\n' which happens to match but breaks any
       primitive whose JSON encoding differs from its String() form
       (booleans for example, where the CJS produces `true` while the
       SDK-routed path was producing `true` — same here, but the
       structural guarantee was wrong before).

VERIFICATION (per-test)
  • bug-2943-config-get-context-window-default.test.cjs — 5/5 pass
  • bug-3086-git-create-tag-config-gate.test.cjs        — 4/4 pass
  • bug-3212-execute-phase-stall-safe-resume.test.cjs   — 7/7 pass
  • Phase 6 contract suite                              — 21/21 pass
  • phase/init/state/core/roadmap/validate/verify       — all pass
                                                          (570 total)
  • Full bug-* suite: 24 fail → 17 fail (7 fixed in this commit).

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

* fix(3575): SDK milestone-archive layout discovery — bug #3164

Two CJS↔SDK divergences in phase discovery and validation surfaced
when projects moved to the milestone-archive layout
(`.planning/milestones/v<version>-phases/<phase>/`) instead of the
flat `.planning/phases/<phase>/`.

1. SDK findPhase had no `searched_directories` field on the not-found
   payload.  CJS surfaces this for diagnostics.  Added: track every
   directory probed (the active `.planning/phases/` plus each
   archive root) and include the relative paths in the not-found
   payload. Bug #3164 — #find-phase tests.

2. SDK validateConsistency only scanned `.planning/phases/`.  CJS
   `cmdValidateConsistency` (verify.cjs:467) walks every active
   phase root via `collectPhaseRoots(planBase)` — the flat dir plus
   the active milestone archive resolved from STATE.md.  Without
   parity, every roadmap phase on a milestone-archive-layout project
   emitted W006 ("no directory on disk") even though the phases were
   present in the archive.

   Ported the helper trio (listMilestoneArchiveDirs,
   getActiveMilestoneArchiveDir, collectPhaseRoots) verbatim from
   verify.cjs:400-444 and rewrote validateConsistency's disk-phase
   scan + per-phase plan scan to iterate `phaseRoots`.  Warning
   labels now include the archive prefix so users can tell which
   root surfaced the issue.

   Also accepts prefixed archive dir names (`CK-64-...`) as phase 64
   via the `(?:[A-Z]{1,6}-)?` group at the head of
   PHASE_TOKEN_FROM_DIR_RE — same regex CJS uses.

VERIFICATION (per-test)
  • bug-3164-milestone-archive-layout.test.cjs — 8/8 pass
  • Phase 6 contract suite                      — 21/21 pass
  • phase/init/state/validate/verify/core/roadmap — 570 pass
  • Full bug-* suite: 17 fail → 12 fail (5 fixed in this commit;
    cumulative 12 fixed since Wave 6 start).

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

* fix(3575): padded phase IDs match unpadded ROADMAP prose — bug #3537

Three failures in bug-3537-padded-id-against-unpadded-roadmap:

1. roadmap.get-phase returned `phase_number` verbatim from the user
   input — `02.7` produced `"phase_number": "02.7"` while `2.7`
   produced `"phase_number": "2.7"` on the same fixture, so a parity
   compare of the two stdouts fails.  Fixed by promoting the matched
   phase token in `searchPhaseInContent` to a capture group and
   returning that as the canonical `phase_number`.  Same fix in the
   checklist-fallback branch so the malformed-roadmap diagnostic
   carries the as-written form too.

2. phase.complete built every ROADMAP-prose regex from
   `escapeRegex(phaseNum)` instead of the padding-tolerant
   `phaseMarkdownRegexSource(phaseNum)`.  Calling
   `phase complete 02.7` against the un-padded heading
   `### Phase 2.7:` matched nothing — checkbox didn't flip, plan
   count stayed at `0/1`, table row stayed `Planned`.  Promoted
   `phaseMarkdownRegexSource` to an exported helper in roadmap.ts
   and wired it into phaseComplete's roadmap mutation block.

3. roadmap.annotate-dependencies infinite-looped through the bridge.
   The SDK handler delegates to `spawnSync(gsd-tools.cjs roadmap
   annotate-dependencies …)`; the child re-entered the roadmap
   router; the router re-dispatched through executeForCjs; synckit
   spawned the same SDK worker; that worker spawned gsd-tools.cjs
   again; …  Recursion hit the 15s timeout and the test reported
   `code=null`.  Fixed with a `GSD_SDK_NESTED=1` env-var guard:
   the SDK handler sets it when spawning the child, and the CJS
   roadmap router refuses SDK dispatch when it sees the flag.

VERIFICATION (per-test)
  • bug-3537-padded-id-against-unpadded-roadmap.test.cjs — 6/6 pass
  • Phase 6 contract suite                                — 21/21 pass
  • phase/init/state/validate/verify/core/roadmap         — 570 pass
  • Full bug-* suite: 12 fail → 7 fail (5 fixed in this commit;
    cumulative 17 fixed across the wave-6/7/8 sequence).

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

* fix(3575): final-7 SDK parity — bugs #2787 #2268 #2526

Closes out the bug-suite tail.  Three independent fixes against three
independent regressions surfaced when Phase 6 routed read-only and
mutation paths through the SDK.

1. extractCurrentMilestone truncated at heading-like lines inside
   fenced code blocks — bug #2787.  The `^#{1,N}\\s+...vX.Y` scan
   ran with the `/m` flag, which matches `^` at every newline,
   including newlines inside ``` and ~~~ fences.  A snippet like
     ```bash
     # Ops runbook — v1.0 compat
     ```
   placed between Phase 2 and Phase 3 of a v1.1 milestone shortened
   the milestone slice and made phases 3, 4 invisible to
   roadmap.analyze / roadmap.get-phase.

   Added `isInsideFencedCodeBlock(content, offset)` — a GFM-aware
   walker that toggles a `fenceChar` cursor on each fence boundary
   (backticks and tildes; closing fences require the matching
   character and no info string — so ```js inside ```text does NOT
   close).  The nextMilestoneRegex loop now skips any match that
   falls inside an open fence.

2. init.manager only marked the FIRST undiscussed phase as
   `is_next_to_discuss` — bug #2268.  Two and five-phase fixtures
   both proved the regression: parallel-discuss capacity was lost,
   recommended_actions emitted at most one discuss action even
   when callers were free to take several.  Replaced the sliding-
   window loop with an unconditional `phase.is_next_to_discuss =
   (status === 'empty' || status === 'no_directory')`.

3. phase.complete didn't surface "REQ-IDs found in body but
   missing from Traceability table" warnings — bug #2526.  CJS
   phase.cjs:1140-1167 scans REQUIREMENTS.md for `**REQ-ID**`
   references in the body, intersects against the IDs that actually
   appear in the Traceability section table, and warns about the
   diff.  The SDK port only ran the per-roadmap-REQ checkbox
   update and never emitted the body-scan warning.  Added the
   missing scan + warning push; also routed the writeFile through a
   `reqContentChanged` flag so we only write when at least one
   substitution actually fired (parity with the implicit
   "every checkbox already complete" no-write CJS branch).

VERIFICATION
  • bug-2787-milestone-fenced-block-truncation.test.cjs — 4/4 pass
  • bug-2268-parallel-discuss.test.cjs                   — 4/4 pass
  • bug-2526-phase-complete-req-discovery.test.cjs       — 3/3 pass
  • Phase 6 contract suite                               — 21/21 pass
  • Major suites (phase/init/state/validate/verify/core/roadmap) — 570 pass
  • **Full bug-* suite: 2397/2397 pass — ZERO failures.**
  • Combined run (major + bug-*): 2967/2967 pass — zero failures.

Cumulative since the bridge-fix landing (PR #3577): 12 sub-test
regressions surfaced + every one resolved.  Phase 6 is now byte-for-
byte CJS-parity across every command family verified by the test
suite.

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

* fix(3575): preserve codex runtime command shape after router migration

* test(3575): pin agent-install-validation init tests to GSD_AGENTS_DIR

PR #3577 routed init.execute-phase and init.plan-phase through executeForCjs
to the SDK handlers. The SDK side's resolveAgentsDir (sdk/src/query/helpers.ts)
honors GSD_AGENTS_DIR or falls back to <runtimeConfigDir>/agents; it does not
walk up from cwd to find <repo>/agents/ like the CJS-era code did. The two
init-suite tests that asserted agents_installed=true relied on that implicit
walk and only passed on dev machines where ~/.claude/agents/ already had the
33 agents installed — Linux CI runners have neither.

Match the pattern every passing sibling in this file already uses: pass
{ GSD_AGENTS_DIR: REPO_AGENTS_DIR } through runGsdTools so the SDK resolver
points at the repo's agents/ dir explicitly. No production code change.

Refs sdk/src/query/QUERY-HANDLERS.md ("subprocess vs in-process path
resolution") and CONTEXT.md DEFECT.PORT-DRIFT.cjs-sdk.

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

* fix(3577): Phase 6 config-* SDK port parity carve-outs

Restored the legacy contract for four CLI tests broken by the Phase 6
router migration:

1. `config-ensure-section` was bound to the new SDK `configEnsureSection`
   handler which requires `args[0]=sectionName`. Every real CLI caller
   uses the no-arg form expecting full default config.json creation.
   Reverted the dispatch case to call `config.cmdConfigEnsureSection`
   directly (matches the precedent in 7d5dfa9d for `codex` runtime).

2. SDK `configNewProject` `commit_docs` and `parallelization` defaults
   set to `true`/`true` (was `false`/`1`) — aligned with
   `sdk/shared/config-defaults.manifest.json` and the CJS
   `buildNewProjectConfig` `hardcoded` block.

3. SDK `configNewProject` returns the project-rooted relative path
   `.planning/config.json` instead of the absolute `paths.config`,
   matching the CJS `ensureConfigFile` output shape.

4. SDK error vocabulary aligned with CJS: `Unknown config key: <key>`
   (no surrounding quotes), and config-get's malformed-JSON message
   leads with `Failed to read config.json:` so legacy substring
   assertions in `tests/config.test.cjs` keep matching.

Local: 132/132 across `tests/{config,agent-skills,ai-evals}.test.cjs`.

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

* fix(3631): family routers forward --raw to SDK bridge as mode:'raw'

#3577 routed every family subcommand through the SDK bridge with a
hardcoded mode:'json'. With --raw set, the bridge returned the typed JSON
IR and routers called `output(result.data)` — bypassing output()'s
rawValue branch. Shell consumers expecting scalar tokens
(`gsd-tools phase next-decimal --raw 1` → `1.1`) received the JSON-
stringified IR instead.

Each `*-command-router.cjs` SDK dispatch path now requests
`mode: raw ? 'raw' : 'json'` from the bridge. The sync-bridge worker is
wired to `formatNativeRaw = formatQueryRawOutput` so the bridge returns
the per-command scalar projection. Routers route the formatted string
through `output(null, true, str)` (rawValue branch) so it lands on
stdout verbatim.

formatQueryRawOutput extended for the two commands covered by the issue
acceptance criteria — phase.next-decimal (→ data.next) and
roadmap.get-phase (→ data.section). Other registered raw projections
(state.load, commit, config-set, state.begin-phase) are unaffected; the
default `safeStringify` branch still applies to unprojected commands.

state-command-router already had a dispatchViaSdk helper that selected
mode based on a rawFormatter. The trailing fallthrough `output(result.data)`
when no rawFormatter was present is the same regression and was patched
to use the rawValue branch under --raw.

Regression test `tests/bug-3631-router-raw-flag.test.cjs` exercises
end-to-end:
  - `phase next-decimal --raw 1` emits a scalar phase token (not JSON).
  - `roadmap get-phase --raw 2` emits the section text (not JSON).

The fix targets `feat/3575-enforcement-hardening` (PR #3577, open) —
not origin/main as the issue body asserted. The #3577 regression lives
on that branch and the fix needs to land there before merge.

Fixes #3631

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

* test(3631): force CJS dispatch path in router unit tests via GSD_WORKSTREAM

phases-command-router.test.cjs and roadmap-command-router.test.cjs
mock the CJS-side `phase`/`milestone`/`roadmap` handlers and assert
they are called with the parsed args. Since #3577 the router prefers
SDK dispatch when sdk/dist is present — the mocks are then bypassed
and the SDK side fails because the test cwd `/tmp/proj` has no
`.planning/` fixture.

The router already gates SDK dispatch on `process.env.GSD_WORKSTREAM`
being unset (workstream-scoped requests fall through to CJS). Setting
GSD_WORKSTREAM in before()/after() deterministically routes through
the CJS handlers the tests were written against, without weakening
the assertions.

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

* fix(3632): report each ts sibling independently in lint-shared-module-handsync

The cooperatingPairs lookup ran inside `.some()` over all ts candidates for
a given cjs. When two ts siblings shared the same basename (e.g.
`sdk/src/foo.ts` and `sdk/src/query/foo.ts`) and only one pair was
allowlisted, `.some()` short-circuited and the unallowlisted sibling
silently passed through CI.

Classify each ts sibling independently against the allowlist so partially-
allowlisted multi-sibling drift surfaces. Added regression test
`reports unallowlisted ts sibling when another ts sibling for the same cjs
IS allowlisted (#3632)`.

Real-tree lint output unchanged on `feat/3575-enforcement-hardening`:
22 cooperating siblings, 0 unauthorized, 0 backlog pairs.

Fixes #3632

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

* fix(3577): ADR/PRD compliance + SDK port completeness for Phase 6

Multiple ADR/PRD violations in the Phase 6 cutover surfaced during
gsd-test-summary docker runs. Root causes traced to docs/adr/
3524-cjs-sdk-hard-seam.md §3 (out-of-seam module list) and
docs/prd/3524-cjs-sdk-hard-seam.md L160 (CJS-only verbs must not
route through the SDK runtime bridge), plus port-drift bugs the ADR
was specifically written to prevent (DEFECT.PORT-DRIFT.cjs-sdk).

Out-of-seam Module bindings removed from SDK catalog/manifests:
- verify.codebase-drift (drift is CJS-only; the SDK stub used
  execFileSync back to gsd-tools, recursing infinitely with the
  Phase 6 router rewrite — forked hundreds of node procs on the
  64 GiB plex2 docker host before manual kill)
- intel.* (8 verbs: diff, snapshot, validate, status, query,
  extract-exports, patch-meta, update — intel is CJS-only per ADR)
Both already have direct-CJS dispatch in gsd-tools.cjs (case
'intel') and verify-command-router.cjs (`'codebase-drift':` now
calls verify.cmdVerifyCodebaseDrift without going via sdkHandler).

config-ensure-section cutover restored via catalog rebind:
- 'config-ensure-section' in command-static-catalog-foundation.ts
  rebound from configEnsureSection (single-section semantics,
  requires args[0]=sectionName the CLI never passes) to
  configNewProject (whose no-args branch produces the full default
  config.json — matches the legacy ensureConfigFile contract).
- gsd-tools.cjs `case 'config-ensure-section'` restored to its
  Phase 6 _dispatchNonFamily form (no CJS fallback — the SDK
  handler now does the right thing).

configNewProject defaults from canonical manifest:
- Replaced the hardcoded duplicate `defaults` block with a
  derivation from CONFIG_DEFAULTS (sdk/src/configuration/index.ts,
  sourced from sdk/shared/config-defaults.manifest.json). The
  duplicate had drifted — omitted workflow.{ai_integration_phase,
  tdd_mode, human_verify_mode, pattern_mapper, plan_bounce*,
  auto_prune_state, subagent_timeout, security_*, post_planning_gaps},
  git.create_tag, claude_md_path, planning.*, graphify.*, mode,
  resolve_model_ids, context_window — every one of which had a
  test asserting the post-init value.

SDK configSet value-validation port (CJS cmdConfigSet parity):
- workflow.drift_action enum (warn|auto-remap)
- workflow.drift_threshold positive-integer
- workflow.human_verify_mode enum (mid-flight|end-of-phase)
- statusline.context_position enum (front|end)
- code_quality.fallow.scope enum (phase|repo)
- code_quality.fallow.profile enum (minimal|standard|strict)
- review.default_reviewers array shape + slug regex +
  lowercase-unique normalisation (matches
  bin/lib/review-reviewer-selection.cjs
  normalizeConfiguredDefaultReviewers, with the normalised value
  persisted to disk)

Init/roadmap/phase/workspace/frontmatter handler fixes:
- initExecutePhase + initPlanPhase parse --tdd boolean override
- initMapCodebase reads workflow.subagent_timeout with 300000
  default per manifest
- roadmapAnalyze surfaces `mode` per phase (parity with
  roadmapGetPhase)
- phaseComplete auto-prunes STATE.md when workflow.auto_prune_state
  is true (port of bin/lib/phase.cjs:1378-1390; #2087)
- initRemoveWorkspace throws GSDError on no-name and
  workspace-not-found instead of returning {data:{error}} which
  the CLI output path treated as success
- frontmatterGet parses --field <name> in addition to positional
  args[1]

Local: 150/150 across the failing-cluster test files
(review-default-reviewers-config, subagent-timeout, pattern-mapper,
tdd-mode, drift-detection, roadmap-mode-field, workspace,
phase-complete-auto-prune, frontmatter-cli). Docker gsd-test-summary
re-run in progress for full validation.

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

* fix(3577): clear 12 ubuntu-only regressions surfaced by gsd-test-summary

Docker test pass 3 (holodeck) surfaced 12 real bugs after the earlier
ADR/PRD-compliance commit (cf4dd0cb). Every one is a SDK-side bug —
fix-forward, not "pre-existing":

bug-3599 (2 subtests) — roadmap.get-phase project-code-prefix lookup:
  Ported phaseMarkdownRegexSourceExact from CJS (core.cjs:704-708) so
  `PROJ-42` queries try the exact escaped form FIRST before falling
  back to the padding-tolerant numeric. searchPhaseInContent now does
  two-pass lookup. Without this, `roadmap get-phase PROJ-42` returned
  not-found even when ROADMAP contains `### Phase PROJ-42:`, and
  bare `42` queries cross-matched the PROJ-42 heading.

roadmap-mode-field (1) — roadmapAnalyze surfaces `mode` per phase:
  Extracts the same `**Mode:**` field that roadmapGetPhase already
  parses (CONTEXT.md "MVP Mode" glossary). Without this, downstream
  consumers reading roadmap.analyze output couldn't tell which phases
  were MVP-mode.

bug-3601 (2 subtests) — phase.remove preserves peer-depth decimals:
  Ported the depth-aware end-of-section regex from CJS phase.cjs
  (named capture `(?<h>#{2,4})` + `\k<h>(?!#)` backreference). Now
  removing `### Phase 2:` stops at `### Phase 2.1:` (same depth, peer
  decimal) while continuing past `#### Phase 27.1:` (child depth).

bug-3602 (1 subtest) — phase.remove renumbers slugged plan refs:
  Extended the padded-plan-reference pattern with optional kebab-case
  slug segments `(?:-[A-Za-z][A-Za-z0-9-]*)*` between NN-NN and the
  PLAN/SUMMARY suffix, matching CJS phase.cjs:#3602 fix. Without this,
  `07-01-cherry-pick-foundation-PLAN.md` references stayed at `07-01-`
  after Phase 7 was removed, while the file on disk was already
  `06-01-...`.

config.test (1) — config-get git.base_branch returns "Key not found":
  configNewProject now filters out manifest keys legacy CJS init does
  NOT materialize: top-level `resolve_model_ids`, `context_window`,
  `mode`, `planning`, `graphify`; nested `git.base_branch`. These have
  their own resolution paths (origin/HEAD auto-detect for base_branch,
  feature opt-in for planning/graphify) and materializing the manifest
  defaults would suppress them. Manifest stays the schema source of
  truth per ADR §6; init shape stays minimal per legacy CJS contract.

gsd-sdk-query-registry-integration (1) — agents/gsd-intel-updater.md
references retargeted from `gsd-sdk query intel.*` to `gsd-tools intel
<subcommand>`. intel is out-of-seam per ADR §3 / PRD L160 ("CJS-only
Module handlers ... keep their in-process CJS implementations").
Removing the SDK catalog entries (cf4dd0cb) made the SDK route invalid;
the agent now correctly invokes the CJS handler via gsd-tools, which
routes through Shell Command Projection for cross-platform formatting.

Local: 79/79 across the failing test files. Docker re-run in progress.

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

* fix(3577): regenerate command-aliases + retarget workflow drift-gate

CI ubuntu-24 surfaced two remaining ADR-compliance gaps after the
previous push:

1. `sdk/src/query/command-aliases.generated.{ts,cjs}` still listed
   verify.codebase-drift + intel.{snapshot,patch-meta} from before the
   manifest-side removal. Ran `npx tsx sdk/scripts/gen-command-aliases.ts`
   to regenerate; both files now match the manifest source of truth.
   Closes the `command-seam-coverage.test.ts` "missing registry
   canonical verify.codebase-drift" failure (its assertion is correct —
   the SDK does NOT register codebase-drift, so the alias entry must
   not be present either).

2. `get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md`
   invoked `gsd-sdk query verify.codebase-drift` — drift is out-of-seam
   (CJS-only) per ADR §3 / PRD L160, so there is no SDK handler to
   route through. Retargeted to `gsd-tools verify codebase-drift` which
   dispatches direct to bin/lib/drift.cjs (the canonical implementation)
   via the CJS router. Closes the
   `gsd-sdk-query-registry-integration.test.cjs` failure.

Local: docker gsd-test-summary 11383/0 on plex2.

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

* fix(3575): raise Node heap for coverage in CI matrix

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: ci <ci@gsd-build>
2026-05-16 13:14:24 -04:00
Tom Boucher
2e4fe65816 fix(3579): ship graphify hook + lib/ helper through build-hooks + install (#3640)
* fix(3579): ship graphify hook + lib/ helper through build-hooks + install

`scripts/build-hooks.js` `HOOKS_TO_COPY` did not include
`gsd-graphify-update.sh` (added in #3347 / PR #3557), so it never landed
in `hooks/dist/` and `bin/install.js` — which `readdirSync`s the dist —
never copied it to `~/.claude/hooks/`. The hook's detached rebuild
helper at `hooks/lib/gsd-graphify-rebuild.sh` was also silently dropped
because both build-hooks.js (flat allowlist) and bin/install.js (readdir
+ isFile filter) only walked top-level files.

The published tarball gap (Gap 3 in the issue body) is not reproduced
on origin/main — `npm pack --dry-run --json` shows both source files
are present today. Only Gaps 1 and 2 are in scope.

Changes:
- Add `gsd-graphify-update.sh` to `HOOKS_TO_COPY`.
- Add `HOOKS_SUBDIRS_TO_COPY = ['lib']` and copy whitelisted hook
  subdirectories (`hooks/<dir>/*` → `hooks/dist/<dir>/*`) in
  build-hooks.js, with the same syntax-check + atomic-rename path the
  top-level loop uses.
- `bin/install.js`: when copying `hooks/dist/`, recurse one level into
  any directory entry so subdir files (e.g. `lib/gsd-graphify-rebuild.sh`)
  land at the mirrored target path the hook's REBUILD_SCRIPT lookup
  expects. Top-level if/else structure for files is unchanged.

Regression test `tests/bug-3579-graphify-hook-publish.test.cjs`:
- Drift guard: every top-level `hooks/*.sh` must appear in
  `HOOKS_TO_COPY`. Generalizes beyond graphify so the next .sh hook
  added cannot regress.
- After build: `hooks/dist/gsd-graphify-update.sh` AND
  `hooks/dist/lib/gsd-graphify-rebuild.sh` exist.
- After install: both files land at the target, and no
  "Missing expected hook: gsd-graphify-update.sh" warning is emitted.

Fixes #3579

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

* test(3579): replace source-grep drift guard with filesystem-behavior assertion

The Gap-1 drift guard read scripts/build-hooks.js as text and regex-parsed the
HOOKS_TO_COPY literal, which tripped lint-no-source-grep and is brittle under
refactors. Replace with a behavior-based assertion: run the build, then for
every top-level hooks/*.sh assert hooks/dist/<name> exists. Strictly stronger
— catches both the original allowlist gap and any future regression that
silently drops a hook for any other reason.

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-05-16 13:14:19 -04:00
Tom Boucher
05a8395566 fix(3406): detect + warn on stale @gsd-build/sdk@0.1.0 global shadow (#3641)
* fix(3406): detect + warn on stale @gsd-build/sdk@0.1.0 global shadow

`@gsd-build/sdk@0.1.0` is the only published version of the standalone
SDK package (the SDK now ships embedded in get-shit-done-cc). When a
user has the stale 0.1.0 globally installed, its `gsd-sdk` bin shadows
the shim get-shit-done-cc wires up — and the 0.1.0 binary only knows
`run | auto | init` (no `query`), so every `gsd-sdk query <cmd>` call
from skills and hooks fails silently until the user runs
`npm uninstall -g @gsd-build/sdk`.

Per maintainer triage decision (option 2): detect at install time and
surface the remediation, instead of waiting for the user to discover
the failure through a broken workflow.

Changes:
- New helper `detectStaleStandaloneSdk(runNpmLs)` (pure function,
  accepts an injected executor). Returns `{stale: true, version}` when
  `@gsd-build/sdk` is in the top-level dependency tree; returns
  `{stale: false}` for every other input including executor throws,
  malformed JSON, missing keys, and null/undefined returns.
- New helper `formatStaleStandaloneSdkWarning(info)` — message names
  the package, version, the exact `npm uninstall -g @gsd-build/sdk`
  remediation command, and references the issue.
- Call site in `install()` for `isGlobal` runs. Spawns
  `npm ls -g @gsd-build/sdk --json --depth=0`, recovers the JSON
  attached to the non-zero-exit error (npm's "absent" signal),
  forwards to detectStaleStandaloneSdk, prints the warning if stale.
  Best-effort: any failure is swallowed so detection never blocks
  install.
- `GSD_SKIP_STALE_SDK_CHECK=1` opt-out for CI/test environments that
  need silence (also used by the install-side test below).

Regression test `tests/bug-3406-stale-sdk-shadow-detect.test.cjs`:
- 8 unit tests pinning every detectStaleStandaloneSdk path (exported,
  absent, present, executor-throws, malformed-JSON, no-deps-field,
  null/undefined, format).
- 1 install-side end-to-end test confirming that when the package is
  absent, the install run does NOT mention `@gsd-build/sdk` or `#3406`
  in stdout. Uses a per-test `npm_config_prefix` so the test never
  depends on the host's npm dependency tree.

Fixes #3406

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

* fix(3406): correct changeset pr field — 3406 was the issue number, not the PR

CodeRabbit caught that .changeset/fix-3406-detect-stale-sdk-shadow.md
referenced `pr: 3406` (the issue number) instead of `pr: 3641` (the
PR number). Per CONTEXT.md PRED.k329 changeset frontmatter pr: must
reference the pull request number.

Local: docker gsd-test-summary 11214/0 on plex2.

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

* fix(3406): two CR follow-ups on bin/install.js stale-shadow check

Round-2 CodeRabbit findings on PR #3641:

1. bin/install.js:7769 — GSD_SKIP_STALE_SDK_CHECK opt-out now matches
   only explicit "1" / "true" / "yes". The previous any-truthy check
   silently disabled the warning for `GSD_SKIP_STALE_SDK_CHECK=0` and
   `GSD_SKIP_STALE_SDK_CHECK=false`.

2. bin/install.js:10568 — detectStaleStandaloneSdk now gates stale=true
   on version === '0.1.0' (the known-bad shadow). Any newer published
   version is intentional and must not flag a "stale shadow" warning
   on every install. Added a regression test for non-0.1.0 versions
   (1.50.0-canary.0 and 2.0.0) returning stale:false.

Local: 11/11 in the bug-3406 test file + docker gsd-test-summary 11215/0
on plex2.

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-05-16 13:14:16 -04:00
Tom Boucher
e090e91646 fix(3588)(security): clear production npm-audit advisories (#3642)
* fix(3588)(security): clear production npm-audit advisories

Before: 6 production advisories (1 high, 5 moderate) reported by
`npm audit --omit=dev` — fast-uri (high), @anthropic-ai/sdk,
express-rate-limit, hono, ip-address (moderate), all pulled in through
@anthropic-ai/claude-agent-sdk and @modelcontextprotocol/sdk.

After: `npm audit fix` bumped the lockfile-pinned transitive versions
to patched releases. No package.json edits — only package-lock.json
and sdk/package-lock.json. Production audit is clean on both:
`npm audit --omit=dev` → 0 vulnerabilities.

Regression test `tests/bug-3588-npm-audit-clean.test.cjs` runs
`npm audit --omit=dev --json` against root and sdk/ and asserts the
metadata vulnerability counts are zero across info/low/moderate/high/
critical. RED on origin/main (1 high + 5 moderate at root), GREEN after
the lockfile bumps. Skips gracefully when node_modules/ is absent so
fresh checkouts mid-`npm install` don't false-fail.

Fixes #3588

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

* fix(3588): npm audit harness throws on unexpected JSON shape

CodeRabbit caught that auditProductionVulns returned null both for
"node_modules missing → skip" AND for "unexpected JSON shape" — and
callers interpret null uniformly as skip, so a real audit harness
failure (npm changed output format, audit aborted before metadata
section, etc.) would silently no-op instead of failing the test.

null is now reserved for the skip signal only. Any other unexpected
shape throws with the cwd in the message so the test fails loudly.

Local: docker gsd-test-summary 11204/0 on plex2.

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-05-16 13:14:01 -04:00
Cristian Uibar
05316369ae fix(3583): normalize retired colon-form commands in generated Claude/Qwen/Hermes SKILL.md bodies (#3629)
* Add first-class grok runtime support (maps to ~/.agents); wire installer, runtime-homes.cjs and sync-skills; update Grok Build engine in local ~/.agents to latest; record session progress in discussion doc

* Normalize gsd colon references to hyphen in generated Claude SKILL.md bodies using the shared transformer. Fixes #3583.

* Refine #3583 implementation after review: cache command names, improve tests, clean up comments

* Harden gsd colon-to-hyphen transformer with bidirectional word boundaries and body-only regression guard

* Track quick-wins batch status and local session notes for #3583/#3579 handoff

* Port installer robustness (hoist copyLibDir + selective Codex hooks) from 3579 to make Codex tests pass on this branch. Fixes ReferenceError and prevents extra hook pollution in Codex installs.

* Restore #3583 transformer wiring and Codex .sh GSD_VERSION branch lost in 50ff8f17 port

Commit 50ff8f17 ('Port installer robustness from #3579') accidentally reverted:
- the top-level require of transformContentToHyphen/readGsdCommandNames
- the body normalization inside convertClaudeCommandToClaudeSkill
- the Codex hook loop's .sh branch with {{GSD_VERSION}} substitution

These were the actual #3583 fix and the Codex half of the #2136 invariant.
Failing tests fixed: bug-2808-skill-hyphen-name, claude-skills-migration #3583
case, bug-2136 Codex .sh substitution.

* Exempt 'sync-skills' slug from docs-parity check (skill dir name in path references)

gsd-sync-skills is an installed Claude skill name and a workflow file but
not a registered slash command. The docs-parity regex catches /gsd-sync-skills
from filesystem path references like ~/.agents/skills/gsd-sync-skills/ in
docs/discussions/grok-build-support-2026-05.md.

Adding to INTERNAL_COMPONENT_SLUGS matches the existing exemption pattern
for 'statusline', 'workspaces', 'graphify-update', etc.

* Restrict hooks/lib/ install to hook-enabled runtimes and managed allowlist

Codex/Copilot/Cursor/Windsurf/Trae/Cline already skip the hooks block but were still copying hooks/lib/ helpers, contradicting the downstream Codex comment. Gate the call on the same runtime check and pass GSD_HOOK_LIB_FILES so install scope matches the uninstall/manifest scope.
2026-05-16 13:09:58 -04:00