* 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.
Root cause: e50ad812 introduced `const { minimatch } = require('minimatch')`
in scripts/pr-template-policy.cjs, but minimatch is not listed in
package.json (neither dependencies nor devDependencies). The
.github/workflows/pr-template-format.yml workflow checks out main via
actions/checkout and runs the script directly with no `npm ci`/`npm install`
step. Because the workflow uses `pull_request_target` + plain checkout
(no `ref:`), every PR — including PRs that don't touch this file — invokes
main's broken script and the `Pull request template format` check fails
with `Cannot find module 'minimatch'`. This blocks every open PR's check.
Fix choice: replace minimatch with Node's built-in `path.matchesGlob`
(stable since Node 22, required engine is `>=22.0.0`). The only minimatch
usage was `minimatch(file, pattern, { matchBase: false, dot: true })` in
allPathsAreTooling, against simple glob patterns (`**`, `*`, `*.md`,
`requirements*.txt`, etc.) with no extglobs, brace-expansion alternation,
or negation. path.matchesGlob handles all required cases including dot
files, so no new dependency is needed and no workflow change is required.
Verified: all 25 existing tests in tests/pr-template-policy.test.cjs pass,
including the tooling carve-out, exempt-marker, and template-detection
suites. Direct script invocation with CHANGED_FILES=.github/workflows/...
produces the expected `skipped: tooling-paths` carve-out.
This unblocks every open PR's `Pull request template format` check.
* 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>
Node 26 lanes have been causing recurring instability and CI churn.
Drop the Node 26 entry from the strategy matrix in .github/workflows/test.yml
until the Node 26 release stabilises. Also remove the companion
continue-on-error guard and its explanatory comment block, which
existed solely to prevent Node 26 failures from gating the workflow.
Files changed:
.github/workflows/test.yml — removed node-version: 26 from matrix,
removed continue-on-error: ${{ matrix.node-version == 26 }} and its
two-line comment block
Preserved: node-version: [22, 24] still run on all three OS lanes
(ubuntu-latest, macos-latest, windows-latest).
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
* 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.
* 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
* 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>
* 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>
* 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>
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>
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
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>
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>
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>
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>
convertClaudeCommandToClaudeSkill(content, skillName, runtime, cmdNames) uses
the runtime arg to gate Hermes/Qwen branding and version: frontmatter emission
(#2808, #3583). Previously the layout module called it with only 2 args so the
runtime-specific formatting was never applied.
Changes:
- skillsKind() gains a runtime param (5th arg after converterName).
- stage() computes cmdNames = readGsdCommandNames() once per call (perf: avoids
repeated fs.readdirSync in the converter) and wraps the real converter so all
4 args are forwarded.
- readGsdCommandNames added to bin/install.js GSD_TEST_MODE exports block so the
stage closure can call it without requiring the script separately.
- All switch arms updated to pass the canonical runtime string.
Converters that do not inspect runtime/cmdNames (Cursor, Codex, Copilot, etc.)
accept and ignore the extra arguments — no behaviour change for those runtimes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously runtime-artifact-layout.cjs set process.env.GSD_TEST_MODE at the
top level before requiring bin/install.js. Any caller that required this module
would silently inherit the GSD_TEST_MODE='1' side-effect for the lifetime of
the process.
Replace with a lazy loader (loadInstallExports / getInstallExports) that saves
the current GSD_TEST_MODE, sets it to '1' only if it was undefined, calls
require(), and restores the original value in a finally block. The exported
module.exports cache (_installExports) ensures the require is run at most once.
Converter names are now passed as strings to skillsKind; the stage closure
resolves them via getInstallExports()[converterName] at call time, so the
installer is not loaded until a stage() function is actually invoked.
Verification: node -e "require('./...runtime-artifact-layout.cjs'); console.log(process.env.GSD_TEST_MODE)"
prints undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both findInstallSourceRoot and findAgentsSourceRoot now accept an optional
runtimeConfigDir. When provided, they first check <runtimeConfigDir>/.gsd-source:
read the stored path, verify it exists, and return it. Fall through to the
path.dirname walk-up only when the marker is absent or points to a missing path.
The factory functions (commandsKind, agentsKind, skillsKind) now receive configDir
from the switch arms and pass it through to the finders. listSurface in surface.cjs
passes its runtimeConfigDir argument to findInstallSourceRoot so installed-from-source
layouts use the marker rather than the walk-up.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both findInstallSourceRoot and findAgentsSourceRoot previously returned a
path.join(__dirname, '..', '..', '..', ...) fallback when the walk-up loop
found nothing. Replace with throw per CLAUDE.md "No Relative Path Traversal"
policy: .. chains silently break on CWD changes; a throw with the failing
__dirname in the message is an unambiguous diagnostic.
The walk-up loop already uses path.dirname iteratively (no literal ..); only
the dead-end fallback used the banned pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
User-visible: applySurface now prunes skills/gsd-*/ dirs on profile
switch, closing the structural gap behind #3659.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Flip status from Proposed → Accepted and record the branch,
implementation reference, and Phase 2 dependency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>