Compare commits

...

14 Commits

Author SHA1 Message Date
github-actions[bot]
3b78b63db1 chore: bump version to 1.39.1 for hotfix 2026-05-01 20:19:49 +00:00
Tom Boucher
a9bc9cdd9f fix(#2969): deterministic Step 5 verification gate for /gsd-reapply-patches (#2972)
* fix(#2969): deterministic Step 5 verification gate for /gsd-reapply-patches

The prior Step 5 "Hunk Verification Gate" was prescribed correctly in the
workflow text — but executed laxly by the LLM, which filled in `verified: yes`
without actually checking content presence. The reporter observed three
distinct files (skills/gsd-discuss-phase/SKILL.md, skills/gsd-autonomous/
SKILL.md, get-shit-done/workflows/new-project.md) where archives contained
substantive user-added blocks that did not survive into the merged result, yet
the gate reported clean.

Move verification from LLM-driven prose into a deterministic Node script the
workflow calls. The script can't be shortcut.

Changes:

- scripts/verify-reapply-patches.cjs (new): pure Node, no external deps.
  For each file in the patches dir, computes user-added significant lines as
  the line-set diff between backup and pristine baseline (when available;
  falls back to "every significant backup line" when no pristine — over-broad
  but the safe direction for this bug class). Asserts each line appears
  literally in the merged installed file via String.prototype.includes.
  Filters trivial lines (length < 12 chars, pure punctuation, decorative
  comments) so harmless drift doesn't trigger false failures. Exits 0 on
  pass, 1 on any miss with per-file diagnostic, 2 on usage error.
  Supports --json for workflow consumption.

- get-shit-done/workflows/reapply-patches.md: rewrite Step 5 to call the
  script and parse its JSON output. The Step 4 Hunk Verification Table
  remains as advisory Claude-readable summary, but the gate is now the
  script's exit code.

- tests/bug-2969-verify-reapply-patches.test.cjs (new): 6 tests covering
  (a) pass when every line survives, (b) fail when a line is missing,
  (c) fail when the merged file is deleted entirely, (d) --json structured
  report shape, (e) backup-meta.json is correctly skipped as metadata,
  (f) no-pristine-dir fallback exercises the safe over-broad path. All pass.

Out of scope: the manifest-baseline tightening described in #2969 Failure 1
(saveLocalPatches comparing against the wrong baseline so prior silent wipes
poison subsequent updates). That's a separate, bigger architectural change
involving pristine-content infrastructure; this PR addresses the gate fidelity
half so users at least see the diagnostic when content goes missing.

Closes #2969 (partial — Failure 2 only)

* fix(#2969): preserve #1999 Hunk Verification Table assertions alongside new script gate

CI failure on PR #2972 surfaced that tests/reapply-patches.test.cjs (the
#1999 contract) asserts Step 5 references:
  - "Hunk Verification Table"
  - `verified: no` failure condition
  - explicit STOP/halt/abort directive
  - "table absent / missing" halt path

My initial Step 5 rewrite for #2969 substituted the deterministic script
for the table-based gate entirely, stripping those references. The script
is the strictly stronger gate, but the existing #1999 test enforces the
table-based safety net as a defense-in-depth contract.

Restore both gates as a layered Step 5:

  - 5a (binding): deterministic verifier script — script gate, exits
    non-zero on any miss, cannot be shortcut by the LLM
  - 5b (advisory): Hunk Verification Table review — preserved as
    redundant safety net for the case where the script has a bug or the
    pristine baseline is unavailable

Both gates must pass. Verified: tests/reapply-patches.test.cjs (5 tests
in the #1999 suite) and tests/bug-2969-verify-reapply-patches.test.cjs
(6 tests in the #2969 suite) all pass — 21/21 total in this fixture.

* fix(#2969): address CodeRabbit findings on workflow + script

Five CR findings on PR #2972, all valid; addressed in this commit:

1. (Major) Stderr was merged into VERIFY_OUTPUT via `2>&1`, so any Node
   warning, deprecation notice, or stack trace would corrupt the JSON
   parse downstream. Capture stdout only; stderr remains on the
   controlling terminal for operator visibility.

2. (Major) verifyFile() crashed with EISDIR/EACCES instead of producing
   a structured diagnostic when the installed path was a directory or
   unreadable. Wrap statSync/readFileSync in try/catch and emit a
   per-file fail row; the whole-run gate continues with structured
   output. Added test case asserting the directory-at-installed-path
   case fails with `not a regular file` diagnostic instead of crashing.

3. (Minor) PRISTINE_FLAG built as a single string + unquoted expansion
   would split paths with spaces. Switched to a bash array (VERIFY_ARGS)
   that preserves whitespace through expansion.

4. (Minor) Fenced code block missing language tag (markdownlint MD040).
   Added `text` tag to the error message block.

5. (Minor) Usage comment said pristine fallback was "backup-meta lookup"
   but the actual code path falls back to significant-line checks from
   backup content. Corrected the comment to match implementation.

Verified all 21 tests in tests/reapply-patches.test.cjs (#1999 contract)
+ tests/bug-2969-verify-reapply-patches.test.cjs (now 7 tests with the
new directory case) pass.

* test(#2969): structured JSON assertions, no substring matching on script output

Replace every assert.match(r.stdout, /pattern/) call with structured
assertions on the parsed JSON report from the script's own --json mode.
The script's --json contract IS the structured shape we test against —
the test author should never depend on the human-readable formatter
output, just as no test should depend on substring presence in source.

Changes:

  - All 7 tests now run the verifier with --json (via a runVerifier()
    helper) and parse the resulting JSON document into { status, report,
    stderr }. Diagnostic stderr is preserved as a separate channel for
    debug output but is not used for assertions.
  - Each previously substring-matched diagnostic ("Failures: 1",
    "not a regular file", "installed file missing after merge",
    file path, dropped line) is now a deepEqual / equal / Array.includes
    against typed report fields: report.failures, report.results[i].status,
    report.results[i].reason, report.results[i].file,
    report.results[i].missing[].
  - Added an explicit "documented shape" test asserting the JSON output
    has exactly the keys { file, missing, reason, status } per result —
    locks the public contract of the --json mode.
  - DRY'd up fixture reset into a resetFixture() helper since every test
    starts with a fresh patches/installed/pristine triple.

Linter: scripts/lint-no-source-grep.cjs reports 0 violations across 348
test files. Combined run of bug-2969-...test.cjs (7 tests) +
reapply-patches.test.cjs (5 tests in the #1999 suite) all pass —
22/22 in the relevant fixture.

* fix(#2969): typed REASON enum + raw-text-matching rule shipped repo-wide

This commit closes the loop on the no-source-grep discipline:

1. scripts/verify-reapply-patches.cjs:
   - Frozen REASON enum exposes the diagnostic surface as stable codes:
     OK_NO_USER_LINES_VS_PRISTINE, OK_NO_SIGNIFICANT_BACKUP_LINES,
     FAIL_INSTALLED_MISSING, FAIL_INSTALLED_NOT_REGULAR_FILE,
     FAIL_READ_ERROR, FAIL_USER_LINES_MISSING.
   - Each result.reason is now a code from this enum, not free text.
     Tests assert via REASON.X equality, not regex on prose.
   - REASON exported from module.exports.

2. tests/bug-2969-verify-reapply-patches.test.cjs:
   - Full rewrite. Every assertion on typed structured fields:
     report.results[0].status === 'fail',
     report.results[0].reason === REASON.FAIL_INSTALLED_NOT_REGULAR_FILE,
     report.results[0].missing.includes(droppedLine) (Array set membership,
     not String substring).
   - Locks the REASON enum surface via Object.keys(REASON).sort() deepEqual.
   - Locks the JSON report shape via Object.keys(report).sort() deepEqual.
   - Zero regex, zero String#includes, zero startsWith/endsWith on text.

3. CONTRIBUTING.md:
   - New section "Prohibited: Raw Text Matching on Test Outputs" with
     concrete BAD/GOOD examples (substring on file content; assert.match
     on stdout; "structured parser" hiding string ops; regex on free-form
     reason fields).
   - The rule statement: "Tests assert on typed structured values. If
     the code under test produces text, the code under test must also
     expose a structured intermediate representation, and the test must
     assert on that IR — never on the rendered text."
   - Required structured-surface table: file IR, --json mode, frozen
     enum, fs facts.
   - "Hiding grep behind a function is still grep" callout — the
     parser-wrapper anti-pattern.
   - New `pre-existing-text-matching` exemption category for the 8
     grandfathered files. Marked Transitional; new tests cannot use it.

4. scripts/lint-no-source-grep.cjs:
   - Three new patterns enforced (in addition to the existing .cjs-source
     readFileSync rule):
     - assert.match/doesNotMatch on .stdout/.stderr
     - .stdout/.stderr.<includes|startsWith|endsWith>(
     - readFileSync(...).<includes|startsWith|endsWith>(
   - Aggregated violations per file (multiple findings now report together).
   - Updated diagnostic message references both CONTRIBUTING.md sections.

5. 8 pre-existing tests annotated with `// allow-test-rule:
   pre-existing-text-matching` so the lint passes on this commit; each
   carries the prose "Tracked for migration to typed-IR assertions; do
   not copy this pattern." Files: bug-2649, bug-2687, bug-2796, bug-2838,
   bug-2943, graphify, hooks-opt-in, security-scan.

Verification: lint 0 violations across 348 test files; full suite passes.

* fix(#2969): rename exemption category to pending-migration-to-typed-ir + cite tracking issue

Per maintainer feedback:
1. "Grandfathered" / "legacy" framing is wrong — both terms imply
   permanent or condoned exemption. The 8 files are tracked for
   correction, not exempted.
2. Each annotated file must cite the tracking issue so the migration
   work is auditable.

Changes:
- CONTRIBUTING.md: rename exemption category from
  `pre-existing-text-matching` to `pending-migration-to-typed-ir`. Update
  prose to "Tracked for correction, not exempted" and require each
  annotation to cite the open migration issue (e.g.
  `// allow-test-rule: pending-migration-to-typed-ir [#NNNN]`).
- 8 test files: update annotation to cite #2974 (the tracking issue
  opened for migrating these files to typed-IR assertions).

(cherry picked from commit ef43f5161f)
2026-05-01 20:19:39 +00:00
Tom Boucher
5fc802dd22 fix(#2962): write npm-style gsd-sdk shim on Windows under --sdk install (#2971)
* fix(#2962): write npm-style gsd-sdk shim on Windows under --sdk install

trySelfLinkGsdSdk previously contained `if (process.platform === 'win32')
return null;` — a missed gap from #2775's POSIX self-link rather than an
intentional design choice. As a result, `npx get-shit-done-cc@latest
--claude --global --sdk` on Windows left `gsd-sdk` off PATH despite the
installer reporting success, and the obvious recovery (`npm i -g
@gsd-build/sdk`) lands the stale 0.1.0 publication that lacks the `query`
subcommand the agents call ~40 times.

This PR addresses the shim half. The npm-publish half (publishing
@gsd-build/sdk at parity with the get-shit-done-cc version) requires
maintainer credentials and is left for separate action.

Changes:

- bin/install.js: replace the unconditional Windows return-null with
  dispatch to a new trySelfLinkGsdSdkWindows() that:
  * resolves npm's global bin via `execFileSync('npm', ['prefix', '-g'])`
    (no shell interpolation; npm is the only PATH-resolved binary)
  * verifies write access with a probe before producing partial state
  * writes the standard npm shim triple to npm's global bin:
    - gsd-sdk.cmd (cmd.exe; CRLF line endings)
    - gsd-sdk.ps1 (PowerShell)
    - gsd-sdk    (Bash wrapper for Cygwin/MSYS/Git-Bash)
  * each shim invokes `node "<absolute path to bin/gsd-sdk.js>"` with the
    passed args, decoupling shim location from SDK location — same logical
    structure as the POSIX wrapper-via-require() fallback above
  * unlinks any stale shims before writing so prior installs don't pin
    callers to a now-absent path
  * returns the .cmd path on success (handle the existing onPath check
    looks for) or null on any failure, falling through to the existing
    "gsd-sdk is not on your PATH" warning at line 8704

- tests/bug-2962-windows-sdk-shim.test.cjs (new): 5 tests exercising
  trySelfLinkGsdSdkWindows directly with cp.execFileSync mocked to redirect
  npm prefix to a temp dir. Asserts shim contents reference the absolute
  path, .cmd uses CRLF, stale shims are replaced not appended, and null is
  returned when `npm prefix -g` fails.

- tests/no-unconditional-win32-skip.test.cjs (new): regression guard
  that fails CI if any future commit re-introduces
  `if (process.platform === 'win32') return null;` (or similar
  skip-only branches) in bin/install.js. Negative test verified by
  transiently re-introducing the bad pattern → guard fired → restored
  → passes.

Out of scope: publishing @gsd-build/sdk@<current> to npm so the natural
`npm i -g @gsd-build/sdk` recovery also lands a usable SDK. That requires
maintainer credentials and is the second half of the issue.

Closes #2962

* fix(#2962): address CodeRabbit findings — execSync for npm.cmd, behavior-based regression guard

CR finding 1 (🟠 Major): Node's child_process docs explicitly call out that
.cmd/.bat files cannot be spawned via execFile/execFileSync without a shell
("Spawning .bat and .cmd files on Windows" section). Since `npm` on Windows
is `npm.cmd`, my use of execFileSync('npm', ['prefix', '-g'], { shell: false })
would have failed on the very platform this PR is meant to fix.

Switched to cp.execSync('npm prefix -g', ...) — matching the existing
convention at line ~8718 which makes the same lookup. Args are static literals
so shell interpolation is not an injection vector.

CR finding 2 (🟠 Major): the source-grep regression test in
tests/no-unconditional-win32-skip.test.cjs violated the repo's no-source-grep
testing standard (CONTRIBUTING.md). Replaced with a behavior-based test that:

  - overrides process.platform to 'win32' via Object.defineProperty
  - mocks cp.execSync to return a temp-dir as npm prefix
  - calls trySelfLinkGsdSdk(shimSrc) and asserts it returns non-null AND
    materializes gsd-sdk.cmd on disk

The behavior guard is strictly stronger than the regex version: it would
catch any equivalent skip pattern (e.g. os.platform() === 'win32', a
typeof-based guard, etc.), not just literal `if (process.platform === 'win32')`
text. Negative-tested by re-introducing the `return null` skip → test fails
with maintainer-quoted diagnostic "trySelfLinkGsdSdk must not silently
return null on Windows; a no-op skip is a missed-parity regression"; restored
→ passes.

Test for Windows shim materialization (bug-2962-windows-sdk-shim.test.cjs)
also updated to mock cp.execSync (matching the new production code path)
instead of cp.execFileSync.

Full suite: 6480/6480 pass.

* test(#2962): make Windows shim tests self-contained per CR

Each test now invokes trySelfLinkGsdSdkWindows() itself before reading
the shim files, so they don't implicitly depend on the earlier test's
side effects. Addresses CR's order-dependence finding.

* test(#2962): structured shim parsing — eliminate substring source-grep

CR found that even after the prior refactor, three tests in the suite
still used .includes()/.startsWith() against shim file content
(cmdContent.includes(\`@node ${jsonQuoted} %*\`) etc.). Substring matching
on file text is the same anti-pattern the no-source-grep standard
forbids — even when the file is one this test wrote — because it asserts
a literal exists rather than that the structured shape is correct.

Replace with three small parsers (parseCmdShim, parsePs1Invocation,
parseBashInvocation) that split each shim into header + invocation
tokens and assert via deepEqual on structured records. The assertions
now check that the .cmd has @ECHO OFF / @SETLOCAL / @node <abs> %* in
that order with exactly 3 meaningful lines, and that the .ps1 and bash
wrappers split into the expected (call, nodeCmd, target, argToken)
tuples.

The stale-shim replacement test was hardened the same way: instead of
proving the absence of a sentinel substring, it now proves the parsed
target equals the new shimSrc and != the old path.

Verified: scripts/lint-no-source-grep.cjs reports 0 violations across
348 test files. The 6-test windows-sdk-shim + win32-skip-guard suite
all pass.

* fix(#2962): expose pure shim IR + tests assert on typed fields, not rendered text

Earlier "structured parser" approach (parseCmdShim / parsePs1Invocation /
parseBashInvocation) was still raw-text manipulation behind a function
wrapper — split('\\r\\n'), trim().split(/\\s+/), content.includes('\\r\\n').
Maintainer was right: hiding grep behind a parser is still grep.

Real fix: refactor production code to expose the structured intermediate
representation, and have tests assert on the IR fields directly.

Production:
- New buildWindowsShimTriple(shimSrc) — pure function, no fs/spawn.
  Returns { invocation: { interpreter, target }, eol: { cmd, ps1, sh },
  fileNames: { cmd, ps1, sh }, render: { cmd: () => string, ... } }.
  The IR is the contract; rendered text is an implementation detail of
  the renderers.
- trySelfLinkGsdSdkWindows now calls buildWindowsShimTriple, looks up
  filenames from triple.fileNames, and writes triple.render[kind]() to
  each target. Same observable behavior, structurally separated.
- buildWindowsShimTriple added to test-mode exports.

Tests (full rewrite — no shim file content is read at any point):
- Layer 1: pure-IR tests assert on triple.invocation.target,
  triple.eol === { cmd: '\\r\\n', ps1: '\\n', sh: '\\n' },
  triple.fileNames === { cmd: 'gsd-sdk.cmd', ... }, and the
  documented IR shape via Object.keys().sort() deepEqual.
- Layer 2: fs/spawn driver tests assert filesystem FACTS:
  - return value equals expected path
  - all three target files exist as regular non-empty files
  - rendered file byte length === Buffer.byteLength of triple.render(kind)
    output (proves the writer writes what the renderer produces, no
    mutation, no truncation, no double-write — without comparing content)
  - mtime advances on rewrite (proves stale-replace behavior)
  - returns null when npm prefix -g throws

No more split, .includes, .startsWith, .endsWith, or substring matching
anywhere in the test suite. Lint clean. 10/10 tests pass.

(cherry picked from commit e9a66da1e7)
2026-05-01 20:19:39 +00:00
Tom Boucher
d94ecc6b3b fix(#2954): align help.md with post-#2824 skill consolidation (#2959)
(cherry picked from commit 0d6abb87ac)
2026-05-01 20:19:39 +00:00
Tom Boucher
ab5df34cab fix(#2957): claude+global post-install instructs restart and skill fallback (#2960)
* fix(#2957): claude+global post-install instructs restart and skill fallback

`npx get-shit-done-cc --claude --global` writes skills to
`~/.claude/skills/gsd-*/SKILL.md` (CC 2.1.88+ format) and removes the
legacy `~/.claude/commands/gsd/`. The post-install message still told
users to type `/gsd-new-project` without mentioning the required Claude
Code restart or the skill-name fallback. On configurations where CC
does not auto-surface skills in the slash menu, users hit "no commands
appear" and assumed the install failed.

Split the post-install message: the existing single-line instruction
stays for every non-Claude runtime and for `--claude --local`. For
`--claude --global` it now reads:

  Restart Claude Code, then in any directory either type
  /gsd-new-project or ask Claude to run the gsd-new-project skill.

This covers both invocation paths and surfaces the restart requirement.

Add tests/bug-2957-claude-global-postinstall-message.test.cjs as a
regression guard: captures the printed message for claude+global,
claude+local, and opencode+global; asserts content for each. Verified
the test fails on main (pre-fix) and passes after the fix.

Closes #2957

* test(#2957): assert legacy generic instruction is replaced not extended

CodeRabbit flagged that the test would still pass if the new restart/
fallback copy were printed *alongside* the old 'open a blank directory'
instruction. Adding a doesNotMatch assertion proves the claude+global
branch replaces the legacy line rather than appending to it.

(cherry picked from commit c5dfdbe42e)
2026-05-01 20:19:39 +00:00
javeroff
295af8b550 fix(query/agent-skills): emit raw <agent_skills> block instead of JSON-wrapped string (#2917)
* fix(query/agent-skills): emit raw <agent_skills> block instead of JSON-wrapped string

The CLI dispatcher (`cli.ts`) JSON-stringifies all query handler results via
`console.log(JSON.stringify(result.data, null, 2))`.  For the `agent-skills`
handler this produced a JSON-quoted string literal — e.g.
`"<agent_skills>\n…</agent_skills>"` — which workflows embedded verbatim via
`$(gsd-sdk query agent-skills gsd-planner)`, breaking all `<agent_skills>`
injection into spawned subagent prompts.

Fix: add an optional `format: 'json' | 'text'` field to `QueryResult`.  When a
handler returns `format: 'text'` and `--pick` is not active, the CLI writes the
string directly via `process.stdout.write` instead of JSON-stringifying it.
`agentSkills` sets `format: 'text'` for non-empty blocks.

Regression guard: two new CLI integration tests in `skills.test.ts` spawn the
CLI as a child process and assert that (a) a mapped agent type receives the raw
XML block on stdout and (b) an unmapped agent type produces the existing JSON
empty-string output.

Fixes #2914.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(changelog): add #2917 entry under Unreleased Fixed

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
(cherry picked from commit 9d0d085a17)
2026-05-01 20:19:39 +00:00
Tom Boucher
92bc13e3ee fix(#2948): wire spike --wrap-up flag dispatch (#2951)
* fix(#2948): wire spike --wrap-up flag dispatch

Add dispatch block to commands/gsd/spike.md so that /gsd-spike --wrap-up
routes to the spike-wrap-up workflow instead of silently no-oping. Also
add spike-wrap-up.md to execution_context so the runtime can load it, and
update both companion references in workflows/spike.md from the deleted
/gsd-spike-wrap-up entry-point to /gsd-spike --wrap-up.

Fixes #2948

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

* test(#2948): rewrite dispatch test using parseFrontmatter + section extraction

Replace raw fs.readFileSync + text.includes() / regex assertions with structural
parsing: parseFrontmatter extracts the YAML frontmatter fields and _body,
extractSection pulls named XML blocks, and parseExecutionContextRefs resolves
the @-prefixed workflow references. Assertions now target the argument-hint
frontmatter field, the execution_context @-ref list, and the routing text within
<context>/<process> sections — not arbitrary substrings in the raw file.

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

* test(#2948): tighten dispatch assertion to line-level rule check

Replace the co-occurrence check (dispatchText.includes('--wrap-up') &&
dispatchText.includes('spike-wrap-up')) with line-level assertions that parse
the <process> section's rules array, find the exact '- If it is `--wrap-up`:'
line, verify it includes 'strip the flag' and 'spike-wrap-up', and assert the
'- Otherwise:' fallback still routes to the spike workflow.

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

* test(#2948): anchor parseFrontmatter to line 0 to avoid mid-file --- delimiters

parseFrontmatter was scanning the whole file for the first two '---' lines,
which can match a mid-document horizontal rule as the opening delimiter.
Now requires lines[0].trim() === '---'; returns { _body: content } for files
with no frontmatter, and searches for the closing '---' from line 1 onward.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit ec07861228)
2026-05-01 20:19:39 +00:00
Tom Boucher
da969c1059 fix(#2950): update stale deleted-command references in workflow files (#2952)
* fix(#2950): update stale deleted-command references in workflow files

Eight workflow files (help.md, do.md, settings.md, discuss-phase.md,
new-project.md, plan-phase.md, spike.md, sketch.md) referenced command
names removed in #2790. Updated all occurrences to canonical new forms:
  /gsd-phase (--insert / --remove), /gsd-capture, /gsd-config (--profile
  / --integrations / --advanced), /gsd-spike --wrap-up,
  /gsd-sketch --wrap-up, /gsd-code-review --fix.
Adds regression test (124 assertions) in tests/bug-2950-stale-command-refs.test.cjs.

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

* test(#2950): update pre-existing assertions to accept new consolidated command forms

gsd-settings-advanced.test.cjs and settings-integrations.test.cjs were checking
settings.md for the old micro-skill names (/gsd-settings-advanced,
/gsd-settings-integrations). Now that #2950 updates settings.md to use the
consolidated equivalents, broaden the assertions to accept both old and new forms.

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

* test(#2950): require canonical command forms and forbid legacy variants

The broadened OR assertions added to unblock CI were too permissive — they
could pass with legacy names still present. Now assert the canonical form is
present (gsd-config --advanced / gsd-config --integrations) AND the legacy
forms are absent (gsd-settings-advanced, gsd:settings-advanced,
/gsd-settings-integrations).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 3ba17e872e)
2026-05-01 20:19:39 +00:00
Tom Boucher
7bc150cd37 fix(#2949): wire sketch --wrap-up flag dispatch (#2953)
* fix(#2949): wire sketch --wrap-up flag dispatch

Add dispatch logic to commands/gsd/sketch.md so --wrap-up routes to the
sketch-wrap-up workflow instead of silently falling through to the normal
sketch workflow. Also adds sketch-wrap-up.md to execution_context and
updates companion references in workflows/sketch.md from the deleted
/gsd-sketch-wrap-up command to /gsd-sketch --wrap-up.

Fixes #2949

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

* fix(#2949): use exact-match "If it is" instead of "If it contains" for --wrap-up dispatch

Aligns with the established pattern across all consolidated commands
(workspace.md, update.md, progress.md) where the first-token check uses
"If it is `--flag`" for exact equality, not substring matching.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 4d628b306a)
2026-05-01 20:19:39 +00:00
Tom Boucher
e8706cd686 fix(code-review): wire --fix dispatch and update stale command references (#2947)
* fix(#2893): surface non-canonical plan filenames instead of silently returning zero plans

Reporter saw `plan_count: 0` from `/gsd:execute-phase` even though five
plan files existed on disk. Investigation showed the planner had written
files like `01-PLAN-01-foundation.md`, while `phase-plan-index`'s strict
filter (`f.endsWith('-PLAN.md') || f === 'PLAN.md'`) rejected them
silently — collapsing two distinct states into the same `plans: []`
return:

  - directory truly has no plans (legit empty)
  - directory has plans but the filter rejected them (user/agent error)

The canonical contract is documented in three places:
  - `agents/gsd-planner.md` write_phase_prompt step (lines 1063-1080)
  - `commands/gsd/plan-phase.md`
  - `references/universal-anti-patterns.md` (rule 26)

It mandates `{padded_phase}-{NN}-PLAN.md` and explicitly forbids
`PLAN-NN.md` / `01-PLAN-01.md` / `plan-NN.md` etc. The strict filter is
correct per that contract. The bug is that the executor never tells the
user when the contract was violated — they just see `plan_count: 0`
with no signal.

Fix: add a diagnostic helper `describeNonCanonicalPlans()` that scans
the phase directory for files matching `*PLAN*.md` (the diagnostic net)
that the canonical filter rejected, excluding legit derivatives like
`*-PLAN-OUTLINE.md` and `*-PLAN.pre-bounce.md`. When offenders exist,
return a `warning` field naming each one and citing the canonical
pattern so the user knows what to rename to.

Wired into the three filter sites:
  - `phase-plan-index` (the executor's main entry point)
  - `phases list --type plans`
  - `find-phase`

The strict filter itself is unchanged — existing canonical plans behave
identically. This is purely a diagnostic that converts silent-empty
into loud-with-actionable-error.

Tests:
  - `phase-plan-index returns warning for reporter's exact filename
    pattern (`01-PLAN-01-foundation.md`)`
  - `truly empty dir does not emit a warning`
  - `canonical plans + outline + pre-bounce files do not emit a warning`

Closes #2893

* test(#2893): add parity tests for find-phase and phases list --type plans warnings

CodeRabbit's only finding on the prior commit: I wired the warning into
three filter sites (`phase-plan-index`, `find-phase`,
`phases list --type plans`) but only `phase-plan-index` had test
coverage for the warning shape. The other two paths could silently
diverge during future refactors — exactly the silent-drift class of bug
this fix exists to prevent.

Add four parity tests mirroring the existing two:

  - find-phase: non-canonical filenames produce a warning naming each
    offender + citing the canonical pattern.
  - find-phase: canonical plan + derivative files (PLAN-OUTLINE,
    pre-bounce) produce no warning.
  - phases list --type plans: same non-canonical case, but assert the
    warning is prefixed with `${dir}: ` (this path aggregates across
    phase directories so each offender is tagged with its dir).
  - phases list --type plans: canonical case, no warning.

`node --test tests/phase.test.cjs`: 98/98 pass (was 94, +4 new).

(cherry picked from commit b328f3269f)
2026-05-01 20:19:39 +00:00
Tom Boucher
4d47f2de7b fix(install): honour --minimal across every runtime + manifest fix for Claude local (#2940)
* fix(install): record commands/gsd in manifest for Claude local + per-runtime --minimal coverage

writeManifest gated commands/gsd/ recording to Gemini, leaving Claude
Code local installs with an incomplete manifest. Audit during #2923
investigation showed every runtime adapter correctly honours --minimal
on disk (6 skills, 0 agents) — but Claude local manifest reported 0
skills, breaking saveLocalPatches() drift detection and any downstream
tooling that reads manifest.files for the installed surface.

Drop the isGemini gate so any runtime that writes commands/gsd/ has
those files hashed into the manifest.

Adds tests/install-minimal-all-runtimes.test.cjs: spawns the installer
end-to-end for all 14 supported runtimes in both --global and --local
modes, parses the manifest JSON, and asserts mode === 'minimal',
skill set equals MINIMAL_SKILL_ALLOWLIST, and zero gsd-* agents are
recorded. Cross-checks the manifest against on-disk skill files.

Closes #2923

* test(install): address CR feedback on bug-2923 minimal-runtime tests

- Assert installer exit status in runInstall() so failing installs do not
  produce misleading downstream artifact assertions; include stderr in the
  failure message for debuggability.
- Guard the on-disk vs manifest parity loop with assert.ok(manifest, ...)
  so the equality check cannot pass accidentally when the manifest is
  missing.

(cherry picked from commit 7cc6358f91)
2026-05-01 20:19:39 +00:00
Tom Boucher
4f2a29aaa5 fix(workflows): assert HEAD on per-agent branch before worktree commits (#2924) (#2941)
* fix(workflows): assert HEAD on per-agent branch before worktree commits

Worktree-mode setup could leave HEAD attached to a protected branch (master),
causing agent commits to land there. The previous response was a destructive
self-recovery via 'git update-ref refs/heads/master <sha>', which silently
rewinds the protected branch and destroys concurrent commits in multi-active
scenarios (parallel agents, user committing while agent runs).

- Reorder <worktree_branch_check> in execute-phase.md and quick.md to assert
  HEAD via 'git symbolic-ref' BEFORE any 'git reset --hard'. HALT with a
  blocker if HEAD is on main/master/develop/trunk/release/* or detached.
- Add a per-commit HEAD assertion (step 0) to gsd-executor.md
  <task_commit_protocol>; HEAD attachment can drift after 'git checkout <sha>'.
- Forbid 'git update-ref refs/heads/<protected>' in
  <destructive_git_prohibition>; surface the blocker rather than self-heal.
- Remove '--no-verify' as the worktree-mode default in execute-phase.md,
  execute-plan.md, quick.md, and references/git-integration.md. Hooks now
  run on every executor commit; opt out only via workflow.worktree_skip_hooks.
- Add regression test that parses the worktree_branch_check blocks structurally
  and asserts the symbolic-ref check precedes the reset --hard, no workflow
  performs update-ref on a protected ref, and --no-verify is no longer the
  default in any parallel-execution prompt.

* fix(#2924): address CodeRabbit review findings on worktree HEAD PR

- Add positive worktree-agent-* allow-list to <task_commit_protocol> step 0
  in gsd-executor.md and to <worktree_branch_check> in execute-phase.md and
  quick.md. The deny-list (main|master|develop|trunk|release/*) silently
  allowed feature/* and other arbitrary branches outside the agent namespace.
- Register workflow.worktree_skip_hooks in both config schemas
  (sdk/src/query/config-schema.ts and get-shit-done/bin/lib/config-schema.cjs)
  and document it in docs/CONFIGURATION.md so config-set accepts it.
- Fix stash lifecycle in execute-phase.md post-wave hook validation: stash
  under a named ref and pop after the hook run; warn on pop failure.
- Pre-dispatch PLAN.md commit in quick.md: gate on git diff --cached --quiet
  for idempotency and exit 1 with a clear error on commit failure (both the
  --no-verify and the normal branches) — no more swallowing real errors.
- Test fixes (tests/bug-2924-worktree-head-attachment.test.cjs):
  - Parse the protected-branch alternation structurally and require
    main, master, develop, trunk, release/.* (release/* was previously
    skipped by the \\b...\\b regex).
  - Use fs.readdirSync(dir, { recursive: true }) so workflows in nested
    subdirectories are also asserted against the update-ref ban.
  - Add allow-list assertions for execute-phase.md, quick.md, and
    gsd-executor.md to lock in the new positive namespace check.

* test(#2924): assert sub-section end marker exists before slicing

* test(#2924): use section boundary instead of fixed window for parallel-agents slice

(cherry picked from commit 8de8acee46)
2026-05-01 20:19:39 +00:00
Tom Boucher
9e1b49d7b7 fix(config-get): return schema default for context_window when absent (#2944)
* fix(config-get): return schema default for context_window when absent (#2943)

cmdConfigGet in bin/lib/config.cjs now consults a SCHEMA_DEFAULTS map before
emitting "Key not found", so context_window (and any future schema-defaulted
keys) return their default value (exit 0) when not set in config.json.

Also updates the stale subagent-timeout.test.cjs assertion that expected the
old broken behavior (exit 1 / "Key not found") to match the corrected behavior.

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

* test: use distinct sentinel to prove --default wins over schema default (#2943)

* docs: update CHANGELOG.md for #2943 fix

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 2cc8796265)
2026-05-01 20:19:39 +00:00
Tom Boucher
952ef2507a fix(detect-custom-files): add skills/ to GSD_MANAGED_DIRS (#2942) (#2945)
After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed
root that the installer wipes on update. GSD_MANAGED_DIRS in gsd-tools.cjs
was missing 'skills', so user-added skill directories (e.g.
skills/custom-skill/SKILL.md) were never walked and silently destroyed
during /gsd-update.

- Add 'skills' to GSD_MANAGED_DIRS so the directory is walked
- Add tests/bug-2942-detect-custom-skills.test.cjs with 5 targeted tests
- Update tests/update-custom-backup.test.cjs: replace the now-incorrect
  "skills/ must NOT be scanned" assertion (written pre-#2790) with a test
  that verifies custom skills ARE detected and GSD-owned skills are not
  falsely flagged

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit faee0287a0)
2026-05-01 20:19:39 +00:00
57 changed files with 3240 additions and 216 deletions

View File

@@ -6,6 +6,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased](https://github.com/gsd-build/get-shit-done/compare/v1.38.5...HEAD)
### Fixed
- **`gsd-sdk query agent-skills` emits raw `<agent_skills>` block instead of JSON-wrapped string** — workflows that embed via `$(gsd-sdk query agent-skills <agent>)` were receiving a JSON-quoted string literal mid-prompt (e.g. `"<agent_skills>\n…"`), silently breaking all `<agent_skills>` injection into spawned subagents. The CLI dispatcher now honors an opt-in `format: 'text'` field on `QueryResult` and writes such results raw via `process.stdout.write`; `--pick` always returns JSON regardless. (#2917)
- **`sketch --wrap-up` now dispatches correctly** — `/gsd-sketch --wrap-up` was silently no-oping because the flag dispatch wiring was omitted when the micro-skill entry point was absorbed in #2790. (#2949)
- **`help.md` no longer advertises eight slash commands removed by the #2824 consolidation** — `/gsd-do`, `/gsd-note`, `/gsd-check-todos`, `/gsd-plant-seed`, `/gsd-research-phase`, `/gsd-list-phase-assumptions`, `/gsd-plan-milestone-gaps`, and `/gsd-join-discord` were removed when 86 skills were folded into 59. `help.md` was not updated alongside, so users typing the documented commands hit *Unknown command*. Each entry is now either rewritten to the surviving flag-based dispatcher (e.g., `/gsd-do …``/gsd-progress --do "…"`, `/gsd-note``/gsd-capture --note`, `/gsd-plant-seed``/gsd-capture --seed`, `/gsd-check-todos``/gsd-capture --list`) or removed for skills with no replacement. A regression test now asserts every `/gsd-*` reference in `help.md` has a matching `commands/gsd/*.md` stub. (#2954)
- **`--sdk` install on Windows now writes a callable `gsd-sdk` shim** — `npx get-shit-done-cc@latest --claude --global --sdk` on Windows previously left `gsd-sdk` off PATH because `trySelfLinkGsdSdk` returned `null` unconditionally on `win32` (a missed gap from #2775's POSIX self-link, not an intentional deferral). The function now dispatches to a Windows counterpart that writes the standard npm shim triple (`gsd-sdk.cmd`, `gsd-sdk.ps1`, and a Bash wrapper) to npm's global bin, so `gsd-sdk` resolves in a fresh shell across cmd.exe, PowerShell, and Cygwin/MSYS/Git-Bash. A new regression guard in `tests/no-unconditional-win32-skip.test.cjs` blocks any future `if (process.platform === 'win32') return null;` skip-only branches in `bin/install.js`. (#2962)
- **`/gsd-reapply-patches` Step 5 gate is now deterministic — no more silent content drops** — the prior gate parsed a Claude-generated *Hunk Verification Table* whose `verified: yes` rows were filled in without actually checking content presence, leading to merged files that lost user-added blocks (e.g., a `<visual_companion>` section, an `--execute-only` flag block) while the workflow reported success. The gate now invokes a Node script (`scripts/verify-reapply-patches.cjs`) that diffs each backup against the pristine baseline, computes the user-added significant lines, and asserts each one is present in the merged file. Exits non-zero with a per-file diagnostic on any miss; the workflow halts and surfaces the JSON output to the user. The verifier ignores low-signal lines (too short, pure whitespace, decorative comments) so trivial differences don't trigger false failures. Out of scope here: the manifest-baseline tightening described in #2969 Failure 1 — that's separate work. (#2969)
### Added — 1.40.0-rc.1
- **Six namespace meta-skills with keyword-tag descriptions** — replace the flat 86-skill
listing with two-stage hierarchical routing. Model sees 6 namespace routers
@@ -38,7 +46,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
now auto-closes PRs opened without a closing keyword that links a tracking issue,
posting a comment that points to the contribution guide. (#2872)
### Fixed
- **Stale deleted command references updated across workflow files** — `help.md`, `do.md`, `settings.md`, `discuss-phase.md`, `new-project.md`, `plan-phase.md`, `spike.md`, and `sketch.md` referenced command names removed in #2790; updated to new consolidated equivalents. (#2950)
### Fixed — 1.40.0-rc.1
- **`spike --wrap-up` now dispatches correctly** — `/gsd-spike --wrap-up` was silently no-oping because the flag dispatch wiring was omitted when the micro-skill entry point was absorbed in #2790. (#2948)
- **`config-get context_window` returns `200000` when key absent** — querying an unset `context_window` previously exited 1 with "Key not found", surfacing a confusing error in planning logs even though the workflow fallback worked correctly. `cmdConfigGet` now consults a `SCHEMA_DEFAULTS` map and returns the documented default (`200000`, exit 0) for absent schema-defaulted keys; unknown absent keys still error as before. (#2943)
- **`gap-analysis` now parses non-`REQ-` requirement IDs and ignores traceability table headers** — `parseRequirements()` no longer hard-codes the `REQ-` prefix and now accepts uppercase prefixed IDs such as `TST-01`, `BACK-07`, and `INSP-04`; markdown table header rows (for example `| REQ-ID | ... |`) are excluded so header tokens are not reported as phantom uncovered requirements. Added regression coverage for mixed-prefix REQUIREMENTS files with traceability tables. (#2897)
- **Gemini slash commands namespaced as `/gsd:<cmd>` instead of `/gsd-<cmd>`** —
Gemini CLI namespaces commands under `gsd:`, so `/gsd-plan-phase` was unexecutable.

View File

@@ -281,6 +281,7 @@ Some tests legitimately read source files. There are six recognized categories:
| `docs-parity` | A reference doc must stay in sync with source-defined constants (e.g., `CONFIG_DEFAULTS`). The source is the canonical list; there is no runtime API to enumerate it. |
| `integration-test-input` | A source file is used as a real fixture input to a transformation function under test — the file is not inspected for strings but passed as data. |
| `structural-implementation-guard` | A feature's interception or wiring point is not reachable end-to-end via `runGsdTools`. Used temporarily until a behavioral path exists. |
| `pending-migration-to-typed-ir` | **Tracked for correction, not exempted.** Test was identified by the lint as carrying a raw-text-matching pattern that contradicts the rule above. Each annotated file MUST cite the open migration issue (e.g. `// allow-test-rule: pending-migration-to-typed-ir [#NNNN]`) so the tracking is auditable. New tests cannot use this category — they must refactor production to expose typed IR. The annotation is removed when the test is corrected. |
Annotate with a standalone `//` comment before the file's opening block comment:
@@ -296,6 +297,68 @@ Annotate with a standalone `//` comment before the file's opening block comment:
The annotation **must** be a standalone `// allow-test-rule:` line, not inside a `/** */` block comment — the CI linter scans for the pattern `// allow-test-rule:`.
### Prohibited: Raw Text Matching on Test Outputs (file content, stdout, stderr)
**Source-grep is not just `readFileSync` of a `.cjs` file.** The same anti-pattern shows up wherever a test pattern-matches against text that a system-under-test produced, regardless of whether that text came from a source file, a rendered shim, a child process's stdout, or a free-form `reason` string. **All forms are forbidden.**
The following are all violations of the same rule:
```javascript
// BAD — substring match on text written by the code under test
const cmdContent = fs.readFileSync(path.join(tmpDir, 'gsd-sdk.cmd'), 'utf8');
assert.ok(cmdContent.includes(`@node ${jsonQuoted} %*`), '.cmd embeds shim path');
// BAD — regex match on a child process's human-readable stdout formatter
const r = cp.spawnSync(SCRIPT, ['--patches-dir', dir]);
assert.match(r.stdout, /Failures: 1/);
assert.match(r.stdout, /not a regular file/);
// BAD — "structured parser" that hides string ops behind a function wrapper
function parseCmdShim(content) {
const lines = content.split('\r\n').filter((l) => l.length > 0);
return { header: lines[0], usesCRLF: content.includes('\r\n') };
}
// BAD — assert.match on a free-form `reason` string from a JSON report
assert.ok(/not a regular file/.test(report.results[0].reason));
```
Each of these passes on accidental near-matches (a comment containing `@node` somewhere, a stack trace that happens to say `Failures: 1`, a mis-typed reason that still contains the substring you're matching) and fails on harmless reformatting (changing `Failures: 1` to `1 failure`, swapping CRLF rendering style, rewording the error prose).
#### The rule
> **Tests assert on typed structured values. If the code under test produces text, the code under test must also expose a structured intermediate representation, and the test must assert on that IR — never on the rendered text.**
Concretely: for any system-under-test that produces text output (a file renderer, a CLI formatter, an error-message builder), the production code MUST expose a typed alternative that the test consumes:
| Output kind | Required structured surface | What the test asserts on |
|---|---|---|
| Rendered file (shim, template, generated code) | A pure builder function returning the IR (`{ invocation, eol, fileNames, render }`) | `triple.invocation.target === expected`, `triple.eol.cmd === '\r\n'` |
| CLI human-formatter output | A `--json` mode that emits the same data structurally | `report.results[0].reason === REASON.FAIL_INSTALLED_NOT_REGULAR_FILE` |
| Error / status / reason | A frozen enum (`Object.freeze({ FAIL_X: 'fail_x', ... })`) | `assert.equal(result.reason, REASON.FAIL_X)` |
| File presence after a write | `fs.statSync().isFile()`, `.size > 0`, `.mtimeMs` advances | Filesystem facts; never read the file content back |
#### Concrete examples from this repo
`buildWindowsShimTriple(shimSrc)` in `bin/install.js` is the canonical IR pattern: pure function, no I/O, returns `{ invocation, eol, fileNames, render }`. `trySelfLinkGsdSdkWindows` calls it and writes `triple.render[kind]()` to disk. Tests assert on `triple.invocation.target`, `triple.eol.cmd`, `Object.keys(triple).sort()` — never on the rendered text. Filesystem-level tests assert `fs.statSync(target).size === Buffer.byteLength(triple.render.cmd())` to prove the writer writes what the renderer produces, **without comparing content**.
`scripts/verify-reapply-patches.cjs` exposes a frozen `REASON` enum and emits it through `--json`. Tests assert `report.results[0].reason === REASON.FAIL_USER_LINES_MISSING`. The human formatter exists for operator console output only — tests must not depend on its prose. Adding a new reason code requires updating the `REASON` enum, the `--json` output, AND the test that locks `Object.keys(REASON).sort()` — three coordinated changes that prevent the code surface from drifting from the test surface.
#### Hiding grep behind a function is still grep
`parseCmdShim`, `parsePs1Invocation`, etc. that internally do `content.split(...)`, `lines[1].trim()`, `content.includes(...)` are still string manipulation. The fact that the entry point looks like a parser doesn't change what's happening underneath — the test is still asserting on the lexical shape of rendered text. The fix is not "wrap the grep in a function with a typed-looking return value." The fix is to **eliminate the rendered text from the test path entirely** by surfacing the IR.
#### When you cannot eliminate text matching
There are exactly two cases where text content is the legitimate object of a test, both already covered by the existing exemption matrix:
1. `source-text-is-the-product` — workflow `.md` / agent `.md` / command `.md` files where the deployed text IS what the runtime loads.
2. `docs-parity` — a reference doc must mirror source-defined constants and there is no runtime enumeration API.
For everything else, if a test reaches for `.includes()` / `.startsWith()` / `assert.match(text, /…/)`, the production code is missing a typed surface. **Add the typed surface; do not work around it.**
**CI enforcement:** `scripts/lint-no-source-grep.cjs` is being extended (see issue tracker for the latest scope) to flag `String#includes`/`String#startsWith`/`String#endsWith`/`assert.match` on `readFileSync` results and on `cp.spawnSync` stdout/stderr in test files, with the same `// allow-test-rule:` exemption mechanism.
### Node.js Version Compatibility
**Node 22 is the minimum supported version.** Node 24 is the primary CI target. All tests must pass on both.

View File

@@ -358,6 +358,30 @@ If RED or GREEN gate commits are missing, add a warning to SUMMARY.md under a `#
<task_commit_protocol>
After each task completes (verification passed, done criteria met), commit immediately.
**0. Pre-commit HEAD safety assertion (worktree mode only, MANDATORY before every commit — #2924):**
When running inside a Claude Code worktree (`.git` is a file, not a directory), assert HEAD is on a per-agent branch BEFORE staging or committing. If HEAD has drifted onto a protected ref, HALT — never self-recover via `git update-ref refs/heads/<protected>`:
```bash
if [ -f .git ]; then # worktree
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Deny-list: never commit on a protected ref.
if [ "$HEAD_REF" = "DETACHED" ] || \
echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
echo "FATAL: refusing to commit — worktree HEAD is on '$ACTUAL_BRANCH' (expected per-agent branch)." >&2
echo "DO NOT use 'git update-ref' to rewind the protected branch — surface as blocker (#2924)." >&2
exit 1
fi
# Positive allow-list: HEAD must be on the canonical Claude Code worktree-agent
# branch namespace (`worktree-agent-<id>`). This catches feature/* and any other
# arbitrary branch that the deny-list would silently allow (#2924).
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
echo "FATAL: refusing to commit — worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace." >&2
echo "Agent commits must live on per-agent branches; surface as blocker (#2924)." >&2
exit 1
fi
fi
```
**1. Check modified files:** `git status --short`
**2. Stage task-related files individually** (NEVER `git add .` or `git add -A`):
@@ -426,6 +450,15 @@ back, those deletions appear on the main branch, destroying prior-wave work (#20
- `git rm` on files not explicitly created by the current task
- `git checkout -- .` or `git restore .` (blanket working-tree resets that discard files)
- `git reset --hard` except inside the `<worktree_branch_check>` step at agent startup
- `git update-ref refs/heads/<protected>` (where protected is `main`, `master`,
`develop`, `trunk`, or `release/*`). This is an absolute prohibition (#2924).
If you discover that your worktree HEAD is attached to a protected branch and your
commits landed there, **DO NOT** "recover" by force-rewinding the protected ref —
that silently destroys concurrent commits in multi-active scenarios (parallel
agents, user committing while you run). HALT and surface a blocker. The setup-time
`<worktree_branch_check>` and per-commit `<pre_commit_head_assertion>` are the
correct prevention; if either fails, the workflow MUST stop, not self-heal.
- `git push --force` / `git push -f` to any branch you did not create.
If you need to discard changes to a specific file you modified during this task, use:
```bash

View File

@@ -6732,7 +6732,12 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
if (USER_OWNED_ARTIFACTS.includes(rel)) continue;
manifest.files['get-shit-done/' + rel] = hash;
}
if (isGemini && fs.existsSync(commandsDir)) {
// Record commands/gsd/ for any runtime that emits it (Gemini globally,
// Claude Code locally — see #2923). Manifest must reflect everything on
// disk so saveLocalPatches() can detect user edits and so per-runtime
// assertions about minimal-mode emit can read manifest.files instead of
// re-walking the dir.
if (fs.existsSync(commandsDir)) {
const cmdHashes = generateManifest(commandsDir);
for (const [rel, hash] of Object.entries(cmdHashes)) {
manifest.files['commands/gsd/' + rel] = hash;
@@ -8145,6 +8150,20 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
if (runtime === 'cline') command = '/gsd-new-project';
if (runtime === 'qwen') command = '/gsd-new-project';
if (runtime === 'hermes') command = '/gsd-new-project';
// Claude Code global installs use the skills/ format (CC 2.1.88+).
// Restart is required for CC to pick up newly-installed skills, and the
// slash-menu surface depends on CC version — so the instruction needs to
// cover both invocation paths to avoid #2957-style "no commands appear".
if (runtime === 'claude' && isGlobal) {
console.log(`
${green}Done!${reset} Restart ${program}, then in any directory either type ${cyan}${command}${reset} or ask Claude to run the ${cyan}gsd-new-project${reset} skill.
${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r
`);
return;
}
console.log(`
${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
@@ -8749,7 +8768,9 @@ function isGsdSdkOnPath() {
* primitive there; we don't try to fabricate a .cmd shim).
*/
function trySelfLinkGsdSdk(shimSrc) {
if (process.platform === 'win32') return null;
if (process.platform === 'win32') {
return trySelfLinkGsdSdkWindows(shimSrc);
}
const path = require('path');
const fs = require('fs');
const home = os.homedir();
@@ -8806,6 +8827,123 @@ function trySelfLinkGsdSdk(shimSrc) {
return null;
}
/**
* #2962: Windows counterpart to trySelfLinkGsdSdk. Prior to this, the function
* unconditionally returned null on Windows ("we don't try to fabricate a .cmd
* shim there"), which left `--sdk --global` installs without a callable
* `gsd-sdk` on PATH despite the installer reporting success.
*
* Strategy: discover npm's global bin directory via `npm prefix -g` (which on
* Windows IS the bin dir, no `bin/` suffix — see line 8721) and write the same
* three-file shim set npm itself emits: `gsd-sdk.cmd` (cmd.exe), `gsd-sdk.ps1`
* (PowerShell), and a Bash wrapper named `gsd-sdk` (for Cygwin/MSYS/Git-Bash).
* Each shim invokes `node "<absolute path to bin/gsd-sdk.js>"` with passed
* args so the shim location is decoupled from the SDK location — same logical
* structure as the POSIX wrapper-via-require() fallback above.
*
* Returns the .cmd file path on success (the primary handle the installer's
* onPath check looks for), null otherwise.
*/
/**
* Pure builder: compute the structured Windows shim triple from a shimSrc path.
* No filesystem I/O, no spawn — produces the IR that `trySelfLinkGsdSdkWindows`
* then renders to disk. Exposed for tests so assertions can run against typed
* fields (interpreter, shimAbs, eol, fileNames) instead of substring matches
* over rendered shim text.
*/
function buildWindowsShimTriple(shimSrc) {
const path = require('path');
const shimAbs = path.resolve(shimSrc);
// JSON.stringify produces a double-quoted string with backslash+quote
// escaping — the safe quoting form for cmd.exe and PowerShell paths alike.
const shimQuoted = JSON.stringify(shimAbs);
const invocation = {
interpreter: 'node',
target: shimAbs,
};
// Renderers are template literals — the only place text is constructed.
// Tests do not parse these strings; they assert on the typed fields above.
const renderCmd = () =>
'@ECHO OFF\r\n@SETLOCAL\r\n@node ' + shimQuoted + ' %*\r\n';
const renderPs1 = () =>
'#!/usr/bin/env pwsh\n& node ' + shimQuoted + ' $args\nexit $LASTEXITCODE\n';
const renderSh = () =>
'#!/usr/bin/env sh\nexec node ' + shimQuoted + ' "$@"\n';
return {
invocation,
eol: { cmd: '\r\n', ps1: '\n', sh: '\n' },
fileNames: { cmd: 'gsd-sdk.cmd', ps1: 'gsd-sdk.ps1', sh: 'gsd-sdk' },
render: { cmd: renderCmd, ps1: renderPs1, sh: renderSh },
};
}
function trySelfLinkGsdSdkWindows(shimSrc) {
const path = require('path');
const fs = require('fs');
const cp = require('child_process');
let npmPrefix;
try {
// On Windows, `npm` is `npm.cmd` — Node's child_process docs explicitly
// call out that .cmd/.bat files cannot be spawned via execFile/execFileSync
// without a shell ("Spawning .bat and .cmd files on Windows" section).
// Match the existing convention at line ~8718 which uses execSync for the
// same `npm prefix -g` lookup. Inputs here are static literals, so shell
// interpolation is not an injection vector.
npmPrefix = cp
.execSync('npm prefix -g', {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
})
.trim();
} catch {
return null;
}
if (!npmPrefix || !fs.existsSync(npmPrefix)) return null;
// Verify writability before producing partial shim sets.
try {
fs.mkdirSync(npmPrefix, { recursive: true });
const probe = path.join(npmPrefix, '.gsd-sdk-write-probe');
fs.writeFileSync(probe, '');
fs.unlinkSync(probe);
} catch {
return null;
}
const triple = buildWindowsShimTriple(shimSrc);
const targets = {
cmd: path.join(npmPrefix, triple.fileNames.cmd),
ps1: path.join(npmPrefix, triple.fileNames.ps1),
sh: path.join(npmPrefix, triple.fileNames.sh),
};
try {
// Replace any existing shims — they may be stale (prior install of an
// older version pointing at a now-absent shim path).
for (const target of Object.values(targets)) {
try { fs.unlinkSync(target); } catch {}
}
fs.writeFileSync(targets.cmd, triple.render.cmd());
fs.writeFileSync(targets.ps1, triple.render.ps1());
fs.writeFileSync(targets.sh, triple.render.sh());
// chmod is a no-op on Windows-native node but harmless; sets exec bit on
// WSL-mounted filesystems where Bash users live.
try { fs.chmodSync(targets.sh, 0o755); } catch {}
return targets.cmd;
} catch {
// Partial-write on permission flap — best-effort cleanup so the next run
// starts from a clean slate.
for (const target of Object.values(targets)) {
try { fs.unlinkSync(target); } catch {}
}
return null;
}
}
/**
* Install GSD for all selected runtimes
*/
@@ -8935,6 +9073,10 @@ if (process.env.GSD_TEST_MODE) {
restoreUserArtifacts,
USER_OWNED_ARTIFACTS,
finishInstall,
trySelfLinkGsdSdk,
trySelfLinkGsdSdkWindows,
buildWindowsShimTriple,
isGsdSdkOnPath,
homePathCoveredByRc,
maybeSuggestPathExport,
runtimeMap,

View File

@@ -30,6 +30,7 @@ Does not require `/gsd-new-project` — auto-creates `.planning/sketches/` if ne
<execution_context>
@~/.claude/get-shit-done/workflows/sketch.md
@~/.claude/get-shit-done/workflows/sketch-wrap-up.md
@~/.claude/get-shit-done/references/ui-brand.md
@~/.claude/get-shit-done/references/sketch-theme-system.md
@~/.claude/get-shit-done/references/sketch-interactivity.md
@@ -50,6 +51,9 @@ Design idea: $ARGUMENTS
</context>
<process>
Execute the sketch workflow from @~/.claude/get-shit-done/workflows/sketch.md end-to-end.
Parse the first token of $ARGUMENTS:
- If it is `--wrap-up`: strip the flag, execute the sketch-wrap-up workflow from @~/.claude/get-shit-done/workflows/sketch-wrap-up.md end-to-end.
- Otherwise: execute the sketch workflow from @~/.claude/get-shit-done/workflows/sketch.md end-to-end.
Preserve all workflow gates (intake, decomposition, target stack research, variant evaluation, MANIFEST updates, commit patterns).
</process>

View File

@@ -30,6 +30,7 @@ Does not require `/gsd-new-project` — auto-creates `.planning/spikes/` if need
<execution_context>
@~/.claude/get-shit-done/workflows/spike.md
@~/.claude/get-shit-done/workflows/spike-wrap-up.md
@~/.claude/get-shit-done/references/ui-brand.md
</execution_context>
@@ -47,6 +48,9 @@ Idea: $ARGUMENTS
</context>
<process>
Execute the spike workflow from @~/.claude/get-shit-done/workflows/spike.md end-to-end.
Parse the first token of $ARGUMENTS:
- If it is `--wrap-up`: strip the flag, execute the spike-wrap-up workflow from @~/.claude/get-shit-done/workflows/spike-wrap-up.md.
- Otherwise: pass all of $ARGUMENTS as the idea to the spike workflow from @~/.claude/get-shit-done/workflows/spike.md end-to-end.
Preserve all workflow gates (prior spike check, decomposition, research, risk ordering, observability assessment, verification, MANIFEST updates, commit patterns).
</process>

View File

@@ -191,6 +191,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 |
| `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 |
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31 |
| `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). |
| `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review-fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 |
| `workflow.code_review_depth` | string | `standard` | Default review depth for `/gsd-code-review`: `quick` (pattern-matching only), `standard` (per-file analysis), or `deep` (cross-file with import graphs). Can be overridden per-run with `--depth=`. Added in v1.34 |
| `workflow.plan_bounce` | boolean | `false` | Run external validation script against generated plans. When enabled, the plan-phase orchestrator pipes each PLAN.md through the script specified by `plan_bounce_script` and blocks on non-zero exit. Added in v1.36 |

View File

@@ -1070,6 +1070,7 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
'agents',
path.join('commands', 'gsd'),
'hooks',
'skills',
];
function walkDir(dir, baseDir) {

View File

@@ -26,6 +26,7 @@ const VALID_CONFIG_KEYS = new Set([
'workflow.skip_discuss',
'workflow.auto_prune_state',
'workflow.use_worktrees',
'workflow.worktree_skip_hooks',
'workflow.code_review',
'workflow.code_review_depth',
'workflow.code_review_command',

View File

@@ -377,6 +377,15 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`);
}
/**
* Schema-level defaults for well-known config keys.
* When a key is absent from config.json and no --default flag was supplied,
* cmdConfigGet checks here before emitting "Key not found".
*/
const SCHEMA_DEFAULTS = {
'context_window': 200000,
};
function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
const configPath = path.join(planningDir(cwd), 'config.json');
const hasDefault = defaultValue !== undefined;
@@ -406,6 +415,11 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
const def = SCHEMA_DEFAULTS[keyPath];
output(def, raw, String(def));
return;
}
error(`Key not found: ${keyPath}`);
}
current = current[key];
@@ -413,6 +427,11 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
if (current === undefined) {
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
const def = SCHEMA_DEFAULTS[keyPath];
output(def, raw, String(def));
return;
}
error(`Key not found: ${keyPath}`);
}

View File

@@ -62,8 +62,11 @@ gsd-sdk query commit "docs: initialize [project-name] ([N] phases)" --files .pla
Each task gets its own commit immediately after completion.
> **Parallel agents:** When running as a parallel executor (spawned by execute-phase),
> use `--no-verify` on all commits to avoid pre-commit hook lock contention.
> The orchestrator validates hooks once after all agents complete.
> run commits normally — let pre-commit hooks run. Do NOT pass `--no-verify` by default
> (#2924). Hooks should fire on the introducing commit; silent bypass violates project
> CLAUDE.md guidance. If a project explicitly opts out via
> `workflow.worktree_skip_hooks=true`, the orchestrator surfaces that flag in the
> executor prompt; absent that signal, hooks run normally.
```
{type}({phase}-{plan}): {task-name}

View File

@@ -252,7 +252,7 @@ RAW_SKETCHES=$(ls .planning/sketches/MANIFEST.md 2>/dev/null)
If findings skills exist, read SKILL.md and reference files; extract validated patterns, landmines, constraints, design decisions. Add them to `<prior_decisions>`.
If raw spikes/sketches exist but no findings skill, note: `⚠ Unpackaged spikes/sketches detected — run /gsd-spike-wrap-up or /gsd-sketch-wrap-up to make findings available.`
If raw spikes/sketches exist but no findings skill, note: `⚠ Unpackaged spikes/sketches detected — run /gsd-spike --wrap-up or /gsd-sketch --wrap-up to make findings available.`
Build internal `<prior_decisions>` with sections for Project-Level (from PROJECT.md / REQUIREMENTS.md), From Prior Phases (per-phase decisions), and From Spike/Sketch Findings (validated patterns, landmines, design decisions).

View File

@@ -44,29 +44,29 @@ Evaluate `$ARGUMENTS` against these routing rules. Apply the **first matching**
| A bug, error, crash, failure, or something broken | `/gsd-debug` | Needs systematic investigation |
| Spiking, "test if", "will this work", "experiment", "prove this out", validate feasibility | `/gsd-spike` | Throwaway experiment to validate feasibility |
| Sketching, "mockup", "what would this look like", "prototype the UI", "design this", explore visual direction | `/gsd-sketch` | Throwaway HTML mockups to explore design |
| Wrapping up spikes, "package the spikes", "consolidate spike findings" | `/gsd-spike-wrap-up` | Package spike findings into reusable skill |
| Wrapping up sketches, "package the designs", "consolidate sketch findings" | `/gsd-sketch-wrap-up` | Package sketch findings into reusable skill |
| Exploring, researching, comparing, or "how does X work" | `/gsd-research-phase` | Domain research before planning |
| Wrapping up spikes, "package the spikes", "consolidate spike findings" | `/gsd-spike --wrap-up` | Package spike findings into reusable skill |
| Wrapping up sketches, "package the designs", "consolidate sketch findings" | `/gsd-sketch --wrap-up` | Package sketch findings into reusable skill |
| Exploring, researching, comparing, or "how does X work" | `/gsd-explore` | Socratic ideation and idea routing |
| Discussing vision, "how should X look", brainstorming | `/gsd-discuss-phase` | Needs context gathering |
| A complex task: refactoring, migration, multi-file architecture, system redesign | `/gsd-add-phase` | Needs a full phase with plan/build cycle |
| A complex task: refactoring, migration, multi-file architecture, system redesign | `/gsd-phase` | Needs a full phase with plan/build cycle |
| Planning a specific phase or "plan phase N" | `/gsd-plan-phase` | Direct planning request |
| Executing a phase or "build phase N", "run phase N" | `/gsd-execute-phase` | Direct execution request |
| Running all remaining phases automatically | `/gsd-autonomous` | Full autonomous execution |
| A review or quality concern about existing work | `/gsd-verify-work` | Needs verification |
| Checking progress, status, "where am I" | `/gsd-progress` | Status check |
| Resuming work, "pick up where I left off" | `/gsd-resume-work` | Session restoration |
| A note, idea, or "remember to..." | `/gsd-add-todo` | Capture for later |
| A note, idea, or "remember to..." | `/gsd-capture` | Capture for later |
| Adding tests, "write tests", "test coverage" | `/gsd-add-tests` | Test generation |
| Completing a milestone, shipping, releasing | `/gsd-complete-milestone` | Milestone lifecycle |
| A specific, actionable, small task (add feature, fix typo, update config) | `/gsd-quick` | Self-contained, single executor |
**Requires `.planning/` directory:** All routes except `/gsd-new-project`, `/gsd-map-codebase`, `/gsd-spike`, `/gsd-sketch`, `/gsd-help`, and `/gsd-join-discord`. If the project doesn't exist and the route requires it, suggest `/gsd-new-project` first.
**Requires `.planning/` directory:** All routes except `/gsd-new-project`, `/gsd-map-codebase`, `/gsd-spike`, `/gsd-sketch`, and `/gsd-help`. If the project doesn't exist and the route requires it, suggest `/gsd-new-project` first.
**Ambiguity handling:** If the text could reasonably match multiple routes, ask the user via AskUserQuestion with the top 2-3 options. For example:
```
"Refactor the authentication system" could be:
1. /gsd-add-phase — Full planning cycle (recommended for multi-file refactors)
1. /gsd-phase — Full planning cycle (recommended for multi-file refactors)
2. /gsd-quick — Quick execution (if scope is small and clear)
Which approach fits better?

View File

@@ -506,40 +506,37 @@ increases monotonically across waves. `{status}` is `complete` (success),
</objective>
<worktree_branch_check>
FIRST ACTION before any other work: verify this worktree's branch is based on the correct commit.
Run:
FIRST ACTION: HEAD assertion MUST run before any reset/checkout. Worktrees
spawned by Claude Code's `isolation="worktree"` use the `worktree-agent-<id>`
namespace. If HEAD is on a protected ref (main/master/develop/trunk/release/*)
or detached, HALT — do NOT self-recover by force-rewinding via `git update-ref`,
that destroys concurrent commits in multi-active scenarios (#2924). Only after
Step 1 passes is `git reset --hard` safe (#2015 — affects all platforms).
```bash
ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE})
```
If `ACTUAL_BASE` != `{EXPECTED_BASE}` (i.e. the worktree branch was created from an older
base such as `main` instead of the feature branch HEAD), hard-reset to the correct base:
```bash
# Safe: this runs before any agent work, so no uncommitted changes to lose
git reset --hard {EXPECTED_BASE}
# Verify correction succeeded
if [ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ]; then
echo "ERROR: Could not correct worktree base — aborting to prevent data loss"
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$HEAD_REF" = "DETACHED" ] || echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
echo "FATAL: worktree HEAD on '$ACTUAL_BRANCH' (expected worktree-agent-*); refusing to self-recover via 'git update-ref' (#2924)." >&2
exit 1
fi
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace; refusing to commit (#2924)." >&2
exit 1
fi
ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE})
if [ "$ACTUAL_BASE" != "{EXPECTED_BASE}" ]; then
git reset --hard {EXPECTED_BASE}
[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && { echo "ERROR: could not correct worktree base"; exit 1; }
fi
```
`reset --hard` is safe here because this is a fresh worktree with no user changes. It
resets both the HEAD pointer AND the working tree to the correct base commit (#2015).
If `ACTUAL_BASE` == `{EXPECTED_BASE}`: the branch base is correct, proceed immediately.
This check fixes a known issue where `EnterWorktree` creates branches from
`main` instead of the current feature branch HEAD (affects all platforms).
Per-commit HEAD assertion lives in `agents/gsd-executor.md` `<task_commit_protocol>` step 0.
</worktree_branch_check>
<parallel_execution>
You are running as a PARALLEL executor agent in a git worktree.
Use --no-verify on all git commits to avoid pre-commit hook contention
with other agents. The orchestrator validates hooks once after all agents complete.
For `gsd-sdk query commit` (or legacy `gsd-tools.cjs` commit): add --no-verify flag when needed.
For direct git commits: use git commit --no-verify -m "..."
Run `git commit` normally — hooks run by default. Do NOT pass `--no-verify`
unless the orchestrator surfaces `workflow.worktree_skip_hooks=true` in this
prompt; silent bypass violates project CLAUDE.md guidance (#2924).
IMPORTANT: Do NOT modify STATE.md or ROADMAP.md. execute-plan.md
auto-detects worktree mode (`.git` is a file, not a directory) and skips
@@ -656,13 +653,16 @@ increases monotonically across waves. `{status}` is `complete` (success),
**This fallback applies automatically to all runtimes.** Claude Code's Task() normally
returns synchronously, but the fallback ensures resilience if it doesn't.
5. **Post-wave hook validation (parallel mode only):**
When agents committed with `--no-verify`, run pre-commit hooks once after the wave:
5. **Post-wave hook validation (parallel mode only):** Hooks run on every executor commit by default (#2924); this post-wave run only fires when `workflow.worktree_skip_hooks=true` opted out of per-commit hooks:
```bash
# Run project's pre-commit hooks on the current state
git diff --cached --quiet || git stash # stash any unstaged changes
git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing"
SKIP_HOOKS=$(gsd-sdk query config-get workflow.worktree_skip_hooks 2>/dev/null || echo "false")
if [ "$SKIP_HOOKS" = "true" ]; then
# Stash uncommitted changes under a named ref so we always pop (bare `git stash` strands them on hook/script failure).
STASHED=false
if (! git diff --quiet || ! git diff --cached --quiet) && git stash push -u -m "gsd-post-wave-hook-$$" >/dev/null 2>&1; then STASHED=true; fi
git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing"
[ "$STASHED" = "true" ] && (git stash pop >/dev/null 2>&1 || echo "⚠ Could not pop gsd-post-wave-hook stash — recover manually")
fi
```
If hooks fail: report the failure and ask "Fix hook issues now?" or "Continue to next wave?"

View File

@@ -81,7 +81,7 @@ Otherwise: Apply checkpoint-based routing below.
| Verify-only | B (segmented) | Segments between checkpoints. After none/human-verify → SUBAGENT. After decision/human-action → MAIN |
| Decision | C (main) | Execute entirely in main context |
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, include a `<worktree_branch_check>` block in the prompt** instructing the executor to run `git merge-base HEAD {EXPECTED_BASE}` and, if the result differs from `{EXPECTED_BASE}`, hard-reset the branch with `git reset --hard {EXPECTED_BASE}` before starting work (safe — runs before any agent work), then verify with `[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && exit 1`. This corrects a known issue where `EnterWorktree` creates branches from `main` instead of the feature branch HEAD (affects all platforms).
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, include a `<worktree_branch_check>` block in the prompt** instructing the executor to: (1) FIRST assert `git symbolic-ref HEAD` resolves to a per-agent branch (NOT a protected ref like `main`/`master`/`develop`/`trunk`/`release/*`) and HALT with a blocker if not — never self-recover via `git update-ref refs/heads/<protected>` (#2924); (2) only after that assertion passes, run `git merge-base HEAD {EXPECTED_BASE}` and, if the result differs from `{EXPECTED_BASE}`, hard-reset the branch with `git reset --hard {EXPECTED_BASE}` before starting work, then verify with `[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && exit 1`. The HEAD assertion (Step 1) MUST run before any reset/checkout. This corrects a known issue where `EnterWorktree` creates branches from `main` instead of the feature branch HEAD (affects all platforms#2015) and prevents the destructive HEAD-on-master self-recovery path (#2924).
**Pattern B:** Execute segment-by-segment. Autonomous segments: spawn subagent for assigned tasks only (no SUMMARY/commit). Checkpoints: main context. After all segments: aggregate, create SUMMARY, commit. See segment_execution.
@@ -239,7 +239,12 @@ See `~/.claude/get-shit-done/references/tdd.md` for structure.
Your commits may trigger pre-commit hooks. Auto-fix hooks handle themselves transparently — files get fixed and re-staged automatically.
**If running as a parallel executor agent (spawned by execute-phase):**
Use `--no-verify` on all commits. Pre-commit hooks cause build lock contention when multiple agents commit simultaneously (e.g., cargo lock fights in Rust projects). The orchestrator validates once after all agents complete.
Run commits normally — let pre-commit hooks run. Do NOT use `--no-verify` by default
(#2924). Hooks should run so issues surface at the introducing commit, and silent
bypass violates project CLAUDE.md guidance. If a project explicitly opts out via
`workflow.worktree_skip_hooks=true`, the orchestrator will surface that flag in the
prompt; absent that signal, hooks run normally. If a hook fails, follow the
sequential-mode handling below.
**If running as the sole executor (sequential mode):**
If a commit is BLOCKED by a hook:

View File

@@ -48,9 +48,13 @@ Creates all `.planning/` artifacts:
Usage: `/gsd-new-project`
**`/gsd-map-codebase`**
**`/gsd-map-codebase [--fast] [--focus <area>] [--query <term>]`**
Map an existing codebase for brownfield projects.
- `--fast` — rapid lightweight assessment (replaces the former `gsd-scan`)
- `--focus <area>` — scope the map to a specific area
- `--query <term>` — query the codebase intelligence index in `.planning/intel/` (replaces the former `gsd-intel`)
- Analyzes codebase with parallel Explore agents
- Creates `.planning/codebase/` with 7 focused documents
- Covers stack, architecture, structure, conventions, testing, integrations, concerns
@@ -60,9 +64,13 @@ Usage: `/gsd-map-codebase`
### Phase Planning
**`/gsd-discuss-phase <number>`**
**`/gsd-discuss-phase <number> [--chain | --analyze | --power] [--batch[=N]]`**
Help articulate your vision for a phase before planning.
- `--chain` — chained-prompt discuss flow
- `--analyze` — deep assumption analysis pass
- `--power` — power-user mode with extended question set
- Captures how you imagine this phase working
- Creates CONTEXT.md with your vision, essentials, and boundaries
- Use when you have ideas about how something should look/feel
@@ -72,28 +80,15 @@ Usage: `/gsd-discuss-phase 2`
Usage: `/gsd-discuss-phase 2 --batch`
Usage: `/gsd-discuss-phase 2 --batch=3`
**`/gsd-research-phase <number>`**
Comprehensive ecosystem research for niche/complex domains.
- Discovers standard stack, architecture patterns, pitfalls
- Creates RESEARCH.md with "how experts build this" knowledge
- Use for 3D, games, audio, shaders, ML, and other specialized domains
- Goes beyond "which library" to ecosystem knowledge
Usage: `/gsd-research-phase 3`
**`/gsd-list-phase-assumptions <number>`**
See what Claude is planning to do before it starts.
- Shows Claude's intended approach for a phase
- Lets you course-correct if Claude misunderstood your vision
- No files created - conversational output only
Usage: `/gsd-list-phase-assumptions 3`
**`/gsd-plan-phase <number>`**
**`/gsd-plan-phase <number> [--skip-research] [--gaps] [--skip-verify] [--tdd] [--mvp]`**
Create detailed execution plan for a specific phase.
- `--skip-research` — bypass the research subagent
- `--gaps` — focus only on closing gaps from a prior plan-check
- `--skip-verify` — skip the post-plan verifier loop
- `--tdd` — plan in test-driven order (tests before code)
- `--mvp` — vertical-slice MVP planning mode
- Generates `.planning/phases/XX-phase-name/XX-YY-PLAN.md`
- Breaks phase into concrete, actionable tasks
- Includes verification criteria and success measures
@@ -106,9 +101,13 @@ Result: Creates `.planning/phases/01-foundation/01-01-PLAN.md`
### Execution
**`/gsd-execute-phase <phase-number>`**
**`/gsd-execute-phase <phase-number> [--wave N] [--gaps-only] [--tdd]`**
Execute all plans in a phase, or run a specific wave.
- `--wave N` — execute only wave N (see *Plans within each wave* below)
- `--gaps-only` — re-run only plans flagged as gaps by a prior verifier
- `--tdd` — enforce test-driven order during execution
- Groups plans by wave (from frontmatter), executes waves sequentially
- Plans within each wave run in parallel via Task tool
- Optional `--wave N` flag executes only Wave `N` and stops unless the phase is now fully complete
@@ -120,7 +119,7 @@ Usage: `/gsd-execute-phase 5 --wave 2`
### Smart Router
**`/gsd-do <description>`**
**`/gsd-progress --do "<description>"`**
Route freeform text to the right GSD command automatically.
- Analyzes natural language input to find the best matching GSD command
@@ -128,9 +127,9 @@ Route freeform text to the right GSD command automatically.
- Resolves ambiguity by asking you to pick between top matches
- Use when you know what you want but don't know which `/gsd-*` command to run
Usage: `/gsd-do fix the login button`
Usage: `/gsd-do refactor the auth system`
Usage: `/gsd-do I want to start a new milestone`
Usage: `/gsd-progress --do "fix the login button"`
Usage: `/gsd-progress --do "refactor the auth system"`
Usage: `/gsd-progress --do "I want to start a new milestone"`
### Quick Mode
@@ -172,26 +171,26 @@ Usage: `/gsd-fast "add .env to gitignore"`
### Roadmap Management
**`/gsd-add-phase <description>`**
**`/gsd-phase <description>`**
Add new phase to end of current milestone.
- Appends to ROADMAP.md
- Uses next sequential number
- Updates phase directory structure
Usage: `/gsd-add-phase "Add admin dashboard"`
Usage: `/gsd-phase "Add admin dashboard"`
**`/gsd-insert-phase <after> <description>`**
**`/gsd-phase --insert <after> <description>`**
Insert urgent work as decimal phase between existing phases.
- Creates intermediate phase (e.g., 7.1 between 7 and 8)
- Useful for discovered work that must happen mid-milestone
- Maintains phase ordering
Usage: `/gsd-insert-phase 7 "Fix critical auth bug"`
Usage: `/gsd-phase --insert 7 "Fix critical auth bug"`
Result: Creates Phase 7.1
**`/gsd-remove-phase <number>`**
**`/gsd-phase --remove <number>`**
Remove a future phase and renumber subsequent phases.
- Deletes phase directory and all references
@@ -199,9 +198,15 @@ Remove a future phase and renumber subsequent phases.
- Only works on future (unstarted) phases
- Git commit preserves historical record
Usage: `/gsd-remove-phase 17`
Usage: `/gsd-phase --remove 17`
Result: Phase 17 deleted, phases 18-20 become 17-19
**`/gsd-phase --edit <number> [--force]`**
Edit any field of an existing roadmap phase in place, preserving number and position.
- Updates title, description, requirements, dependencies in `ROADMAP.md`
- `--force` allows editing already-started phases (use with caution)
### Milestone Management
**`/gsd-new-milestone <name>`**
@@ -230,7 +235,7 @@ Usage: `/gsd-complete-milestone 1.0.0`
### Progress Tracking
**`/gsd-progress`**
**`/gsd-progress [--next | --forensic | --do "<description>"]`**
Check project status and intelligently route to next action.
- Shows visual progress bar and completion percentage
@@ -240,7 +245,15 @@ Check project status and intelligently route to next action.
- Offers to execute next plan or create it if missing
- Detects 100% milestone completion
Modes:
- **default** — progress report + intelligent routing
- **`--next`** — auto-advance to the next logical step (use `--next --force` to bypass safety gates)
- **`--forensic`** — append a 6-check integrity audit after the progress report
- **`--do "<text>"`** — smart router: dispatch freeform intent to the matching `/gsd-*` command (see *Smart Router* above)
Usage: `/gsd-progress`
Usage: `/gsd-progress --next`
Usage: `/gsd-progress --forensic`
### Session Management
@@ -264,9 +277,11 @@ Usage: `/gsd-pause-work`
### Debugging
**`/gsd-debug [issue description]`**
**`/gsd-debug [issue description] [--diagnose]`**
Systematic debugging with persistent state across context resets.
- `--diagnose` — run a one-shot diagnostic pass without opening a persistent debug session
- Gathers symptoms through adaptive questioning
- Creates `.planning/debug/[slug].md` to track investigation
- Investigates using scientific method (evidence → hypothesis → test)
@@ -305,7 +320,7 @@ Rapidly sketch UI/design ideas using throwaway HTML mockups with multi-variant e
Usage: `/gsd-sketch "dashboard layout for the admin panel"`
Usage: `/gsd-sketch --quick "form card grouping"`
**`/gsd-spike-wrap-up`**
**`/gsd-spike --wrap-up`**
Package spike findings into a persistent project skill.
- Curates each spike one-at-a-time (include/exclude/partial/UAT)
@@ -314,9 +329,9 @@ Package spike findings into a persistent project skill.
- Writes summary to `.planning/spikes/WRAP-UP-SUMMARY.md`
- Adds auto-load routing line to project CLAUDE.md
Usage: `/gsd-spike-wrap-up`
Usage: `/gsd-spike --wrap-up`
**`/gsd-sketch-wrap-up`**
**`/gsd-sketch --wrap-up`**
Package sketch design findings into a persistent project skill.
- Curates each sketch one-at-a-time (include/exclude/partial/revisit)
@@ -325,27 +340,12 @@ Package sketch design findings into a persistent project skill.
- Writes summary to `.planning/sketches/WRAP-UP-SUMMARY.md`
- Adds auto-load routing line to project CLAUDE.md
Usage: `/gsd-sketch-wrap-up`
Usage: `/gsd-sketch --wrap-up`
### Quick Notes
### Capturing Ideas, Notes, and Todos
**`/gsd-note <text>`**
Zero-friction idea capture — one command, instant save, no questions.
- Saves timestamped note to `.planning/notes/` (or `~/.claude/notes/` globally)
- Three subcommands: append (default), list, promote
- Promote converts a note into a structured todo
- Works without a project (falls back to global scope)
Usage: `/gsd-note refactor the hook system`
Usage: `/gsd-note list`
Usage: `/gsd-note promote 3`
Usage: `/gsd-note --global cross-project idea`
### Todo Management
**`/gsd-add-todo [description]`**
Capture idea or task as todo from current conversation.
**`/gsd-capture [description]`**
Capture an idea or task as a structured todo from current conversation.
- Extracts context from conversation (or uses provided description)
- Creates structured todo file in `.planning/todos/pending/`
@@ -353,20 +353,33 @@ Capture idea or task as todo from current conversation.
- Checks for duplicates before creating
- Updates STATE.md todo count
Usage: `/gsd-add-todo` (infers from conversation)
Usage: `/gsd-add-todo Add auth token refresh`
Usage: `/gsd-capture` (infers from conversation)
Usage: `/gsd-capture Add auth token refresh`
**`/gsd-check-todos [area]`**
**`/gsd-capture --note <text>`**
Zero-friction note capture — one command, instant save, no questions.
- Saves timestamped note to `.planning/notes/` (or `~/.claude/notes/` globally)
- Three subcommands: append (default), list, promote
- Promote converts a note into a structured todo
- Works without a project (falls back to global scope)
Usage: `/gsd-capture --note refactor the hook system`
Usage: `/gsd-capture --note list`
Usage: `/gsd-capture --note promote 3`
Usage: `/gsd-capture --note --global cross-project idea`
**`/gsd-capture --list [area]`**
List pending todos and select one to work on.
- Lists all pending todos with title, area, age
- Optional area filter (e.g., `/gsd-check-todos api`)
- Optional area filter (e.g., `/gsd-capture --list api`)
- Loads full context for selected todo
- Routes to appropriate action (work now, add to phase, brainstorm)
- Moves todo to done/ when work begins
Usage: `/gsd-check-todos`
Usage: `/gsd-check-todos api`
Usage: `/gsd-capture --list`
Usage: `/gsd-capture --list api`
### User Acceptance Testing
@@ -420,14 +433,23 @@ Usage: `/gsd-pr-branch` or `/gsd-pr-branch main`
---
**`/gsd-plant-seed [idea]`**
**`/gsd-capture --seed [idea]`**
Capture a forward-looking idea with trigger conditions for automatic surfacing.
- Seeds preserve WHY, WHEN to surface, and breadcrumbs to related code
- Auto-surfaces during `/gsd-new-milestone` when trigger conditions match
- Better than deferred items — triggers are checked, not forgotten
Usage: `/gsd-plant-seed "add real-time notifications when we build the events system"`
Usage: `/gsd-capture --seed "add real-time notifications when we build the events system"`
**`/gsd-capture --backlog [description]`**
Add an idea to the backlog parking lot for future milestones.
- Creates a backlog item under 999.x numbering in ROADMAP.md
- Reserves ideas without committing to the current milestone
- Surface and promote later via `/gsd-review-backlog`
Usage: `/gsd-capture --backlog "real-time notifications when events ship"`
---
@@ -452,16 +474,6 @@ Audit milestone completion against original intent.
Usage: `/gsd-audit-milestone`
**`/gsd-plan-milestone-gaps`**
Create phases to close gaps identified by audit.
- Reads MILESTONE-AUDIT.md and groups gaps into phases
- Prioritizes by requirement priority (must/should/nice)
- Adds gap closure phases to ROADMAP.md
- Ready for `/gsd-plan-phase` on new phases
Usage: `/gsd-plan-milestone-gaps`
### Configuration
**`/gsd-settings`**
@@ -473,15 +485,19 @@ Configure workflow toggles and model profile interactively.
Usage: `/gsd-settings`
**`/gsd-set-profile <profile>`**
Quick switch model profile for GSD agents.
**`/gsd-config [--profile <profile> | --advanced | --integrations]`**
Configure GSD beyond the basic settings: model profile, advanced tuning, and third-party integrations.
- `--profile <profile>` — quick switch model profile (`quality | balanced | budget | inherit`)
- `--advanced` — power-user tuning: plan bounce, timeouts, branch templates, cross-AI execution (replaces the former `gsd-settings-advanced`)
- `--integrations` — third-party API keys, code-review CLI routing, agent-skill injection (replaces the former `gsd-settings-integrations`)
- `quality` — Opus everywhere except verification
- `balanced` — Opus for planning, Sonnet for execution (default)
- `budget` — Sonnet for writing, Haiku for research/verification
- `inherit` — Use current session model for all agents (OpenCode `/model`)
Usage: `/gsd-set-profile budget`
Usage: `/gsd-config --profile budget`
### Utility Commands
@@ -498,9 +514,12 @@ Usage: `/gsd-cleanup`
**`/gsd-help`**
Show this command reference.
**`/gsd-update`**
**`/gsd-update [--sync] [--reapply]`**
Update GSD to latest version with changelog preview.
- `--sync` — sync managed GSD skills across runtime roots (replaces the former `gsd-sync-skills`)
- `--reapply` — reapply local modifications after an update (replaces the former `gsd-reapply-patches`)
- Shows installed vs latest version comparison
- Displays changelog entries for versions you've missed
- Highlights breaking changes
@@ -509,13 +528,72 @@ Update GSD to latest version with changelog preview.
Usage: `/gsd-update`
**`/gsd-join-discord`**
Join the GSD Discord community.
## Additional Commands
- Get help, share what you're building, stay updated
- Connect with other GSD users
The commands above cover the most common day-to-day flows. Every command listed here is also a live `/gsd-*` slash command and is grouped by purpose.
Usage: `/gsd-join-discord`
### Discovery & Specification
- **`/gsd-explore`** — Socratic ideation and idea routing. Think through ideas before committing to plans.
- **`/gsd-spec-phase <phase> [--auto] [--text]`** — Clarify WHAT a phase delivers with ambiguity scoring; produces a SPEC.md before discuss-phase.
- **`/gsd-ai-integration-phase [phase]`** — Generate an AI-SPEC.md design contract for phases that involve building AI systems.
- **`/gsd-ui-phase [phase]`** — Generate UI design contract (UI-SPEC.md) for frontend phases.
- **`/gsd-import --from <filepath>`** — Ingest external plans with conflict detection against project decisions before writing anything.
- **`/gsd-ingest-docs [path] [--mode new|merge] [--manifest <file>] [--resolve auto|interactive]`** — Bootstrap or merge a `.planning/` setup from existing ADRs, PRDs, SPECs, and docs in a repo.
### Planning & Execution
- **`/gsd-ultraplan-phase [phase]`** — [BETA] Offload plan phase to Claude Code's ultraplan cloud; review in browser and import back.
- **`/gsd-plan-review-convergence <phase> [--codex] [--gemini] [--claude] [--opencode] [--ollama] [--lm-studio] [--llama-cpp] [--all] [--text] [--ws <name>] [--max-cycles N]`** — Cross-AI plan convergence loop — replan with review feedback until no HIGH concerns remain. Supports both cloud reviewers (Codex/Gemini/Claude/OpenCode) and local model runtimes (Ollama, LM Studio, llama.cpp).
- **`/gsd-autonomous [--from N] [--to N] [--only N] [--interactive]`** — Run all remaining phases autonomously: discuss → plan → execute per phase.
### Quality, Review & Verification
- **`/gsd-code-review <phase> [--depth=quick|standard|deep] [--files file1,file2,...] [--fix [--all] [--auto]]`** — Review source files changed during a phase for bugs, security issues, and code quality problems.
- **`/gsd-secure-phase [phase]`** — Retroactively verify threat mitigations for a completed phase.
- **`/gsd-validate-phase [phase]`** — Retroactively audit and fill Nyquist validation gaps for a completed phase.
- **`/gsd-ui-review [phase]`** — Retroactive 6-pillar visual audit of implemented frontend code.
- **`/gsd-eval-review [phase]`** — Audit an executed AI phase's evaluation coverage and produce an EVAL-REVIEW.md remediation plan.
- **`/gsd-audit-fix --source <audit-uat> [--severity medium|high|all] [--max N] [--dry-run]`** — Autonomous audit-to-fix pipeline: find issues, classify, fix, test, commit.
- **`/gsd-add-tests <phase> [additional instructions]`** — Generate tests for a completed phase based on UAT criteria and implementation.
### Diagnostics & Maintenance
- **`/gsd-health [--repair] [--context]`** — Diagnose planning directory health and optionally repair issues.
- **`/gsd-forensics [problem description]`** — Post-mortem investigation for failed GSD workflows; diagnoses what went wrong.
- **`/gsd-undo --last N | --phase NN | --plan NN-MM`** — Safe git revert. Roll back phase or plan commits using the phase manifest with dependency checks.
- **`/gsd-docs-update [--force] [--verify-only]`** — Generate or update project documentation verified against the codebase.
- **`/gsd-extract-learnings <phase>`** — Extract decisions, lessons, patterns, and surprises from completed phase artifacts.
### Knowledge & Context
- **`/gsd-graphify [build|query <term>|status|diff]`** — Build, query, and inspect the project knowledge graph in `.planning/graphs/`.
- **`/gsd-thread [list [--open|--resolved] | close <slug> | status <slug> | name | description]`** — Manage persistent context threads for cross-session work.
- **`/gsd-profile-user [--questionnaire] [--refresh]`** — Generate developer behavioral profile and create Claude-discoverable artifacts.
- **`/gsd-stats`** — Display project statistics: phases, plans, requirements, git metrics, and timeline.
### Workflow & Orchestration
- **`/gsd-manager`** — Interactive command center for managing multiple phases from one terminal.
- **`/gsd-workspace [--new | --list | --remove] [name]`** — Manage GSD workspaces: create, list, or remove isolated workspace environments.
- **`/gsd-workstreams`** — Manage parallel workstreams: list, create, switch, status, progress, complete, and resume.
- **`/gsd-review-backlog`** — Review and promote backlog items to active milestone.
- **`/gsd-milestone-summary [version]`** — Generate a comprehensive project summary from milestone artifacts for team onboarding and review.
### Repository Integration
- **`/gsd-inbox [--issues] [--prs] [--label] [--close-incomplete] [--repo owner/repo]`** — Triage and review open GitHub issues and PRs against project templates and contribution guidelines.
### Namespace Routers (model-facing meta-skills)
These six skills exist primarily for the model to perform two-stage hierarchical routing across 60+ skills. You can invoke them directly when you want to browse a category interactively.
- **`/gsd-context`** — Codebase intelligence routing (map, graphify, docs, learnings).
- **`/gsd-ideate`** — Exploration / capture routing (explore, sketch, spike, spec, capture).
- **`/gsd-manage`** — Configuration and workspace routing (workstreams, thread, update, ship, inbox).
- **`/gsd-project`** — Project-lifecycle routing (milestones, audits, summary).
- **`/gsd-review`** — Quality-gate routing (code review, debug, audit, security, eval, ui).
- **`/gsd-workflow`** — Phase-pipeline routing (discuss, plan, execute, verify, phase, progress).
## Files & Structure
@@ -627,7 +705,7 @@ Example config:
**Adding urgent mid-milestone work:**
```
/gsd-insert-phase 5 "Critical security fix"
/gsd-phase --insert 5 "Critical security fix"
/gsd-plan-phase 5.1
/gsd-execute-phase 5.1
```
@@ -643,10 +721,12 @@ Example config:
**Capturing ideas during work:**
```
/gsd-add-todo # Capture from conversation context
/gsd-add-todo Fix modal z-index # Capture with explicit description
/gsd-check-todos # Review and work on todos
/gsd-check-todos api # Filter by area
/gsd-capture # Capture from conversation context
/gsd-capture Fix modal z-index # Capture with explicit description
/gsd-capture --note refactor auth system # Quick friction-free note
/gsd-capture --seed "real-time notifications" # Forward-looking idea with triggers
/gsd-capture --list # Review and work on todos
/gsd-capture --list api # Filter by area
```
**Debugging an issue:**

View File

@@ -269,8 +269,8 @@ If any of these exist, surface them before questioning:
⚡ Prior exploration detected:
{if SPIKE_SKILL} ✓ Spike findings skill: {path} — validated patterns from experiments
{if SKETCH_SKILL} ✓ Sketch findings skill: {path} — validated design decisions
{if HAS_SPIKES && !SPIKE_SKILL} ◆ Raw spikes in .planning/spikes/ — consider `/gsd-spike-wrap-up` to package findings
{if HAS_SKETCHES && !SKETCH_SKILL} ◆ Raw sketches in .planning/sketches/ — consider `/gsd-sketch-wrap-up` to package findings
{if HAS_SPIKES && !SPIKE_SKILL} ◆ Raw spikes in .planning/spikes/ — consider `/gsd-spike --wrap-up` to package findings
{if HAS_SKETCHES && !SKETCH_SKILL} ◆ Raw sketches in .planning/sketches/ — consider `/gsd-sketch --wrap-up` to package findings
These findings will be incorporated into project context and available to planning agents.
```

View File

@@ -996,7 +996,7 @@ rest become a follow-up phase
Use AskUserQuestion with these 3 options.
**If "Split":** Use `/gsd-insert-phase` to create the sub-phases, then replan each.
**If "Split":** Use `/gsd-phase --insert` to create the sub-phases, then replan each.
**If "Proceed":** Return to planner with instruction to attempt all items at full fidelity, accepting more plans/tasks.
**If "Prioritize":** Use AskUserQuestion (multiSelect) to let user pick which items are "now" vs "later". Create CONTEXT.md for each sub-phase with the selected items.
@@ -1025,7 +1025,7 @@ Options:
Use AskUserQuestion for each gap (or batch if multiple gaps).
**If "Add plan":** Return to planner (step 8) with instruction to add plans covering the missing items, preserving existing plans.
**If "Split":** Use `/gsd-insert-phase` for overflow items, then replan.
**If "Split":** Use `/gsd-phase --insert` for overflow items, then replan.
**If "Defer":** Record in CONTEXT.md `## Deferred Ideas` with developer's confirmation. Proceed to step 10.
## 10. Spawn gsd-plan-checker Agent

View File

@@ -637,7 +637,21 @@ if [ "${USE_WORKTREES}" != "false" ]; then
COMMIT_DOCS=$(gsd-sdk query config-get commit_docs 2>/dev/null || echo "true")
if [ "$COMMIT_DOCS" != "false" ]; then
git add "${QUICK_DIR}/${quick_id}-PLAN.md"
git commit --no-verify -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" || true
# No-op skip if nothing actually staged (idempotent re-runs).
if git diff --cached --quiet -- "${QUICK_DIR}/${quick_id}-PLAN.md"; then
echo " Pre-dispatch PLAN.md commit skipped (no staged changes)"
else
# Run hooks normally (#2924). If a project opts out via
# workflow.worktree_skip_hooks=true, honor that opt-in only.
SKIP_HOOKS=$(gsd-sdk query config-get workflow.worktree_skip_hooks 2>/dev/null || echo "false")
if [ "$SKIP_HOOKS" = "true" ]; then
git commit --no-verify -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" \
|| { echo "ERROR: pre-dispatch PLAN.md commit failed (--no-verify path). Aborting before executor dispatch." >&2; exit 1; }
else
git commit -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" \
|| { echo "ERROR: pre-dispatch PLAN.md commit failed — likely a pre-commit hook failure. Fix the hook output above (or set workflow.worktree_skip_hooks=true to bypass) and re-run." >&2; exit 1; }
fi
fi
fi
fi
```
@@ -660,12 +674,31 @@ Execute quick task ${quick_id}.
${USE_WORKTREES !== "false" ? `
<worktree_branch_check>
FIRST ACTION before any other work: verify this worktree branch is based on the correct commit.
Run: git merge-base HEAD ${EXPECTED_BASE}
If the result differs from ${EXPECTED_BASE}, hard-reset to the correct base (safe — runs before any agent work):
git reset --hard ${EXPECTED_BASE}
Then verify: if [ "$(git rev-parse HEAD)" != "${EXPECTED_BASE}" ]; then echo "ERROR: Could not correct worktree base"; exit 1; fi
This corrects a known issue where EnterWorktree creates branches from main instead of the feature branch HEAD (affects all platforms).
FIRST ACTION before any other work: verify this worktree's HEAD is bound to a per-agent
branch and that the branch is based on the correct commit.
Step 1 — HEAD attachment assertion (MANDATORY, runs before any reset/commit):
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$HEAD_REF" = "DETACHED" ] || echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
echo "FATAL: worktree HEAD is on '$ACTUAL_BRANCH' (expected per-agent branch like worktree-agent-*)." >&2
echo "Refusing to commit/reset on a protected ref. DO NOT self-recover via 'git update-ref refs/heads/$ACTUAL_BRANCH' — that destroys concurrent work (#2924)." >&2
echo "Aborting before any commits. Surface as a blocker for human review." >&2
exit 1
fi
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace (Claude Code's per-agent worktree branch namespace)." >&2
echo "Refusing to commit; surface as blocker (#2924)." >&2
exit 1
fi
Step 2 — Base correctness (only after Step 1 passes):
Run: git merge-base HEAD ${EXPECTED_BASE}
If the result differs from ${EXPECTED_BASE}, hard-reset to the correct base (safe — Step 1 confirmed HEAD is on a per-agent branch and the worktree is fresh):
git reset --hard ${EXPECTED_BASE}
Then verify: if [ "$(git rev-parse HEAD)" != "${EXPECTED_BASE}" ]; then echo "ERROR: Could not correct worktree base"; exit 1; fi
This corrects a known issue where EnterWorktree creates branches from main instead of the feature branch HEAD (#2015) and prevents the destructive HEAD-on-master self-recovery path (#2924).
</worktree_branch_check>
` : ''}

View File

@@ -269,17 +269,80 @@ After writing each merged file, verify that user modifications survived the merg
## Step 5: Hunk Verification Gate
Before proceeding to cleanup, evaluate the Hunk Verification Table produced in Step 4.
Two layered gates. Both must pass before proceeding to cleanup.
**If the Hunk Verification Table is absent** (Step 4 did not produce it), STOP immediately and report to the user:
```
ERROR: Hunk Verification Table is missing. Post-merge verification was not completed.
Rerun /gsd-update --reapply to retry with full verification.
### 5a: Deterministic verifier (binding gate, #2969)
Run the deterministic verifier script. Do NOT rely solely on the free-text `verified: yes/no` Hunk Verification Table from Step 4 — bug #2969 traced repeated false-positive `verified: yes` reports to that table being filled in without an actual content-presence check. The script performs the check structurally and exits non-zero on any miss.
Run the verifier as a child process (the gsd-tools binary directory is not required — the script ships under `scripts/` in the source repo and is also exposed via the SDK at `sdk/dist/cli.js verify-reapply` when present):
```bash
PRISTINE_DIR="${CONFIG_DIR}/gsd-pristine"
# Build args as a bash array so paths with spaces survive expansion intact
# (string-concat + unquoted expansion would split incorrectly on whitespace).
VERIFY_ARGS=(
--patches-dir "$PATCHES_DIR"
--config-dir "$CONFIG_DIR"
)
if [ -d "$PRISTINE_DIR" ]; then
VERIFY_ARGS+=(--pristine-dir "$PRISTINE_DIR")
fi
VERIFY_ARGS+=(--json)
# Capture stdout (the structured JSON report) separately from stderr so that
# Node warnings, deprecation notices, or stack traces do not corrupt the
# JSON parse downstream. Stderr is preserved on the controlling terminal
# for operator visibility.
VERIFY_OUTPUT="$(node "${GSD_HOME}/scripts/verify-reapply-patches.cjs" "${VERIFY_ARGS[@]}")"
VERIFY_STATUS=$?
```
**If any row in the Hunk Verification Table shows `verified: no`**, STOP and report to the user:
**If `VERIFY_STATUS` is non-zero**, STOP and report to the user, parsing the JSON output:
```text
ERROR: {failures} file(s) failed deterministic post-merge verification (#2969 gate).
The verifier compared user-added lines (computed from the diff between
the backup and the pristine baseline) against the merged installed file.
Lines listed below are present in the backup but absent from the merged result.
For each failed file:
{file}
missing: {first significant missing line, up to 5 per file}
backup: {patches_dir}/{file}
Resolve before proceeding:
(a) Re-merge the missing content into the installed file by hand, or
(b) Restore from backup: cp {patches_dir}/{file} {installed_path}
Then re-run /gsd-update --reapply to re-verify.
```
ERROR: {N} hunk(s) failed verification — content may have been dropped during merge.
Do not proceed to cleanup until the verifier exits 0.
**Only when `VERIFY_STATUS` is 0** (or when all files had zero significant user-added lines, which the verifier reports as `Failures: 0`) may execution continue to gate 5b.
### 5b: Hunk Verification Table review (advisory gate, #1999)
The Hunk Verification Table produced in Step 4 must also be reviewed before proceeding. This is advisory after the script gate but is preserved as a defense-in-depth check — if the script ever has a bug or the pristine baseline is unavailable, the table-based gate still catches obvious regressions.
**If the Hunk Verification Table is absent** (Step 4 silently produced nothing), STOP and report:
```
ERROR: Hunk Verification Table is missing — Step 4 did not produce it.
The deterministic verifier (5a) may still have passed, but a missing table
means post-merge verification was not fully completed. Rerun
/gsd-update --reapply to retry with full verification.
```
A missing table absent from the workflow output cannot bypass this gate.
**If any row in the Hunk Verification Table shows `verified: no`**, STOP and report:
```
ERROR: {N} hunk(s) failed Step 5b verification — content may have been dropped during merge.
Unverified hunks:
{file} hunk {hunk_id}: signature line "{signature_line}" not found in merged output
@@ -290,9 +353,9 @@ Review the merged file manually, then either:
(b) Restore from backup: cp {patches_dir}/{file} {installed_path}
```
Do not proceed to cleanup until the user confirms they have resolved all unverified hunks.
Do not proceed to cleanup until both gates (5a and 5b) pass.
**Only when all rows show `verified: yes`** (or when all files had zero user-added hunks) may execution continue to Step 6.
**Why both gates?** 5a (the script) is the binding gate — it does the actual substring check structurally and cannot be shortcut by the LLM. 5b (the table review) is the advisory gate — it provides a redundant safety net via the Step 4 prose summary, ensuring that even a script regression or absent pristine baseline cannot silently allow a `verified: no` row to slip past, nor can a missing table go unnoticed. Layered gates favour false-positive halts (recoverable) over silent successes on lost content (unrecoverable).
## Step 6: Cleanup option

View File

@@ -45,7 +45,7 @@ Parse current values (default to `true` if not present):
- `workflow.ui_safety_gate` — prompt to run /gsd-ui-phase before planning frontend phases (default: true if absent)
- `workflow.ai_integration_phase` — framework selection + eval strategy for AI phases (default: true if absent)
- `workflow.tdd_mode` — enforce RED/GREEN/REFACTOR gate sequence during execute-phase (default: false if absent)
- `workflow.code_review` — enable /gsd-code-review and /gsd-code-review-fix commands (default: true if absent)
- `workflow.code_review` — enable /gsd-code-review and /gsd-code-review --fix commands (default: true if absent)
- `workflow.code_review_depth` — default depth for /gsd-code-review: `quick`, `standard`, or `deep` (default: `"standard"` if absent; only relevant when `code_review` is on)
- `workflow.ui_review` — run visual quality audit (/gsd-ui-review) in autonomous mode (default: true if absent)
- `commit_docs` — whether `.planning/` files are committed to git (default: true if absent)
@@ -150,7 +150,7 @@ AskUserQuestion([
]
},
{
question: "Enable Code Review? (/gsd-code-review and /gsd-code-review-fix commands)",
question: "Enable Code Review? (/gsd-code-review and /gsd-code-review --fix commands)",
header: "Code Review",
multiSelect: false,
options: [
@@ -457,12 +457,12 @@ Display:
These settings apply to future /gsd-plan-phase and /gsd-execute-phase runs.
Quick commands:
- /gsd-settings-integrations — configure API keys (Brave/Firecrawl/Exa), review.models CLI routing, and agent_skills injection
- /gsd-set-profile <profile> — switch model profile
- /gsd-config --integrations — configure API keys (Brave/Firecrawl/Exa), review.models CLI routing, and agent_skills injection
- /gsd-config --profile <profile> — switch model profile
- /gsd-plan-phase --research — force research
- /gsd-plan-phase --skip-research — skip research
- /gsd-plan-phase --skip-verify — skip plan check
- /gsd-settings-advanced — power-user tuning (plan bounce, timeouts, branch templates, cross-AI, context window)
- /gsd-config --advanced — power-user tuning (plan bounce, timeouts, branch templates, cross-AI, context window)
```
</step>

View File

@@ -1,7 +1,7 @@
<purpose>
Explore design directions through throwaway HTML mockups before committing to implementation.
Each sketch produces 2-3 variants for comparison. Saves artifacts to `.planning/sketches/`.
Companion to `/gsd-sketch-wrap-up`.
Companion to `/gsd-sketch --wrap-up`.
Supports two modes:
- **Idea mode** (default) — user describes a design idea to sketch
@@ -331,7 +331,7 @@ After all sketches complete:
**Package findings** — wrap design decisions into a reusable skill
`/gsd-sketch-wrap-up`
`/gsd-sketch --wrap-up`
───────────────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
<purpose>
Spike an idea through experiential exploration — build focused experiments to feel the pieces
of a future app, validate feasibility, and produce verified knowledge for the real build.
Saves artifacts to `.planning/spikes/`. Companion to `/gsd-spike-wrap-up`.
Saves artifacts to `.planning/spikes/`. Companion to `/gsd-spike --wrap-up`.
Supports two modes:
- **Idea mode** (default) — user describes an idea to spike
@@ -421,7 +421,7 @@ gsd-sdk query commit "docs(spikes): update conventions" --files .planning/spikes
**Package findings** — wrap spike knowledge into an implementation blueprint
`/gsd-spike-wrap-up`
`/gsd-spike --wrap-up`
───────────────────────────────────────────────────────────────

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "get-shit-done-cc",
"version": "1.39.0-rc.4",
"version": "1.39.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "get-shit-done-cc",
"version": "1.39.0-rc.4",
"version": "1.39.1",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.84",

View File

@@ -1,6 +1,6 @@
{
"name": "get-shit-done-cc",
"version": "1.39.0-rc.4",
"version": "1.39.1",
"description": "A meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini and Codex by TÂCHES.",
"bin": {
"get-shit-done-cc": "bin/install.js",

View File

@@ -39,6 +39,29 @@ const READ_WITH_CONST_RE = /readFileSync\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,/gm;
// Matches readFileSync with an inline path.join(.cjs) as first arg
const READ_WITH_INLINE_CJS_RE = /readFileSync\s*\([^,)]*path\.join\s*\([^)]*(?:'bin'|"bin"|'lib'|"lib"|'get-shit-done'|"get-shit-done")[^)]*['"][^'"]*\.cjs['"]/;
/**
* #2962-class violations: raw text matching against process output or file
* content. The rule from CONTRIBUTING.md "Prohibited: Raw Text Matching on
* Test Outputs": tests assert on typed structured fields, never on rendered
* text. Patterns below are the obvious anti-patterns; subtler hidden forms
* (e.g. wrapping the same logic in a parser function) are still forbidden
* by the prose rule but cannot be detected lexically without an AST.
*/
const RAW_MATCH_PATTERNS = [
{
re: /assert\.(?:match|doesNotMatch)\s*\(\s*[A-Za-z_$][A-Za-z0-9_$]*\.(?:stdout|stderr)\b/,
label: 'assert.match/doesNotMatch on .stdout/.stderr (emit --json from the SUT and assert on typed fields)',
},
{
re: /\.(?:stdout|stderr)\.(?:includes|startsWith|endsWith)\s*\(/,
label: '.stdout/.stderr substring match (emit --json and assert on typed fields)',
},
{
re: /readFileSync\s*\([^)]*\)\s*\.(?:includes|startsWith|endsWith)\s*\(/,
label: 'readFileSync(...).<includes|startsWith|endsWith> (expose an IR from production code; assert on its fields)',
},
];
function setFromMatches(content, re) {
const found = new Set();
let m;
@@ -53,13 +76,14 @@ function check(filepath) {
if (ALLOW_ANNOTATION.test(content)) return null;
const violations = [];
// Pattern A: readFileSync(path.join(..., 'foo.cjs'), ...)
if (READ_WITH_INLINE_CJS_RE.test(content)) {
return {
file: rel,
violations.push({
reason: 'readFileSync with inline .cjs path literal',
fix: 'Replace with runGsdTools() behavioral test, or add // allow-test-rule: <reason>',
};
});
}
// Pattern B: const FOO_PATH = path.join(..., 'foo.cjs') + readFileSync(FOO_PATH, ...)
@@ -68,15 +92,26 @@ function check(filepath) {
const readConsts = setFromMatches(content, READ_WITH_CONST_RE);
const overlap = [...cjsConsts].filter(c => readConsts.has(c));
if (overlap.length > 0) {
return {
file: rel,
violations.push({
reason: `source .cjs path constant(s) used in readFileSync: ${overlap.join(', ')}`,
fix: 'Replace with runGsdTools() behavioral test, or add // allow-test-rule: <reason>',
};
});
}
}
return null;
// Patterns C..E: raw text matching against process output or file content.
// See CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs".
for (const { re, label } of RAW_MATCH_PATTERNS) {
if (re.test(content)) {
violations.push({
reason: label,
fix: 'Expose typed IR from production code; assert on structured fields. Or add // allow-test-rule: <reason>',
});
}
}
if (violations.length === 0) return null;
return { file: rel, violations };
}
function findTestFiles(dir) {
@@ -101,12 +136,17 @@ if (violations.length === 0) {
process.exit(0);
}
process.stderr.write(`\nERROR lint-no-source-grep: ${violations.length} violation(s) found\n\n`);
for (const v of violations) {
process.stderr.write(` ${v.file}\n`);
process.stderr.write(` Problem : ${v.reason}\n`);
process.stderr.write(` Fix : ${v.fix}\n\n`);
const totalIssues = violations.reduce((n, v) => n + v.violations.length, 0);
process.stderr.write(`\nERROR lint-no-source-grep: ${totalIssues} violation(s) across ${violations.length} file(s)\n\n`);
for (const f of violations) {
process.stderr.write(` ${f.file}\n`);
for (const v of f.violations) {
process.stderr.write(` Problem : ${v.reason}\n`);
process.stderr.write(` Fix : ${v.fix}\n`);
}
process.stderr.write('\n');
}
process.stderr.write('See CONTRIBUTING.md "Prohibited: Source-Grep Tests" for guidance.\n');
process.stderr.write('See CONTRIBUTING.md "Prohibited: Source-Grep Tests" and\n');
process.stderr.write('"Prohibited: Raw Text Matching on Test Outputs" for guidance.\n');
process.stderr.write('Structural tests that legitimately read source files: add // allow-test-rule: <reason>\n\n');
process.exit(1);

View File

@@ -0,0 +1,247 @@
#!/usr/bin/env node
'use strict';
/**
* Deterministic verifier for the /gsd-reapply-patches Step 5 "Hunk Verification
* Gate". For each backed-up patch file, asserts that the user's added lines
* (computed from a real diff against the pristine baseline, not from the
* LLM's prose summary) survive into the merged output.
*
* Usage:
* node scripts/verify-reapply-patches.cjs \
* --patches-dir <path> \ # gsd-local-patches/
* --config-dir <path> \ # ~/.claude (or runtime equivalent)
* [--pristine-dir <path>] # gsd-pristine/; if absent, falls back to
* # treating every significant backup line as
* # required (over-broad but safe for #2969:
* # false-positive halts beat silent successes
* # on lost content)
* [--json] # emit JSON report instead of human text
*
* Exit codes:
* 0 — every user-added line is present in the merged file (gate passes)
* 1 — at least one missing line in at least one file (gate fails)
* 2 — usage / structural error (e.g. patches dir missing)
*
* Bug #2969: the Step 5 gate previously trusted Claude's free-text "verified:
* yes/no" reporting per hunk. The LLM was filling in `yes` even when content
* had been silently dropped. Moving the check to a deterministic script is the
* durability fix.
*/
const fs = require('node:fs');
const path = require('node:path');
const SIGNIFICANT_MIN_CHARS = 12;
function parseArgs(argv) {
const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--patches-dir') opts.patchesDir = argv[++i];
else if (arg === '--config-dir') opts.configDir = argv[++i];
else if (arg === '--pristine-dir') opts.pristineDir = argv[++i];
else if (arg === '--json') opts.json = true;
else if (arg === '--help' || arg === '-h') {
process.stdout.write(
'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json]\n',
);
process.exit(0);
} else {
process.stderr.write(`unknown argument: ${arg}\n`);
process.exit(2);
}
}
return opts;
}
function isSignificantLine(line) {
const trimmed = line.trim();
if (trimmed.length < SIGNIFICANT_MIN_CHARS) return false;
// Pure punctuation / closing brackets carry too little structural info to
// reliably distinguish a survived hunk from incidental similarity.
if (/^[\s})\];,]+$/.test(trimmed)) return false;
// Generic decorative comments like `// ----` similarly fail the test.
if (/^[\s\-=#*/]+$/.test(trimmed)) return false;
return true;
}
/**
* Walk a directory, returning every file's path relative to the root.
*/
function walk(rootDir, relPrefix = '') {
const out = [];
if (!fs.existsSync(rootDir)) return out;
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
const rel = relPrefix ? path.join(relPrefix, entry.name) : entry.name;
const abs = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
out.push(...walk(abs, rel));
} else if (entry.isFile()) {
out.push(rel);
}
}
return out;
}
/**
* Compute the set of "user-added" lines: lines present in the backup but
* absent from the pristine baseline. If no pristine is provided, falls back
* to using every significant line in the backup (over-broad but safe — favours
* false-positive failures over silent successes, which is the right side to
* err on for #2969).
*/
function computeUserAddedLines(backupContent, pristineContent) {
const backupLines = backupContent.split(/\r?\n/);
if (!pristineContent) {
return backupLines.filter(isSignificantLine);
}
const pristineSet = new Set(pristineContent.split(/\r?\n/));
return backupLines.filter((line) => isSignificantLine(line) && !pristineSet.has(line));
}
/**
* Stable reason codes for the per-file result. Tests assert via
* `assert.equal(result.reason, REASON.X)` rather than regex-matching prose,
* so the diagnostic surface is a typed enum, not free text.
*
* Adding a new reason requires updating the REASON map AND the tests'
* shape assertion that locks the documented set of codes.
*/
const REASON = Object.freeze({
OK_NO_USER_LINES_VS_PRISTINE: 'ok_no_user_lines_vs_pristine',
OK_NO_SIGNIFICANT_BACKUP_LINES: 'ok_no_significant_backup_lines',
FAIL_INSTALLED_MISSING: 'fail_installed_missing',
FAIL_INSTALLED_NOT_REGULAR_FILE: 'fail_installed_not_regular_file',
FAIL_READ_ERROR: 'fail_read_error',
FAIL_USER_LINES_MISSING: 'fail_user_lines_missing',
});
function verifyFile({ relPath, patchesDir, configDir, pristineDir }) {
const backupPath = path.join(patchesDir, relPath);
const installedPath = path.join(configDir, relPath);
const result = { file: relPath, status: 'ok', missing: [], reason: null };
if (!fs.existsSync(backupPath) || !fs.statSync(backupPath).isFile()) {
return result; // walked entry no longer exists — non-fatal
}
// Installed path checks: must exist, must be a regular file, must be
// readable. Anything else is a fail-with-diagnostic, not a crash that
// aborts the whole gate run and drops structured output.
let installedStat;
try {
installedStat = fs.statSync(installedPath);
} catch {
result.status = 'fail';
result.reason = REASON.FAIL_INSTALLED_MISSING;
return result;
}
if (!installedStat.isFile()) {
result.status = 'fail';
result.reason = REASON.FAIL_INSTALLED_NOT_REGULAR_FILE;
return result;
}
let backupContent;
let installedContent;
try {
backupContent = fs.readFileSync(backupPath, 'utf8');
installedContent = fs.readFileSync(installedPath, 'utf8');
} catch {
result.status = 'fail';
result.reason = REASON.FAIL_READ_ERROR;
return result;
}
let pristineContent = null;
if (pristineDir) {
const pristinePath = path.join(pristineDir, relPath);
try {
const stat = fs.statSync(pristinePath);
if (stat.isFile()) {
pristineContent = fs.readFileSync(pristinePath, 'utf8');
}
} catch {
// Pristine missing or unreadable — fall through to over-broad mode.
}
}
const userAdded = computeUserAddedLines(backupContent, pristineContent);
if (userAdded.length === 0) {
// Backup and pristine match exactly (or no significant content) — nothing
// to verify but also nothing to lose. Report as ok with diagnostic code.
result.reason = pristineContent
? REASON.OK_NO_USER_LINES_VS_PRISTINE
: REASON.OK_NO_SIGNIFICANT_BACKUP_LINES;
return result;
}
for (const line of userAdded) {
if (!installedContent.includes(line)) {
result.missing.push(line.trim());
}
}
if (result.missing.length > 0) {
result.status = 'fail';
result.reason = REASON.FAIL_USER_LINES_MISSING;
}
return result;
}
function main() {
const opts = parseArgs(process.argv.slice(2));
if (!opts.patchesDir || !opts.configDir) {
process.stderr.write('--patches-dir and --config-dir are required\n');
process.exit(2);
}
if (!fs.existsSync(opts.patchesDir)) {
process.stderr.write(`patches dir not found: ${opts.patchesDir}\n`);
process.exit(2);
}
if (!fs.existsSync(opts.configDir)) {
process.stderr.write(`config dir not found: ${opts.configDir}\n`);
process.exit(2);
}
const files = walk(opts.patchesDir).filter((f) => !f.endsWith('backup-meta.json'));
const results = files.map((relPath) =>
verifyFile({
relPath,
patchesDir: opts.patchesDir,
configDir: opts.configDir,
pristineDir: opts.pristineDir,
}),
);
const failures = results.filter((r) => r.status === 'fail');
if (opts.json) {
process.stdout.write(JSON.stringify({ checked: results.length, failures: failures.length, results }, null, 2) + '\n');
} else {
process.stdout.write(`# Hunk Verification Gate (#2969)\n\n`);
process.stdout.write(`Checked: ${results.length} file(s)\n`);
process.stdout.write(`Failures: ${failures.length}\n\n`);
if (failures.length > 0) {
process.stdout.write(`## Files with missing user-added content\n\n`);
for (const r of failures) {
process.stdout.write(`- ${r.file}\n`);
if (r.reason) process.stdout.write(` reason: ${r.reason}\n`);
for (const line of r.missing.slice(0, 5)) {
process.stdout.write(` missing: ${line}\n`);
}
if (r.missing.length > 5) {
process.stdout.write(` …and ${r.missing.length - 5} more line(s)\n`);
}
}
}
}
process.exit(failures.length > 0 ? 1 : 0);
}
if (require.main === module) {
main();
}
module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON };

4
sdk/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@gsd-build/sdk",
"version": "1.39.0-rc.4",
"version": "1.39.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gsd-build/sdk",
"version": "1.39.0-rc.4",
"version": "1.39.1",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.84",

View File

@@ -1,6 +1,6 @@
{
"name": "@gsd-build/sdk",
"version": "1.39.0-rc.4",
"version": "1.39.1",
"description": "GSD SDK — programmatic interface for running GSD plans via the Agent SDK",
"type": "module",
"main": "dist/index.js",

View File

@@ -443,7 +443,13 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
output = extractField(output, pickField);
}
console.log(JSON.stringify(output, null, 2));
// Handlers can signal format:'text' to emit a raw string (e.g. agent-skills
// emits an <agent_skills> XML block workflows embed via $(...) substitution).
if (!pickField && result.format === 'text' && typeof output === 'string') {
process.stdout.write(output);
} else {
console.log(JSON.stringify(output, null, 2));
}
}
} catch (err) {
if (err instanceof GSDError) {

View File

@@ -28,6 +28,7 @@ export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
'workflow.skip_discuss',
'workflow.auto_prune_state',
'workflow.use_worktrees',
'workflow.worktree_skip_hooks',
'workflow.code_review',
'workflow.code_review_depth',
'workflow.code_review_command',

View File

@@ -8,11 +8,15 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execSync } from 'node:child_process';
import { join, resolve } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { agentSkills } from './skills.js';
const CLI = resolve(fileURLToPath(import.meta.url), '../../../dist/cli.js');
async function writeSkill(rootDir: string, name: string) {
const skillDir = join(rootDir, name);
await mkdir(skillDir, { recursive: true });
@@ -120,4 +124,75 @@ describe('agentSkills', () => {
const r = await agentSkills(['gsd-planner'], tmpDir);
expect(r.data).toBe('');
});
it('signals format:"text" for non-empty blocks (used by CLI dispatcher)', async () => {
await writeSkill(join(tmpDir, '.claude', 'skills'), 'a-skill');
await writeConfig(tmpDir, {
agent_skills: { 'gsd-planner': '.claude/skills/a-skill' },
});
const r = await agentSkills(['gsd-planner'], tmpDir);
expect(r.format).toBe('text');
});
it('does not signal format:"text" for empty result', async () => {
const r = await agentSkills(['gsd-planner'], tmpDir);
expect(r.format).toBeUndefined();
});
});
// ─── CLI stdout integration ─────────────────────────────────────────────────
// Regression guard for the JSON-wrapping bug (#2914): the CLI must emit the
// raw <agent_skills> block to stdout, not a JSON-quoted string. Spawns the
// CLI as a child process so the full dispatch path (including cli.ts format
// handling) is exercised.
describe('agentSkills CLI stdout', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skills-cli-'));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('writes raw <agent_skills> block to stdout — not JSON-wrapped', async () => {
const skillDir = join(tmpDir, '.claude', 'skills', 'cli-skill');
await mkdir(skillDir, { recursive: true });
await writeFile(join(skillDir, 'SKILL.md'), '# cli-skill\n');
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ agent_skills: { 'gsd-planner': '.claude/skills/cli-skill' } }),
);
const stdout = execSync(
`node "${CLI}" query --project-dir "${tmpDir}" agent-skills gsd-planner`,
{ encoding: 'utf-8' },
);
expect(stdout).toBe(
'<agent_skills>\nRead these user-configured skills:\n- @.claude/skills/cli-skill/SKILL.md\n</agent_skills>',
);
});
it('emits empty output (no JSON null) when agent type is unmapped', async () => {
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ agent_skills: { 'gsd-executor': ['.claude/skills/foo'] } }),
);
const stdout = execSync(
`node "${CLI}" query --project-dir "${tmpDir}" agent-skills gsd-planner`,
{ encoding: 'utf-8' },
);
// Unmapped agent → empty string → CLI falls through to JSON (""), not raw
// text. This is acceptable: workflows that embed an empty var are no-ops.
// The important invariant is that a MAPPED agent never gets JSON-wrapped.
expect(stdout.trim()).toBe('""');
});
});

View File

@@ -123,7 +123,9 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
if (validEntries.length === 0) return { data: '' };
const lines = validEntries.map((e) => `- @${e.ref}`).join('\n');
return {
data: `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`,
};
const block = `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`;
// Signal the CLI dispatcher to write raw text — workflows embed the result
// with `$(gsd-sdk query agent-skills …)` and need the XML block verbatim, not
// a JSON-quoted string (see cli.ts QueryResult.format handling).
return { data: block, format: 'text' };
};

View File

@@ -24,6 +24,16 @@ import { GSDError, ErrorClassification } from '../errors.js';
/** Structured result returned by all query handlers. */
export interface QueryResult<T = unknown> {
data: T;
/**
* Output format hint for the CLI dispatcher.
* `'text'` — write `data` as-is to stdout (no JSON-stringify).
* `'json'` (default) — JSON-stringify as usual.
*
* Only meaningful when `data` is a string and the consumer is the CLI.
* Used by `agent-skills` so workflows embedding `$(gsd-sdk query …)` receive
* a raw `<agent_skills>` XML block rather than a JSON-quoted string.
*/
format?: 'json' | 'text';
}
/** Signature for a query handler function. */

View File

@@ -11,6 +11,10 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
const { test, describe, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');

View File

@@ -1,5 +1,9 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
/**
* Regression test for #2687 — loadConfig must not emit "unknown config key"
* warnings for keys that are registered in DYNAMIC_KEY_PATTERNS (e.g. review,

View File

@@ -17,6 +17,10 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');

View File

@@ -24,6 +24,10 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');

View File

@@ -0,0 +1,463 @@
/**
* Regression tests for #2924: worktree HEAD attaches to a protected branch
* (master/main) so agent commits land there; the workflow then "self-recovers"
* by force-rewinding the protected branch via `git update-ref refs/heads/master`,
* destroying concurrent work in multi-active scenarios.
*
* Fixes asserted by these tests (parsed structurally — not via raw content
* regex/includes — per project test policy):
*
* 1. The <worktree_branch_check> block in execute-phase.md and quick.md
* contains a HEAD-attachment assertion (symbolic-ref + protected-branch
* check) that runs BEFORE any `git reset --hard`.
* 2. The parallel-execution prompt in execute-phase.md and execute-plan.md
* no longer mandates `--no-verify` as the default for worktree-mode commits.
* 3. gsd-executor.md prohibits `git update-ref refs/heads/<protected>` as a
* "recovery" path and includes a pre-commit HEAD assertion in the task
* commit protocol.
* 4. No workflow file in get-shit-done/workflows/ contains an unconditional
* `git update-ref refs/heads/master` (or main/develop/trunk) call.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..');
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-phase.md');
const EXECUTE_PLAN_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-plan.md');
const QUICK_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'quick.md');
const EXECUTOR_AGENT_PATH = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
const GIT_INTEGRATION_PATH = path.join(REPO_ROOT, 'get-shit-done', 'references', 'git-integration.md');
/**
* Extract the inner body of a named XML-like block (e.g. <worktree_branch_check>...</worktree_branch_check>)
* from a markdown document. Returns null when not found.
*/
function extractNamedBlock(markdown, blockName) {
const open = `<${blockName}>`;
const close = `</${blockName}>`;
const start = markdown.indexOf(open);
if (start === -1) return null;
const end = markdown.indexOf(close, start + open.length);
if (end === -1) return null;
return markdown.slice(start + open.length, end);
}
/**
* Extract all fenced code blocks (```...```) from a markdown chunk.
* Returns array of { lang, body } objects.
*/
function extractFencedCodeBlocks(markdown) {
const blocks = [];
const lines = markdown.split('\n');
let inFence = false;
let fenceLang = '';
let buffer = [];
for (const line of lines) {
const trimmed = line.trimStart();
if (trimmed.startsWith('```')) {
if (!inFence) {
inFence = true;
fenceLang = trimmed.slice(3).trim();
buffer = [];
} else {
blocks.push({ lang: fenceLang, body: buffer.join('\n') });
inFence = false;
fenceLang = '';
buffer = [];
}
} else if (inFence) {
buffer.push(line);
}
}
return blocks;
}
/**
* Tokenize a shell-like script into individual statements (split on `;`, `&&`, `||`, newlines)
* and return commands as arrays of word tokens. Handles `$(cmd ...)` command substitution
* and `VAR=$(cmd ...)` assignments by extracting the inner command. This is intentionally
* simple — adequate for asserting on the presence of well-known git invocations.
*/
function shellStatements(script) {
const statements = [];
const lines = script.split('\n');
for (let raw of lines) {
const line = raw.replace(/#.*$/, '').trim();
if (!line) continue;
// Split on shell statement separators
const parts = line.split(/(?:&&|\|\||;)/);
for (const part of parts) {
let trimmed = part.trim();
if (!trimmed) continue;
// Strip leading `VAR=` assignments so the substituted command surfaces as cmd[0].
// Then unwrap `$(...)` command substitution.
const assignMatch = trimmed.match(/^[A-Za-z_][A-Za-z0-9_]*=(.*)$/);
if (assignMatch) trimmed = assignMatch[1];
const subMatch = trimmed.match(/^\$\((.*?)\)?$/);
if (subMatch) trimmed = subMatch[1];
// Also handle leading `$(` without closing paren (paren may have been split off)
if (trimmed.startsWith('$(')) trimmed = trimmed.slice(2);
// Strip trailing closing parens left over from substitution
trimmed = trimmed.replace(/\)+\s*$/, '').trim();
if (!trimmed) continue;
// Strip surrounding quotes on the leading word
statements.push(trimmed.split(/\s+/).filter(Boolean));
}
}
return statements;
}
/**
* Find the line index of the first command matching a predicate.
* Returns -1 when not found.
*/
function findCommandIndex(statements, predicate) {
for (let i = 0; i < statements.length; i++) {
if (predicate(statements[i])) return i;
}
return -1;
}
describe('bug #2924: worktree HEAD attachment + destructive recovery', () => {
describe('execute-phase.md worktree_branch_check', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const block = extractNamedBlock(content, 'worktree_branch_check');
test('block exists', () => {
assert.ok(block, 'execute-phase.md must contain a <worktree_branch_check> block');
});
test('block invokes `git symbolic-ref` to inspect HEAD attachment', () => {
const codeBlocks = extractFencedCodeBlocks(block);
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
const idx = findCommandIndex(allStatements, (cmd) =>
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
);
assert.notStrictEqual(
idx, -1,
'worktree_branch_check must run `git symbolic-ref ... HEAD` to verify HEAD attachment before any reset'
);
});
test('HEAD-attachment assertion runs BEFORE `git reset --hard`', () => {
const codeBlocks = extractFencedCodeBlocks(block);
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
const symbolicRefIdx = findCommandIndex(allStatements, (cmd) =>
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
);
const resetHardIdx = findCommandIndex(allStatements, (cmd) =>
cmd[0] === 'git' && cmd[1] === 'reset' && cmd.includes('--hard')
);
assert.notStrictEqual(symbolicRefIdx, -1, 'symbolic-ref check must exist');
assert.notStrictEqual(resetHardIdx, -1, 'reset --hard must exist');
assert.ok(
symbolicRefIdx < resetHardIdx,
'HEAD attachment assertion (symbolic-ref) must precede `git reset --hard` so a stale HEAD never moves a protected branch'
);
});
test('block names protected branches that must NOT be the agent branch', () => {
// The protected-branch list must be enforced by name. Parse it out of the
// shell scripts and verify required names are present.
const codeBlocks = extractFencedCodeBlocks(block);
const scripts = codeBlocks.map(({ body }) => body).join('\n');
// Look for an assignment whose value is a regex/list naming protected refs.
// Acceptable forms: PROTECTED_BRANCHES_RE='...' or grep -Eq '^(main|...)$'
// Parse the alternation list out of the grep -E pattern so we assert
// structurally on the protected-branch enumeration rather than via
// raw substring matching (release/* contains regex-special chars and
// can't be safely tested with `\b...\b`).
const altMatch = scripts.match(/grep\s+-Eq?\s+'\^\(([^)]+)\)\$'/);
assert.ok(
altMatch,
'worktree_branch_check must contain a `grep -Eq` protected-branch alternation pattern'
);
const branches = altMatch[1].split('|').map((b) => b.trim());
const required = ['main', 'master', 'develop', 'trunk', 'release/.*'];
for (const name of required) {
assert.ok(
branches.includes(name),
`worktree_branch_check protected-branch alternation must include '${name}' (found: ${branches.join(', ')})`
);
}
});
test('block enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
const codeBlocks = extractFencedCodeBlocks(block);
const scripts = codeBlocks.map(({ body }) => body).join('\n');
// Allow-list must reference the canonical Claude Code worktree-agent-<id>
// namespace via a regex assertion (grep -Eq '^worktree-agent-...').
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
assert.ok(
allowListRe.test(scripts),
'worktree_branch_check must enforce a positive allow-list matching ^worktree-agent-* (#2924 hardening)'
);
});
test('block forbids `git update-ref` self-recovery in its guidance text', () => {
// The forbidding statement is documentation text, not a shell command,
// so structural shell parsing does not apply. Verify the prohibition
// appears as standalone guidance somewhere in the block.
assert.ok(
block.includes('update-ref'),
'worktree_branch_check must explicitly forbid `git update-ref` self-recovery'
);
});
});
describe('execute-phase.md no longer defaults to --no-verify in parallel mode', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const block = extractNamedBlock(content, 'parallel_execution');
test('parallel_execution block exists', () => {
assert.ok(block, 'execute-phase.md must contain a <parallel_execution> block');
});
test('parallel_execution does NOT instruct agents to use --no-verify by default', () => {
// Tokenize the block as plain words and look for an unconditional
// imperative naming `--no-verify`. The acceptable presence is in a
// negated/opt-out context (e.g. "Do NOT pass --no-verify"); reject
// any sentence whose first verb is "Use --no-verify".
const sentences = block
.replace(/\n+/g, ' ')
.split(/(?<=[.!?])\s+/);
for (const sentence of sentences) {
if (!sentence.includes('--no-verify')) continue;
const lower = sentence.toLowerCase();
const isProhibition =
/\b(do not|don't|never|no longer)\b/.test(lower) ||
/\bopt[\s-]?out\b/.test(lower) ||
/\bopt[\s-]?in\b/.test(lower) ||
/\bif\b/.test(lower);
assert.ok(
isProhibition,
`parallel_execution sentence appears to mandate --no-verify by default: "${sentence.trim()}"`
);
}
});
});
describe('execute-plan.md no longer mandates --no-verify for parallel executor', () => {
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
const block = extractNamedBlock(content, 'precommit_failure_handling');
test('precommit_failure_handling block exists', () => {
assert.ok(block, 'execute-plan.md must contain a <precommit_failure_handling> block');
});
test('parallel-executor sub-section does not unconditionally mandate --no-verify', () => {
// Locate the parallel-executor sub-section heading and parse the
// sentences under it.
const headingIdx = block.indexOf('parallel executor');
assert.notStrictEqual(headingIdx, -1, 'must contain a parallel-executor sub-section');
const endIdx = block.indexOf('**If running as the sole', headingIdx);
assert.notStrictEqual(endIdx, -1, 'parallel-executor sub-section terminator must exist');
const subBlock = block.slice(headingIdx, endIdx);
assert.ok(subBlock.length > 0, 'sub-section must have content');
const sentences = subBlock.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
for (const sentence of sentences) {
if (!sentence.includes('--no-verify')) continue;
const lower = sentence.toLowerCase();
const isProhibition =
/\b(do not|don't|never|no longer)\b/.test(lower) ||
/\bopt[\s-]?out\b/.test(lower) ||
/\bopt[\s-]?in\b/.test(lower) ||
/\bif\b/.test(lower);
assert.ok(
isProhibition,
`parallel-executor guidance sentence appears to mandate --no-verify: "${sentence.trim()}"`
);
}
});
});
describe('quick.md worktree_branch_check', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const block = extractNamedBlock(content, 'worktree_branch_check');
test('block exists', () => {
assert.ok(block, 'quick.md must contain a <worktree_branch_check> block');
});
test('block references `git symbolic-ref` for HEAD attachment assertion', () => {
// quick.md uses inline `git symbolic-ref ... HEAD` rather than a fenced
// block, so search the block as a token stream of statements.
const statements = shellStatements(block);
const idx = findCommandIndex(statements, (cmd) =>
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
);
assert.notStrictEqual(
idx, -1,
'quick.md worktree_branch_check must run `git symbolic-ref ... HEAD`'
);
});
test('HEAD assertion precedes `git reset --hard`', () => {
const symbolicRefByteIdx = block.indexOf('symbolic-ref');
const resetHardByteIdx = block.indexOf('reset --hard');
assert.notStrictEqual(symbolicRefByteIdx, -1);
assert.notStrictEqual(resetHardByteIdx, -1);
assert.ok(
symbolicRefByteIdx < resetHardByteIdx,
'symbolic-ref HEAD assertion must appear before `git reset --hard` in quick.md worktree_branch_check'
);
});
test('block forbids `git update-ref` self-recovery', () => {
assert.ok(
block.includes('update-ref'),
'quick.md worktree_branch_check must explicitly forbid `git update-ref` self-recovery'
);
});
test('block enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
assert.ok(
allowListRe.test(block),
'quick.md worktree_branch_check must enforce a positive allow-list matching ^worktree-agent-* (#2924 hardening)'
);
});
});
describe('quick.md pre-dispatch plan commit no longer hard-codes --no-verify', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const codeBlocks = extractFencedCodeBlocks(content);
// Find the bash block containing the pre-dispatch plan commit
const target = codeBlocks.find(({ body }) =>
body.includes('pre-dispatch plan') && body.includes('git commit')
);
test('pre-dispatch plan commit block exists', () => {
assert.ok(target, 'quick.md must contain the pre-dispatch plan commit block');
});
test('pre-dispatch plan commit gates --no-verify behind a config flag', () => {
// The block must contain BOTH a `git commit` without --no-verify AND
// gate any --no-verify variant inside an `if` block reading a config
// value (workflow.worktree_skip_hooks).
const statements = shellStatements(target.body);
const noVerifyCommits = statements.filter((cmd) =>
cmd[0] === 'git' && cmd[1] === 'commit' && cmd.includes('--no-verify')
);
const cleanCommits = statements.filter((cmd) =>
cmd[0] === 'git' && cmd[1] === 'commit' && !cmd.includes('--no-verify')
);
assert.ok(
cleanCommits.length >= 1,
'must include at least one `git commit` without --no-verify (default path)'
);
// If --no-verify still appears, the block must reference the opt-in flag.
if (noVerifyCommits.length > 0) {
assert.ok(
target.body.includes('worktree_skip_hooks'),
'--no-verify commits must be gated behind workflow.worktree_skip_hooks config flag'
);
}
});
});
describe('gsd-executor.md prohibits update-ref self-recovery', () => {
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
const block = extractNamedBlock(content, 'destructive_git_prohibition');
test('destructive_git_prohibition block exists', () => {
assert.ok(block, 'gsd-executor.md must contain a <destructive_git_prohibition> block');
});
test('block prohibits `git update-ref refs/heads/<protected>`', () => {
assert.ok(
block.includes('update-ref'),
'destructive_git_prohibition must enumerate `git update-ref` as a prohibited command'
);
assert.ok(
block.includes('protected') || block.includes('main') || block.includes('master'),
'destructive_git_prohibition must call out protected branches in the update-ref prohibition'
);
});
test('block references issue #2924', () => {
assert.ok(
block.includes('#2924'),
'destructive_git_prohibition should cite #2924 as the source of the update-ref prohibition'
);
});
});
describe('gsd-executor.md task_commit_protocol enforces worktree-agent-* allow-list', () => {
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
const block = extractNamedBlock(content, 'task_commit_protocol');
test('task_commit_protocol block exists', () => {
assert.ok(block, 'gsd-executor.md must contain a <task_commit_protocol> block');
});
test('step 0 enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
const codeBlocks = extractFencedCodeBlocks(block);
const scripts = codeBlocks.map(({ body }) => body).join('\n');
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
assert.ok(
allowListRe.test(scripts),
'task_commit_protocol step 0 must enforce a positive allow-list matching ^worktree-agent-* in addition to the protected-ref deny-list (#2924 hardening)'
);
});
});
describe('no workflow file performs unconditional update-ref on a protected branch', () => {
const workflowsDir = path.join(REPO_ROOT, 'get-shit-done', 'workflows');
const workflowFiles = fs
.readdirSync(workflowsDir, { recursive: true })
.filter((f) => typeof f === 'string' && f.endsWith('.md'))
.map((f) => path.join(workflowsDir, f));
for (const filePath of workflowFiles) {
test(`${path.basename(filePath)} contains no update-ref of a protected ref`, () => {
const content = fs.readFileSync(filePath, 'utf-8');
const blocks = extractFencedCodeBlocks(content);
for (const { body } of blocks) {
const statements = shellStatements(body);
for (const cmd of statements) {
if (cmd[0] !== 'git') continue;
if (cmd[1] !== 'update-ref') continue;
// Reject any update-ref that targets a protected ref.
const target = cmd[2] || '';
const protectedRe = /^refs\/heads\/(main|master|develop|trunk|release\/.+)$/;
assert.ok(
!protectedRe.test(target),
`${path.basename(filePath)} contains forbidden 'git update-ref ${target}' (#2924)`
);
}
}
});
}
});
describe('git-integration.md guidance reflects new default', () => {
const content = fs.readFileSync(GIT_INTEGRATION_PATH, 'utf-8');
test('parallel-agents guidance no longer mandates --no-verify', () => {
// Find the parallel-agents callout and parse its sentences.
const idx = content.indexOf('Parallel agents');
assert.notStrictEqual(idx, -1, 'must contain a "Parallel agents" callout');
const section = content.slice(idx);
const endMatch = section.slice(1).match(/\n#{1,6}\s/);
assert.ok(endMatch, 'Parallel agents section must terminate at the next heading');
const tail = section.slice(0, 1 + endMatch.index);
const sentences = tail.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
for (const sentence of sentences) {
if (!sentence.includes('--no-verify')) continue;
const lower = sentence.toLowerCase();
const isProhibition =
/\b(do not|don't|never|no longer)\b/.test(lower) ||
/\bopt[\s-]?out\b/.test(lower) ||
/\bopt[\s-]?in\b/.test(lower) ||
/\bif\b/.test(lower);
assert.ok(
isProhibition,
`git-integration.md "Parallel agents" sentence appears to mandate --no-verify: "${sentence.trim()}"`
);
}
});
});
});

View File

@@ -0,0 +1,177 @@
/**
* GSD Tools Tests — detect-custom-files misses skills/ directory (#2942)
*
* After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed root.
* GSD_MANAGED_DIRS was missing 'skills', so user-added skill directories like
* skills/custom-skill/SKILL.md were never walked and got silently destroyed
* during /gsd-update.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
* Each entry is also written to disk so the directory structure exists.
*/
function writeManifest(configDir, files) {
const manifest = {
version: '1.39.0',
timestamp: new Date().toISOString(),
files: {}
};
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
manifest.files[relPath] = sha256(content);
}
fs.writeFileSync(
path.join(configDir, 'gsd-file-manifest.json'),
JSON.stringify(manifest, null, 2)
);
}
/**
* Write a file inside configDir (creating parent dirs), but do NOT add it to the manifest.
*/
function writeCustomFile(configDir, relPath, content) {
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIRS (#2942)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-2942-skills-');
});
afterEach(() => {
cleanup(tmpDir);
});
// Test 1: detects custom skill in skills/<name>/SKILL.md
test('detects custom skill file at skills/<name>/SKILL.md', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
// User-added custom skill — NOT in manifest
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# My Custom Skill\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(json.custom_count >= 1, `custom_count should be >= 1, got ${json.custom_count}`);
assert.ok(
json.custom_files.includes('skills/test-custom/SKILL.md'),
`skills/test-custom/SKILL.md should be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 2: does not flag GSD-owned skills as custom (manifest-tracked path NOT in custom_files)
test('does not flag GSD-owned skill as custom when it is tracked in manifest', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
// No extra files — only the manifest-tracked skill exists
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
`GSD-owned skill should NOT be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 3: regression guard — still detects custom files in get-shit-done/workflows/
test('regression: still detects custom files in get-shit-done/workflows/', () => {
writeManifest(tmpDir, {
'get-shit-done/workflows/plan-phase.md': '# Plan Phase\n',
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'get-shit-done/workflows/custom-workflow.md', '# My Custom Workflow\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(
json.custom_files.includes('get-shit-done/workflows/custom-workflow.md'),
`custom workflow should still be detected; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 4: custom_count matches custom_files.length
test('custom_count matches custom_files.length when multiple custom skills exist', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# Custom Skill One\n');
writeCustomFile(tmpDir, 'skills/another-custom/SKILL.md', '# Custom Skill Two\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(
json.custom_count,
json.custom_files.length,
`custom_count (${json.custom_count}) should equal custom_files.length (${json.custom_files.length})`
);
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom skill files');
});
// Test 5: manifest_found: true when manifest is present
test('manifest_found is true when manifest is present', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.manifest_found, true, 'manifest_found should be true');
});
});

View File

@@ -0,0 +1,132 @@
/**
* Regression test for bug #2943
*
* `gsd-tools.cjs config-get context_window` (and the SDK equivalent) threw
* "Key not found: context_window" when the key was absent from config.json,
* even though context_window has a documented schema default of 200000.
*
* Fix: `cmdConfigGet` in bin/lib/config.cjs now consults a SCHEMA_DEFAULTS map
* before emitting "Key not found", so schema-defaulted keys always return the
* default value (exit 0) when not explicitly set in the project config.
*/
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const GSD_TOOLS = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
describe('bug-2943: config-get returns schema default for context_window', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-2943-'));
planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
/**
* Run config-get with optional extra args. Returns { exitCode, stdout, stderr }.
* Uses --raw so we get the plain scalar value, not JSON-wrapped.
*/
function runConfigGet(keyPath, extraArgs = []) {
const args = [GSD_TOOLS, 'config-get', keyPath, '--raw', '--cwd', tmpDir, ...extraArgs];
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
stdout = execFileSync(process.execPath, args, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
});
} catch (err) {
exitCode = err.status ?? 1;
stdout = err.stdout?.toString() ?? '';
stderr = err.stderr?.toString() ?? '';
}
return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
}
test('returns "200000" (exit 0) when context_window absent from config.json', () => {
// Fixture A: config with unrelated keys, no context_window
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: false } })
);
const result = runConfigGet('context_window');
assert.strictEqual(result.exitCode, 0, 'should exit 0 (schema default applied)');
assert.strictEqual(result.stdout, '200000', 'should return schema default of 200000');
});
test('returns configured value when context_window is explicitly set', () => {
// Fixture B: config has context_window: 1000000
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ context_window: 1000000 })
);
const result = runConfigGet('context_window');
assert.strictEqual(result.exitCode, 0, 'should exit 0 for found key');
assert.strictEqual(result.stdout, '1000000', 'should return configured value not schema default');
});
test('--default flag overrides schema default', () => {
// config has context_window but we pass --default with a different value —
// when key IS present, real value wins over any default
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: false } })
);
const result = runConfigGet('context_window', ['--default', '123456']);
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default provided');
assert.strictEqual(result.stdout, '123456', 'should return the --default value, not schema default');
});
test('errors with "Key not found" (exit 1) for an unknown absent key — no regression', () => {
// An unrecognised key with no schema default still errors as before
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: false } })
);
const result = runConfigGet('totally_unknown_key_xyz');
assert.strictEqual(result.exitCode, 1, 'should exit 1 for unknown absent key');
assert.ok(
result.stderr.includes('Key not found') || result.stdout.includes('Key not found'),
`expected "Key not found" in output, got stderr="${result.stderr}" stdout="${result.stdout}"`
);
});
test('--default flag still works for arbitrary absent keys', () => {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({})
);
const result = runConfigGet('some.missing.key', ['--default', '200000']);
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default supplied');
assert.strictEqual(result.stdout, '200000', 'should return the explicit --default value');
});
});

View File

@@ -0,0 +1,162 @@
/**
* Regression test for bug #2948
*
* `/gsd-spike --wrap-up` was silently no-oping because:
* 1. `commands/gsd/spike.md` listed `--wrap-up` as a flag but had no dispatch block.
* 2. `workflows/spike.md` still referenced the deleted `/gsd-spike-wrap-up` entry-point
* instead of the correct `/gsd-spike --wrap-up` form.
*
* Fix:
* - `commands/gsd/spike.md` now has a dispatch block that routes `--wrap-up` to
* spike-wrap-up.md, and spike-wrap-up.md is listed in execution_context so the
* runtime can find it.
* - `workflows/spike.md` companion references updated from `/gsd-spike-wrap-up` to
* `/gsd-spike --wrap-up`.
*/
// allow-test-rule: source-text-is-the-product
// commands/gsd/*.md files ARE what the runtime loads — testing their
// frontmatter and section content tests the deployed system-prompt contract.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SPIKE_CMD_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'spike.md');
const SPIKE_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'spike.md');
/**
* Parse YAML frontmatter + body from a markdown file.
* Returns a shallow { key: value } map of frontmatter fields plus `_body`.
* Mirrors the parseFrontmatter utility used in enh-2792-namespace-skills.test.cjs.
*/
function parseFrontmatter(content) {
const lines = content.split(/\r?\n/);
// Frontmatter must start at the very first line; a mid-file '---' is a
// horizontal rule, not a frontmatter delimiter.
if (lines[0]?.trim() !== '---') {
return { _body: content };
}
let closeIdx = -1;
for (let i = 1; i < lines.length; i += 1) {
if (lines[i].trim() === '---') {
closeIdx = i;
break;
}
}
assert.ok(closeIdx !== -1, 'frontmatter block must be delimited by --- on its own lines');
const fm = {};
for (const line of lines.slice(1, closeIdx)) {
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (!m) continue;
const [, key, raw] = m;
fm[key] = raw.trim().replace(/^["']|["']$/g, '');
}
fm._body = lines.slice(closeIdx + 1).join('\n');
return fm;
}
/**
* Extract the text content of a named XML-like section from a markdown body.
* Returns null if the section is absent.
*/
function extractSection(body, tag) {
const open = `<${tag}>`;
const close = `</${tag}>`;
const start = body.indexOf(open);
const end = body.indexOf(close);
if (start === -1 || end === -1) return null;
return body.slice(start + open.length, end);
}
/**
* Parse the @-prefixed workflow references out of an execution_context section.
* Returns an array of resolved reference strings (@ stripped).
*/
function parseExecutionContextRefs(section) {
return section
.split(/\r?\n/)
.map(l => l.trim())
.filter(l => l.startsWith('@'))
.map(l => l.slice(1).trim());
}
describe('bug-2948: /gsd-spike --wrap-up dispatch wiring', () => {
describe('commands/gsd/spike.md — frontmatter and section contract', () => {
test('spike.md command file exists and has valid frontmatter', () => {
assert.ok(fs.existsSync(SPIKE_CMD_PATH), 'commands/gsd/spike.md should exist');
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
assert.ok(fm.name, 'frontmatter must have a name field');
});
test('argument-hint frontmatter field advertises --wrap-up flag', () => {
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
assert.ok(
fm['argument-hint'] && fm['argument-hint'].includes('--wrap-up'),
`argument-hint must advertise --wrap-up; got: "${fm['argument-hint']}"`
);
});
test('execution_context section includes spike-wrap-up workflow reference', () => {
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
const execSection = extractSection(fm._body, 'execution_context');
assert.ok(execSection !== null, 'spike.md must have an <execution_context> section');
const refs = parseExecutionContextRefs(execSection);
assert.ok(
refs.some(r => r.includes('spike-wrap-up')),
`execution_context must declare a spike-wrap-up reference so the runtime can load the workflow; ` +
`declared refs: ${JSON.stringify(refs)}`
);
});
test('process section dispatches first-token --wrap-up to spike-wrap-up workflow', () => {
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
const processSection = extractSection(fm._body, 'process');
assert.ok(processSection, 'spike.md must have a <process> section');
const rules = processSection
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
const wrapUpRule = rules.find(line => line.startsWith('- If it is `--wrap-up`:'));
const fallbackRule = rules.find(line => line.startsWith('- Otherwise:'));
assert.ok(
wrapUpRule && wrapUpRule.includes('strip the flag') && wrapUpRule.includes('spike-wrap-up'),
'process must define a --wrap-up branch that strips the flag and routes to spike-wrap-up'
);
assert.ok(
fallbackRule && fallbackRule.includes('spike workflow'),
'process must define an Otherwise fallback to the normal spike workflow'
);
});
});
describe('get-shit-done/workflows/spike.md — companion references', () => {
test('spike workflow file exists', () => {
assert.ok(fs.existsSync(SPIKE_WORKFLOW_PATH), 'get-shit-done/workflows/spike.md should exist');
});
test('does NOT reference the deleted /gsd-spike-wrap-up entry-point', () => {
const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8'));
assert.ok(
!fm._body.includes('/gsd-spike-wrap-up'),
'workflows/spike.md must not reference the deleted /gsd-spike-wrap-up command; use /gsd-spike --wrap-up instead'
);
});
test('references /gsd-spike --wrap-up as the canonical wrap-up invocation', () => {
const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8'));
assert.ok(
fm._body.includes('/gsd-spike --wrap-up'),
'workflows/spike.md must reference /gsd-spike --wrap-up as the canonical wrap-up command'
);
});
});
});

View File

@@ -0,0 +1,61 @@
/**
* GSD Tests — /gsd-sketch --wrap-up silently no-ops (#2949)
*
* The --wrap-up flag was documented in commands/gsd/sketch.md but never dispatched.
* The sketch-wrap-up.md micro-skill entry point was deleted in #2790 and the dispatch
* wiring was never added to the command or workflow.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const SKETCH_COMMAND = path.join(ROOT, 'commands/gsd/sketch.md');
const SKETCH_WORKFLOW = path.join(ROOT, 'get-shit-done/workflows/sketch.md');
describe('bug-2949: sketch --wrap-up dispatch wiring', () => {
test('commands/gsd/sketch.md contains --wrap-up dispatch logic', () => {
const content = fs.readFileSync(SKETCH_COMMAND, 'utf8');
assert.ok(
content.includes('--wrap-up'),
'sketch.md should contain --wrap-up dispatch logic'
);
// The dispatch should route to sketch-wrap-up workflow
assert.ok(
content.includes('sketch-wrap-up'),
'sketch.md should reference sketch-wrap-up in dispatch logic'
);
});
test('commands/gsd/sketch.md has sketch-wrap-up in execution_context section', () => {
const content = fs.readFileSync(SKETCH_COMMAND, 'utf8');
// Find execution_context block
const execCtxMatch = content.match(/<execution_context>([\s\S]*?)<\/execution_context>/);
assert.ok(execCtxMatch, 'sketch.md must have an <execution_context> block');
const execCtx = execCtxMatch[1];
assert.ok(
execCtx.includes('sketch-wrap-up'),
`execution_context block should include sketch-wrap-up workflow; got: ${execCtx}`
);
});
test('workflows/sketch.md does NOT contain old /gsd-sketch-wrap-up form', () => {
const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8');
assert.ok(
!content.includes('/gsd-sketch-wrap-up'),
'workflows/sketch.md must not reference the old /gsd-sketch-wrap-up command'
);
});
test('workflows/sketch.md DOES contain new /gsd-sketch --wrap-up form', () => {
const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8');
assert.ok(
content.includes('/gsd-sketch --wrap-up'),
'workflows/sketch.md should reference /gsd-sketch --wrap-up (the new form)'
);
});
});

View File

@@ -0,0 +1,140 @@
/**
* Bug #2950: Stale deleted command references in workflow files
*
* Multiple workflow files referenced command names removed in #2790
* (gsd-add-phase, gsd-insert-phase, gsd-remove-phase, gsd-add-todo,
* gsd-set-profile, gsd-settings-integrations, gsd-settings-advanced,
* gsd-spike-wrap-up, gsd-sketch-wrap-up, gsd-code-review-fix).
*
* Fix: Update every occurrence to the new consolidated forms:
* /gsd-phase (no flag | --insert | --remove)
* /gsd-capture
* /gsd-config (--profile | --integrations | --advanced)
* /gsd-spike --wrap-up
* /gsd-sketch --wrap-up
* /gsd-code-review --fix
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'get-shit-done', 'workflows');
function read(filename) {
return fs.readFileSync(path.join(WORKFLOWS_DIR, filename), 'utf-8');
}
// Deleted command names that must not appear anywhere in the fixed files.
const DELETED_COMMANDS = [
'/gsd-add-phase',
'/gsd-insert-phase',
'/gsd-remove-phase',
'/gsd-add-todo',
'/gsd-set-profile',
'/gsd-settings-integrations',
'/gsd-settings-advanced',
'/gsd-spike-wrap-up',
'/gsd-sketch-wrap-up',
'/gsd-code-review-fix',
];
// Per-file assertions: [file, deletedCmd, newForm]
const FILE_ASSERTIONS = [
// help.md
['help.md', '/gsd-add-phase', '/gsd-phase "Add admin dashboard"'],
['help.md', '/gsd-insert-phase', '/gsd-phase --insert 7 "Fix critical auth bug"'],
['help.md', '/gsd-remove-phase', '/gsd-phase --remove 17'],
['help.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
['help.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
['help.md', '/gsd-add-todo', '/gsd-capture'],
['help.md', '/gsd-set-profile', '/gsd-config --profile budget'],
// do.md
['do.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
['do.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
['do.md', '/gsd-add-phase', '/gsd-phase'],
['do.md', '/gsd-add-todo', '/gsd-capture'],
// settings.md
['settings.md', '/gsd-code-review-fix', '/gsd-code-review --fix'],
['settings.md', '/gsd-settings-integrations', '/gsd-config --integrations'],
['settings.md', '/gsd-set-profile', '/gsd-config --profile'],
['settings.md', '/gsd-settings-advanced', '/gsd-config --advanced'],
// discuss-phase.md
['discuss-phase.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
['discuss-phase.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
// new-project.md
['new-project.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
['new-project.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
// plan-phase.md
['plan-phase.md', '/gsd-insert-phase', '/gsd-phase --insert'],
// spike.md
['spike.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
// sketch.md
['sketch.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
];
describe('bug #2950: stale deleted-command references removed from workflow files', () => {
// Build a map of file → content to avoid re-reading
const files = [...new Set(FILE_ASSERTIONS.map(([f]) => f))];
const contentMap = {};
for (const f of files) {
contentMap[f] = read(f);
}
// For each (file, deletedCmd) pair, assert the old name is absent
for (const [file, deletedCmd] of FILE_ASSERTIONS) {
test(`${file}: does not contain deleted command "${deletedCmd}"`, () => {
const content = contentMap[file];
assert.ok(
!content.includes(deletedCmd),
`${file} still contains deleted command "${deletedCmd}" — update to new form`
);
});
}
// For each (file, deletedCmd, newForm) triple, assert the new form is present
for (const [file, , newForm] of FILE_ASSERTIONS) {
test(`${file}: contains new form "${newForm}"`, () => {
const content = contentMap[file];
assert.ok(
content.includes(newForm),
`${file} is missing expected new form "${newForm}"`
);
});
}
// Blanket check: no affected workflow file contains any of the deleted command names
// (catches any we might have missed in per-file assertions above)
const affectedFiles = [
'help.md',
'do.md',
'settings.md',
'discuss-phase.md',
'new-project.md',
'plan-phase.md',
'spike.md',
'sketch.md',
];
for (const file of affectedFiles) {
const content = read(file);
for (const deleted of DELETED_COMMANDS) {
test(`${file}: blanket check — "${deleted}" not present`, () => {
assert.ok(
!content.includes(deleted),
`${file} contains deleted command "${deleted}"`
);
});
}
}
});

View File

@@ -0,0 +1,165 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #2954: keep `help.md` and the live `commands/gsd/*` slash surface
* in lockstep. Two regression tests:
*
* 1. help.md must not advertise any /gsd-<name> that has no shipped
* slash command. (Caught the original #2954 regression: #2824 deleted
* 31 stubs without updating help.md.)
*
* 2. Every shipped /gsd-<name> command must appear in help.md. (Caught
* the inverse: a command lands without docs, so users never discover it.)
*
* The shipped slash name is parsed from frontmatter `name:` (which can be
* either `gsd:foo` or `gsd-foo` — Claude Code surfaces both as `/gsd-foo`),
* NOT from the filename, because some files (e.g. `ns-context.md`) ship a
* different slash name (`gsd-context`) than their filename suggests.
*
* Also covers `do.md`, the dispatcher invoked at runtime by
* `/gsd-progress --do`: any `/gsd-<name>` token in its routing table must
* resolve to a live command, otherwise the dispatcher emits "Unknown command".
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
const HELP_MD = path.join(ROOT, 'get-shit-done', 'workflows', 'help.md');
const DO_MD = path.join(ROOT, 'get-shit-done', 'workflows', 'do.md');
function parseFrontmatter(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;
const fields = {};
for (const line of match[1].split(/\r?\n/)) {
const fieldMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
if (!fieldMatch) continue;
const value = fieldMatch[2].trim().replace(/^["']|["']$/g, '');
fields[fieldMatch[1]] = value;
}
return fields;
}
/**
* Returns the set of slash-base-names actually shipped under commands/gsd/.
* A "slash-base-name" is the part after `/gsd-` — e.g. for frontmatter
* `name: gsd:foo` or `name: gsd-foo`, the slash-base-name is `foo`.
*/
function listShippedSlashBaseNames() {
const names = new Set();
for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8');
const fm = parseFrontmatter(content);
if (!fm || !fm.name) continue;
const fmName = fm.name;
let base = null;
if (fmName.startsWith('gsd:')) base = fmName.slice(4);
else if (fmName.startsWith('gsd-')) base = fmName.slice(4);
if (base && /^[a-z][a-z0-9-]*$/.test(base)) names.add(base);
}
return names;
}
function extractSlashReferences(contents) {
const names = new Set();
const tokenRe = /\/gsd-([a-z][a-z0-9-]*)/g;
let match;
while ((match = tokenRe.exec(contents)) !== null) {
names.add(match[1]);
}
return names;
}
/**
* For every shipped command with an `argument-hint:` frontmatter entry,
* collect the `--flag` tokens it advertises. Returns a Map<slashBaseName,
* Set<flagName>>. Flags are recorded without their leading `--`.
*/
function listShippedFlagsByCommand() {
const out = new Map();
for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8');
const fm = parseFrontmatter(content);
if (!fm || !fm.name || !fm['argument-hint']) continue;
const fmName = fm.name;
let base = null;
if (fmName.startsWith('gsd:')) base = fmName.slice(4);
else if (fmName.startsWith('gsd-')) base = fmName.slice(4);
if (!base || !/^[a-z][a-z0-9-]*$/.test(base)) continue;
const flags = new Set();
for (const m of fm['argument-hint'].matchAll(/--([a-z][a-z0-9-]*)/g)) {
flags.add(m[1]);
}
if (flags.size) out.set(base, flags);
}
return out;
}
describe('Bug #2954: help.md ↔ commands/gsd/ bidirectional parity', () => {
test('every /gsd-<name> referenced in help.md is a shipped command', () => {
const helpContents = fs.readFileSync(HELP_MD, 'utf8');
const referenced = extractSlashReferences(helpContents);
const shipped = listShippedSlashBaseNames();
const dangling = [...referenced].filter((n) => !shipped.has(n)).sort();
assert.deepEqual(
dangling,
[],
`help.md advertises /gsd-<name> commands that are not shipped: ${dangling.join(', ')}`,
);
});
test('every shipped /gsd-<name> command is documented in help.md', () => {
const helpContents = fs.readFileSync(HELP_MD, 'utf8');
const referenced = extractSlashReferences(helpContents);
const shipped = listShippedSlashBaseNames();
const undocumented = [...shipped].filter((n) => !referenced.has(n)).sort();
assert.deepEqual(
undocumented,
[],
`commands shipped under commands/gsd/ with no /gsd-<name> reference in help.md: ${undocumented.join(', ')}`,
);
});
test('every /gsd-<name> in do.md (live dispatcher) is a shipped command', () => {
const doContents = fs.readFileSync(DO_MD, 'utf8');
const referenced = extractSlashReferences(doContents);
const shipped = listShippedSlashBaseNames();
const dangling = [...referenced].filter((n) => !shipped.has(n)).sort();
assert.deepEqual(
dangling,
[],
`do.md routing table references /gsd-<name> that is not shipped: ${dangling.join(', ')}`,
);
});
test('every --flag in a command\'s argument-hint appears in help.md', () => {
const helpContents = fs.readFileSync(HELP_MD, 'utf8');
const flagsByCommand = listShippedFlagsByCommand();
const gaps = [];
for (const [command, flags] of flagsByCommand) {
for (const flag of flags) {
// Accept `/gsd-<command> --<flag>` (precise) OR a bare `--<flag>` token
// anywhere in help.md (good enough for shared flags like `--force` that
// appear under multiple commands' descriptions).
const preciseToken = `/gsd-${command} --${flag}`;
const flagToken = `--${flag}`;
if (!helpContents.includes(preciseToken) && !helpContents.includes(flagToken)) {
gaps.push(`/gsd-${command} --${flag}`);
}
}
}
assert.deepEqual(
gaps.sort(),
[],
`commands ship --flag(s) in argument-hint that are absent from help.md: ${gaps.join(', ')}`,
);
});
});

View File

@@ -0,0 +1,70 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #2957: post-install message for `--claude --global` must instruct
* users to restart Claude Code and offer the skill-name fallback, since
* the skills-only install layout (CC 2.1.88+) leaves nothing in
* commands/gsd/ for the slash menu to read on older configurations.
*
* Captures the call to finishInstall(runtime='claude', isGlobal=true) and
* asserts the printed message contains both invocation paths.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
function captureFinishInstallOutput(runtime, isGlobal) {
const original = console.log;
const lines = [];
console.log = (...args) => { lines.push(args.join(' ')); };
try {
installModule.finishInstall(
'/tmp/gsd-test-settings.json',
{},
null,
false,
runtime,
isGlobal,
null,
);
} finally {
console.log = original;
}
// Strip ANSI color escapes so message-content assertions don't couple to colors.
return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, '');
}
describe('Bug #2957: claude+global post-install message', () => {
test('claude+global message tells the user to restart and offers skill-name fallback', () => {
const output = captureFinishInstallOutput('claude', true);
assert.match(output, /restart claude code/i, 'should mention restart');
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
assert.match(output, /gsd-new-project skill/i, 'should mention the skill name fallback');
assert.doesNotMatch(
output,
/open a blank directory/i,
'global claude install should replace, not extend, the legacy generic instruction',
);
});
test('claude+local message keeps the original /gsd-new-project instruction', () => {
const output = captureFinishInstallOutput('claude', false);
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
assert.doesNotMatch(output, /restart claude code/i, 'local install does not require the skills restart note');
});
test('non-claude runtimes keep their original message format', () => {
const output = captureFinishInstallOutput('opencode', true);
assert.match(output, /Open a blank directory/, 'opencode message should be unchanged');
assert.doesNotMatch(output, /restart/i, 'opencode message should not have the claude-specific restart note');
});
});

View File

@@ -0,0 +1,150 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #2962: --sdk install flag on Windows leaves gsd-sdk un-shimmed.
*
* Tests are split into two layers, each at the right level of abstraction:
*
* 1. buildWindowsShimTriple — pure IR builder. Tests assert on TYPED
* FIELDS of the returned record (interpreter, target, eol, fileNames).
* No filesystem, no spawn, no text reads. This is the level where
* structural correctness lives.
*
* 2. trySelfLinkGsdSdkWindows — fs/spawn driver that calls the IR builder
* and writes the rendered shims to disk. Tests assert FILESYSTEM FACTS
* (file exists, file is non-empty, file mtime advanced after replace,
* function return value). No reads, no parsing, no substring matching.
*
* Per the repo's no-source-grep testing standard (CONTRIBUTING.md): the
* test must NEVER read shim file contents and pattern-match against them.
* The IR is the contract; the rendered text is an implementation detail of
* the renderer.
*/
const { test, describe, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const cp = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
describe('Bug #2962: buildWindowsShimTriple — pure IR builder', () => {
test('resolves shimSrc to an absolute path on the invocation.target field', () => {
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
const triple = installModule.buildWindowsShimTriple(shimSrc);
assert.equal(triple.invocation.target, path.resolve(shimSrc));
assert.equal(triple.invocation.interpreter, 'node');
});
test('produces a structured IR with the documented shape', () => {
const triple = installModule.buildWindowsShimTriple(path.join(ROOT, 'bin', 'gsd-sdk.js'));
// Lock the public IR shape — adding/removing a key requires updating this assertion.
assert.deepEqual(Object.keys(triple).sort(), ['eol', 'fileNames', 'invocation', 'render']);
assert.deepEqual(Object.keys(triple.invocation).sort(), ['interpreter', 'target']);
assert.deepEqual(Object.keys(triple.eol).sort(), ['cmd', 'ps1', 'sh']);
assert.deepEqual(Object.keys(triple.fileNames).sort(), ['cmd', 'ps1', 'sh']);
assert.deepEqual(Object.keys(triple.render).sort(), ['cmd', 'ps1', 'sh']);
});
test('declares CRLF line endings on the .cmd file, LF on .ps1 and bash wrapper', () => {
const triple = installModule.buildWindowsShimTriple(path.join(ROOT, 'bin', 'gsd-sdk.js'));
assert.deepEqual(triple.eol, { cmd: '\r\n', ps1: '\n', sh: '\n' });
});
test('declares the standard npm-style filenames for the shim triple', () => {
const triple = installModule.buildWindowsShimTriple(path.join(ROOT, 'bin', 'gsd-sdk.js'));
assert.deepEqual(triple.fileNames, { cmd: 'gsd-sdk.cmd', ps1: 'gsd-sdk.ps1', sh: 'gsd-sdk' });
});
test('IR is purely a function of shimSrc — no fs / spawn side effects', () => {
// If buildWindowsShimTriple touched the filesystem, calling it twice with
// different shimSrc paths would leave two different artifacts. Asserting
// pure-function behavior structurally: same input → identical IR.
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
const a = installModule.buildWindowsShimTriple(shimSrc);
const b = installModule.buildWindowsShimTriple(shimSrc);
assert.deepEqual(a.invocation, b.invocation);
assert.deepEqual(a.eol, b.eol);
assert.deepEqual(a.fileNames, b.fileNames);
});
});
describe('Bug #2962: trySelfLinkGsdSdkWindows — fs/spawn driver', () => {
let tmpDir;
let origExecSync;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2962-'));
origExecSync = cp.execSync;
cp.execSync = (cmd) => {
if (typeof cmd === 'string' && cmd.trim() === 'npm prefix -g') {
return tmpDir + '\n';
}
return origExecSync.call(cp, cmd);
};
});
after(() => {
cp.execSync = origExecSync;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('returns the .cmd path on success and writes all three shim files', () => {
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
const triple = installModule.buildWindowsShimTriple(shimSrc);
const result = installModule.trySelfLinkGsdSdkWindows(shimSrc);
assert.equal(result, path.join(tmpDir, triple.fileNames.cmd));
for (const fileName of Object.values(triple.fileNames)) {
const target = path.join(tmpDir, fileName);
const stat = fs.statSync(target);
assert.ok(stat.isFile(), `${fileName} must be a regular file`);
assert.ok(stat.size > 0, `${fileName} must be non-empty`);
}
});
test('the rendered file size matches the IR renderer\'s output length (renderer drives the writer)', () => {
// Asserts the writer writes exactly what the renderer produces — no mutation,
// no double-write, no truncation. We compare BYTE LENGTHS, not contents:
// length is a structural property; content equality would re-introduce text matching.
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
const triple = installModule.buildWindowsShimTriple(shimSrc);
installModule.trySelfLinkGsdSdkWindows(shimSrc);
for (const kind of ['cmd', 'ps1', 'sh']) {
const target = path.join(tmpDir, triple.fileNames[kind]);
const expected = Buffer.byteLength(triple.render[kind](), 'utf8');
assert.equal(fs.statSync(target).size, expected, `${kind} byte length matches renderer`);
}
});
test('replaces stale shims atomically (mtime advances on rewrite)', () => {
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
installModule.trySelfLinkGsdSdkWindows(shimSrc);
const cmdPath = path.join(tmpDir, 'gsd-sdk.cmd');
const beforeMtime = fs.statSync(cmdPath).mtimeMs;
// Wait at least 10ms so mtime granularity (1ms on most fs, 1s on some) records the change.
const wait = Date.now() + 20;
while (Date.now() < wait) { /* busy-wait, intentional */ }
installModule.trySelfLinkGsdSdkWindows(shimSrc);
const afterMtime = fs.statSync(cmdPath).mtimeMs;
assert.ok(afterMtime > beforeMtime, `mtime must advance: before=${beforeMtime} after=${afterMtime}`);
});
test('returns null when npm prefix -g fails', () => {
const restore = cp.execSync;
cp.execSync = () => { throw new Error('npm not on PATH'); };
try {
const result = installModule.trySelfLinkGsdSdkWindows(path.join(ROOT, 'bin', 'gsd-sdk.js'));
assert.equal(result, null);
} finally {
cp.execSync = restore;
}
});
});

View File

@@ -0,0 +1,205 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #2969: /gsd-reapply-patches Step 5 hunk verification gate reports
* success on lost content because the LLM-driven workflow fills in
* "verified: yes" without actually checking content presence.
*
* Fix: deterministic verifier script (scripts/verify-reapply-patches.cjs)
* that the workflow calls.
*
* Per the repo's no-source-grep testing standard (CONTRIBUTING.md):
* tests must assert on TYPED structured fields — not regex/substring
* matching against script output, formatter prose, or file content.
*
* The script's --json mode emits a structured report whose `reason`
* field is a stable enum (exposed as REASON), and whose `missing` field
* is an array of typed strings (exact set membership, not substring).
* Every assertion below is a deepEqual / equal / Array.includes against
* those typed fields. Zero regex, zero String#includes on text.
*/
const { test, describe, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const cp = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'verify-reapply-patches.cjs');
const { REASON } = require(SCRIPT);
let tmpRoot;
let patchesDir;
let configDir;
let pristineDir;
function writeFile(absPath, content) {
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content);
}
function resetFixture({ withPristine = true } = {}) {
for (const dir of [patchesDir, configDir, pristineDir]) {
fs.rmSync(dir, { recursive: true, force: true });
}
fs.mkdirSync(patchesDir);
fs.mkdirSync(configDir);
if (withPristine) fs.mkdirSync(pristineDir);
}
/** Runs the verifier with --json. Returns parsed structured report. */
function runVerifier({ includePristine = true } = {}) {
const args = [
SCRIPT,
'--patches-dir', patchesDir,
'--config-dir', configDir,
...(includePristine ? ['--pristine-dir', pristineDir] : []),
'--json',
];
const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8' });
return {
status: r.status,
report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null,
};
}
before(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2969-'));
patchesDir = path.join(tmpRoot, 'patches');
configDir = path.join(tmpRoot, 'installed');
pristineDir = path.join(tmpRoot, 'pristine');
resetFixture();
});
after(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
describe('Bug #2969: deterministic Step 5 verification gate', () => {
test('REASON enum exposes the documented set of stable codes', () => {
// Locks the public diagnostic surface — adding a code requires updating
// this assertion, removing one breaks consumers that switch on the enum.
assert.deepEqual(
Object.keys(REASON).sort(),
[
'FAIL_INSTALLED_MISSING',
'FAIL_INSTALLED_NOT_REGULAR_FILE',
'FAIL_READ_ERROR',
'FAIL_USER_LINES_MISSING',
'OK_NO_SIGNIFICANT_BACKUP_LINES',
'OK_NO_USER_LINES_VS_PRISTINE',
],
);
});
test('exits 0 with status=ok when every user-added line is present in the merged file', () => {
resetFixture();
const pristine = 'line one of stock content here\nline two of stock content here\nline three of stock content here\n';
const userAdded = 'a custom line the user added for behavior X\nanother substantial line that the user inserted\n';
writeFile(path.join(pristineDir, 'skills', 'foo', 'SKILL.md'), pristine);
writeFile(path.join(patchesDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded);
writeFile(path.join(configDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded);
const { status, report } = runVerifier();
assert.equal(status, 0);
assert.equal(report.failures, 0);
assert.equal(report.checked, 1);
assert.equal(report.results[0].status, 'ok');
assert.deepEqual(report.results[0].missing, []);
});
test('reason=FAIL_USER_LINES_MISSING with the exact dropped line in .missing[]', () => {
resetFixture();
const pristine = 'first stock line in the original file here\nsecond stock line in the original file here\n';
const lostLine = 'this is the visual companion block that must survive';
writeFile(path.join(pristineDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine);
writeFile(path.join(patchesDir, 'skills', 'discuss-phase', 'SKILL.md'), `${pristine}${lostLine}\n`);
writeFile(path.join(configDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine);
const { status, report } = runVerifier();
assert.equal(status, 1);
assert.equal(report.failures, 1);
const r0 = report.results[0];
assert.equal(r0.file, 'skills/discuss-phase/SKILL.md');
assert.equal(r0.status, 'fail');
assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING);
assert.ok(
r0.missing.includes(lostLine),
`dropped line should be in .missing[]; got ${JSON.stringify(r0.missing)}`,
);
});
test('reason=FAIL_INSTALLED_NOT_REGULAR_FILE when installed path is a directory', () => {
resetFixture();
writeFile(path.join(pristineDir, 'a.md'), 'pristine line of substantial content here\n');
writeFile(path.join(patchesDir, 'a.md'), 'pristine line of substantial content here\nuser added line that is substantial\n');
fs.mkdirSync(path.join(configDir, 'a.md')); // EISDIR trap
const { status, report } = runVerifier();
assert.equal(status, 1);
assert.equal(report.results[0].status, 'fail');
assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_NOT_REGULAR_FILE);
});
test('reason=FAIL_INSTALLED_MISSING when the merged file has been deleted', () => {
resetFixture();
const pristine = 'stock line one with substantial content for the test\n';
writeFile(path.join(pristineDir, 'workflow.md'), pristine);
writeFile(path.join(patchesDir, 'workflow.md'), `${pristine}user line that should survive but does not\n`);
// configDir intentionally missing the file.
const { status, report } = runVerifier();
assert.equal(status, 1);
assert.equal(report.results[0].status, 'fail');
assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_MISSING);
});
test('--json report has the documented shape: { checked, failures, results: [{ file, status, missing, reason }] }', () => {
resetFixture();
const pristine = 'pristine line that is sufficiently long to be significant\n';
const userAdded = 'extra line the user wrote for their workflow customisation';
writeFile(path.join(pristineDir, 'a.md'), pristine);
writeFile(path.join(patchesDir, 'a.md'), `${pristine}${userAdded}\n`);
writeFile(path.join(configDir, 'a.md'), pristine);
const { status, report } = runVerifier();
assert.equal(status, 1);
assert.deepEqual(Object.keys(report).sort(), ['checked', 'failures', 'results']);
const r0 = report.results[0];
assert.deepEqual(Object.keys(r0).sort(), ['file', 'missing', 'reason', 'status']);
assert.equal(typeof r0.file, 'string');
assert.equal(typeof r0.status, 'string');
assert.equal(typeof r0.reason, 'string');
assert.ok(Array.isArray(r0.missing));
});
test('ignores backup-meta.json — it is metadata, not a patched file', () => {
resetFixture();
writeFile(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({ files: [] }));
const { status, report } = runVerifier();
assert.equal(status, 0);
assert.equal(report.checked, 0);
assert.equal(report.failures, 0);
assert.deepEqual(report.results, []);
});
test('without --pristine-dir, treats every significant backup line as required (safe over-broad fallback)', () => {
resetFixture({ withPristine: false });
const presentLine = 'this is a substantial line of user content here';
const droppedLine = 'another substantial line that should survive';
writeFile(path.join(patchesDir, 'b.md'), `${presentLine}\n${droppedLine}\n`);
writeFile(path.join(configDir, 'b.md'), `${presentLine}\n`);
const { status, report } = runVerifier({ includePristine: false });
assert.equal(status, 1);
assert.equal(report.results[0].reason, REASON.FAIL_USER_LINES_MISSING);
assert.ok(report.results[0].missing.includes(droppedLine));
assert.ok(!report.results[0].missing.includes(presentLine));
});
});

View File

@@ -1,5 +1,9 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
/**
* Tests for get-shit-done/bin/lib/graphify.cjs
*

View File

@@ -201,11 +201,15 @@ describe('gsd-settings-advanced — VALID_CONFIG_KEYS coverage', () => {
// ─── /gsd-settings mentions /gsd-settings-advanced ────────────────────────────
describe('/gsd-settings advertises /gsd-settings-advanced', () => {
test('settings workflow confirmation mentions gsd-settings-advanced', () => {
test('settings workflow mentions canonical /gsd-config --advanced', () => {
const text = fs.readFileSync(SETTINGS_WORKFLOW_PATH, 'utf-8');
assert.ok(
text.includes('gsd-settings-advanced') || text.includes('gsd:settings-advanced'),
'get-shit-done/workflows/settings.md must mention /gsd-settings-advanced or /gsd:settings-advanced'
text.includes('gsd-config --advanced'),
'get-shit-done/workflows/settings.md must mention /gsd-config --advanced'
);
assert.ok(
!text.includes('gsd-settings-advanced') && !text.includes('gsd:settings-advanced'),
'get-shit-done/workflows/settings.md must not mention legacy /gsd-settings-advanced variants'
);
});
});

View File

@@ -1,3 +1,7 @@
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
/**
* GSD Tools Tests - Community Hooks (opt-in)
*

View File

@@ -0,0 +1,337 @@
/**
* Per-runtime regression test for `--minimal` install profile (#2923).
*
* Background: #2923 reported that `--opencode --local --minimal` silently
* installed the full surface. While auditing the central gate
* (`stageSkillsForMode` in get-shit-done/bin/lib/install-profiles.cjs),
* we found that:
* - Skills are correctly filtered for every runtime in both `--global`
* and `--local` modes (the dispatch sites in install.js all call
* stageSkillsForMode unconditionally).
* - Agents are correctly suppressed under --minimal.
* - HOWEVER, the install manifest only recorded `commands/gsd/` for
* Gemini, leaving Claude Code local installs with an incomplete
* manifest. saveLocalPatches() then couldn't detect user edits and
* a minimal-mode reinstall couldn't be verified manifest-side.
*
* This test pins per-runtime behavior end-to-end: spawn the installer
* with --minimal for each runtime in each scope, parse the resulting
* manifest JSON, assert that mode === 'minimal', the recorded skill set
* equals MINIMAL_SKILL_ALLOWLIST, and zero gsd-* agents are present.
*
* Cline is rules-based and embeds the workflow in `.clinerules` rather
* than emitting per-skill files. Asserted separately: mode === 'minimal',
* zero agents, .clinerules exists.
*
* No regex / `.includes()` against file contents — every assertion
* either parses JSON or walks a directory tree.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const {
MINIMAL_SKILL_ALLOWLIST,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
// Per-runtime config dir name for local installs. Mirrors getDirName() in
// bin/install.js; kept as a fixture to avoid coupling the test to that
// internal helper.
const LOCAL_DIR_NAME = {
claude: '.claude',
opencode: '.opencode',
gemini: '.gemini',
kilo: '.kilo',
codex: '.codex',
copilot: '.github',
antigravity: '.agent',
cursor: '.cursor',
windsurf: '.windsurf',
augment: '.augment',
trae: '.trae',
qwen: '.qwen',
codebuddy: '.codebuddy',
cline: '.', // Cline writes to project root
};
// Skill-emitting runtimes (everything except Cline, which is rules-based).
const SKILL_RUNTIMES = [
'claude',
'opencode',
'gemini',
'kilo',
'codex',
'copilot',
'antigravity',
'cursor',
'windsurf',
'augment',
'trae',
'qwen',
'codebuddy',
];
const ALL_RUNTIMES = [...SKILL_RUNTIMES, 'cline'];
/**
* Run the installer in either global or local mode and return the parsed
* manifest (or null if no manifest was written).
*/
function runInstall({ runtime, scope, extraArgs = [] }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`));
try {
let configDir;
let cwd = process.cwd();
const args = [INSTALL_SCRIPT, `--${runtime}`];
if (scope === 'global') {
args.push('--global', '--config-dir', root);
configDir = root;
} else {
args.push('--local');
cwd = root;
configDir = runtime === 'cline'
? root
: path.join(root, LOCAL_DIR_NAME[runtime]);
}
args.push(...extraArgs);
const result = spawnSync(process.execPath, args, {
cwd,
encoding: 'utf8',
});
assert.strictEqual(
result.status,
0,
`installer exited with status ${result.status} for ${runtime} --${scope}` +
`\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
const manifestPath = path.join(configDir, MANIFEST_NAME);
let manifest = null;
if (fs.existsSync(manifestPath)) {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}
return { manifest, configDir, root, stdout: result.stdout, stderr: result.stderr };
} catch (err) {
fs.rmSync(root, { recursive: true, force: true });
throw err;
}
}
/**
* Walk the manifest's `files` keys and project them onto a per-runtime
* "skill set". Each runtime emits skills under one of three keyspaces:
* skills/<name>/... (Claude global, Codex, Copilot, Antigravity,
* Cursor, Windsurf, Augment, Trae, Qwen,
* CodeBuddy)
* command/gsd-<name>.md (OpenCode, Kilo)
* commands/gsd/<name>.md (Gemini, Claude local — fixed in #2923)
*
* Returns the unique set of skill basenames recorded in the manifest.
*/
function manifestSkillSet(manifest) {
if (!manifest || !manifest.files) return new Set();
const out = new Set();
for (const key of Object.keys(manifest.files)) {
if (key.startsWith('skills/')) {
// Strip both the optional `gsd-` prefix (used by Claude/Codex/etc as
// a per-skill subdir name) and any trailing `.md` (Codex flat layout).
const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, '');
out.add(seg);
} else if (key.startsWith('command/')) {
const file = key.split('/')[1];
// Strip `gsd-` prefix and `.md` suffix. Subdirs flatten with `-`,
// but our minimal allowlist is flat (top-level files only) so this
// is safe here.
const base = file.replace(/^gsd-/, '').replace(/\.md$/, '');
out.add(base);
} else if (key.startsWith('commands/gsd/')) {
// Gemini transforms .md → .toml on emit; Claude local keeps .md.
const file = key.split('/')[2];
out.add(file.replace(/\.(md|toml)$/, ''));
}
}
return out;
}
function manifestAgentCount(manifest) {
if (!manifest || !manifest.files) return 0;
return Object.keys(manifest.files).filter((k) => k.startsWith('agents/')).length;
}
function expectedSkillSet() {
return new Set([...MINIMAL_SKILL_ALLOWLIST]);
}
describe('install: --minimal honoured for every runtime in --global mode', () => {
for (const runtime of SKILL_RUNTIMES) {
test(`${runtime} --global --minimal emits exactly the core skill set, zero agents`, () => {
const { manifest, root } = runInstall({
runtime,
scope: 'global',
extraArgs: ['--minimal'],
});
try {
assert.ok(manifest, `${runtime} global install must produce a manifest`);
assert.strictEqual(manifest.mode, 'minimal',
`${runtime} global manifest.mode should be "minimal"`);
assert.deepStrictEqual(
[...manifestSkillSet(manifest)].sort(),
[...expectedSkillSet()].sort(),
`${runtime} global should record exactly the MINIMAL allowlist in the manifest`,
);
assert.strictEqual(manifestAgentCount(manifest), 0,
`${runtime} global --minimal should record zero gsd-* agents`);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
describe('install: --minimal honoured for every runtime in --local mode', () => {
for (const runtime of SKILL_RUNTIMES) {
test(`${runtime} --local --minimal emits exactly the core skill set, zero agents`, () => {
const { manifest, root } = runInstall({
runtime,
scope: 'local',
extraArgs: ['--minimal'],
});
try {
assert.ok(manifest, `${runtime} local install must produce a manifest`);
assert.strictEqual(manifest.mode, 'minimal',
`${runtime} local manifest.mode should be "minimal"`);
assert.deepStrictEqual(
[...manifestSkillSet(manifest)].sort(),
[...expectedSkillSet()].sort(),
`${runtime} local should record exactly the MINIMAL allowlist in the manifest (regression guard for #2923)`,
);
assert.strictEqual(manifestAgentCount(manifest), 0,
`${runtime} local --minimal should record zero gsd-* agents`);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
describe('install: Cline --minimal (rules-based runtime — no skills/ dir)', () => {
for (const scope of ['global', 'local']) {
test(`cline --${scope} --minimal records mode=minimal and zero agents`, () => {
const { manifest, configDir, root } = runInstall({
runtime: 'cline',
scope,
extraArgs: ['--minimal'],
});
try {
assert.ok(manifest, `cline ${scope} install must produce a manifest`);
assert.strictEqual(manifest.mode, 'minimal');
assert.strictEqual(manifestAgentCount(manifest), 0,
`cline ${scope} --minimal should record zero gsd-* agents`);
// .clinerules exists (Cline embeds the workflow there in lieu of
// per-skill files).
const clinerules = path.join(configDir, '.clinerules');
assert.ok(fs.existsSync(clinerules),
`cline install should emit .clinerules at ${clinerules}`);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
describe('install: directory-on-disk matches manifest for --minimal', () => {
// Cross-check that the manifest isn't lying — actually walk the install
// dir and verify the gsd-* surface on disk equals what the manifest claims.
// This catches the inverse of #2923: manifest says minimal, but disk has
// full surface (or vice versa).
for (const runtime of SKILL_RUNTIMES) {
for (const scope of ['global', 'local']) {
test(`${runtime} --${scope} --minimal: on-disk skill files match manifest`, () => {
const { manifest, configDir, root } = runInstall({
runtime,
scope,
extraArgs: ['--minimal'],
});
try {
assert.ok(
manifest,
`${runtime} ${scope} --minimal: manifest must exist before parity check`,
);
const onDisk = collectSkillBasenamesOnDisk(configDir);
const inManifest = manifestSkillSet(manifest);
assert.deepStrictEqual(
[...onDisk].sort(),
[...inManifest].sort(),
`${runtime} ${scope}: on-disk skills must match manifest record`,
);
// And no gsd-*.md agent file should exist on disk either:
const agentsDir = path.join(configDir, 'agents');
if (fs.existsSync(agentsDir)) {
const gsdAgents = fs.readdirSync(agentsDir).filter(
(f) => f.startsWith('gsd-') && f.endsWith('.md'),
);
assert.deepStrictEqual(gsdAgents, [],
`${runtime} ${scope} --minimal should not write gsd-*.md agents on disk`);
}
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
}
});
/**
* Walk the per-runtime install destination and return the set of skill
* basenames found on disk. Mirrors manifestSkillSet but reads the
* filesystem, not the manifest — used to verify the two agree.
*/
function collectSkillBasenamesOnDisk(configDir) {
const out = new Set();
// skills/<name>/SKILL.md (or SKILL.toml/.md depending on runtime)
const skillsDir = path.join(configDir, 'skills');
if (fs.existsSync(skillsDir)) {
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
out.add(entry.name.replace(/^gsd-/, ''));
} else if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
// Codex flat skills/ layout: skills/gsd-<name>.md
out.add(entry.name.replace(/^gsd-/, '').replace(/\.md$/, ''));
}
}
}
// command/gsd-<name>.md (OpenCode, Kilo)
const commandDir = path.join(configDir, 'command');
if (fs.existsSync(commandDir)) {
for (const file of fs.readdirSync(commandDir)) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
}
}
}
// commands/gsd/<name>.{md,toml} (Claude local emits .md; Gemini emits .toml)
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
if (fs.existsSync(commandsGsdDir)) {
for (const file of fs.readdirSync(commandsGsdDir)) {
if (file.endsWith('.md') || file.endsWith('.toml')) {
out.add(file.replace(/\.(md|toml)$/, ''));
}
}
}
return out;
}

View File

@@ -0,0 +1,70 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Behavior-based regression guard for #2962-class bugs.
*
* "Nothing for Windows should be deferred — if it wasn't in, it was missed
* not deferred." (maintainer guidance, 2026-05-01.)
*
* Specifically guards against trySelfLinkGsdSdk silently no-op'ing on
* Windows. Rather than regex-scanning bin/install.js source (which would
* fail on harmless refactors and conflicts with the repo's no-source-grep
* testing standard), this test exercises the function under a simulated
* `process.platform === 'win32'` and asserts shim files actually land on
* disk — i.e., the Windows branch dispatches, doesn't early-return null.
*/
const { test, describe, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const cp = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
describe('Windows parity guard for trySelfLinkGsdSdk (#2962)', () => {
let tmpDir;
let origPlatform;
let origExecSync;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-win32-guard-'));
origPlatform = process.platform;
origExecSync = cp.execSync;
// Override process.platform to simulate Windows. process.platform is a
// configurable property in Node — Object.defineProperty can swap it.
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
cp.execSync = (cmd) => {
if (typeof cmd === 'string' && cmd.trim() === 'npm prefix -g') {
return tmpDir + '\n';
}
throw new Error(`unexpected execSync: ${cmd}`);
};
});
after(() => {
Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true });
cp.execSync = origExecSync;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('trySelfLinkGsdSdk dispatches to the Windows handler and writes shims (does NOT silently return null)', () => {
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
const result = installModule.trySelfLinkGsdSdk(shimSrc);
assert.notEqual(
result,
null,
'trySelfLinkGsdSdk must not silently return null on Windows; ' +
'a no-op skip is a missed-parity regression (see #2962, #2775).',
);
assert.ok(
fs.existsSync(path.join(tmpDir, 'gsd-sdk.cmd')),
'Windows dispatch must materialize gsd-sdk.cmd in the npm global bin',
);
});
});

View File

@@ -12,6 +12,10 @@
*/
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync, execSync } = require('child_process');

View File

@@ -265,11 +265,15 @@ describe('#2529 config merge safety', () => {
// ─── /gsd-settings mentions /gsd-settings-integrations ──────────────────────
describe('#2529 /gsd-settings mentions new command', () => {
test('settings workflow mentions /gsd-settings-integrations in its confirmation output', () => {
test('settings workflow mentions canonical /gsd-config --integrations', () => {
const src = fs.readFileSync(SETTINGS_WORKFLOW_PATH, 'utf-8');
assert.ok(
src.includes('/gsd-settings-integrations'),
'settings.md must mention /gsd-settings-integrations as a follow-up'
src.includes('gsd-config --integrations'),
'settings.md must mention /gsd-config --integrations'
);
assert.ok(
!src.includes('/gsd-settings-integrations'),
'settings.md must not mention the legacy /gsd-settings-integrations variant'
);
});
});

View File

@@ -199,16 +199,17 @@ describe('config-get context_window (#1472)', () => {
assert.strictEqual(output, 1000000);
});
test('config-get context_window errors when key is absent', () => {
test('config-get context_window returns schema default (200000) when key is absent', () => {
// Bug #2943: context_window has a schema-level default of 200000.
// config-get must return it (exit 0) rather than "Key not found" (exit 1).
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
const result = runGsdTools('config-get context_window', tmpDir);
assert.strictEqual(result.success, false);
assert.ok(
result.error.includes('Key not found'),
`Expected "Key not found" in error: ${result.error}`
);
assert.ok(result.success, `Expected success but got: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output, 200000, 'schema default for context_window should be 200000');
});
});

View File

@@ -226,19 +226,20 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
);
});
// #2505 — installer does NOT wipe skills/ or command/; scanning them produces
// false-positive "custom file" reports for every skill the user has installed
// from other packages.
test('does not scan skills/ directory (installer does not wipe it)', () => {
// After v1.39.0 skill consolidation (#2790), the installer wipes skills/ on
// update. skills/ is now a GSD-managed directory and must be scanned so that
// user-added skill directories are backed up before the wipe (#2942).
// GSD-owned skills (tracked in manifest) must NOT be flagged as custom.
test('scans skills/ directory and detects user-added skills not in manifest (#2942)', () => {
writeManifest(tmpDir, {
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
'skills/gsd-planner/SKILL.md': '# GSD Planner\n',
});
// Simulate user having third-party skills installed — none in manifest
const skillsDir = path.join(tmpDir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'my-custom-skill.md'), '# My Skill\n');
fs.writeFileSync(path.join(skillsDir, 'another-plugin-skill.md'), '# Another\n');
// Simulate user having a custom skill installed — NOT in manifest
const customSkillDir = path.join(tmpDir, 'skills', 'my-custom-skill');
fs.mkdirSync(customSkillDir, { recursive: true });
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
@@ -248,10 +249,17 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
const skillFiles = json.custom_files.filter(f => f.startsWith('skills/'));
assert.strictEqual(
skillFiles.length, 0,
`skills/ should not be scanned; got false positives: ${JSON.stringify(skillFiles)}`
// The user's custom skill should be detected
assert.ok(
json.custom_files.includes('skills/my-custom-skill/SKILL.md'),
`custom skill should be detected; got: ${JSON.stringify(json.custom_files)}`
);
// The GSD-owned skill (in manifest) should NOT be flagged as custom
assert.ok(
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
`GSD-owned skill should not be flagged as custom; got: ${JSON.stringify(json.custom_files)}`
);
});