Commit Graph

2236 Commits

Author SHA1 Message Date
Tom Boucher
fb92d1e596 fix(#2983): classifier exit-code discipline, base-tag staging, drop vestigial merge-back (#2984)
* fix(#2983): classifier exit-code discipline, base-tag staging, drop vestigial merge-back

Three issues surfaced by CodeRabbit's post-merge review of #2981 plus
a production failure on the v1.39.1 release run.

(1) Overloaded classifier exit code

scripts/diff-touches-shipped-paths.cjs reused exit 1 for both the
legitimate "no shipped paths" result and Node's default exit on
uncaught throw, so any classifier failure (corrupt package.json,
EPERM, etc.) was indistinguishable from a normal skip — the workflow's
`if ! ... ; then skip` idiom would silently drop the commit.

Distinct exit codes now:
  0  shipped       — at least one path is in the npm `files` whitelist
  1  not shipped   — CI / test / docs / planning only
  2  classifier error — workflow MUST fail-fast

uncaughtException + unhandledRejection + try/catch around fs/JSON
parsing all route to exit 2 with stderr context.

(2) Classifier missing at the base tag (CRITICAL)

`Prepare hotfix branch` runs `git checkout -b "$BRANCH" "$BASE_TAG"`
BEFORE the cherry-pick loop, replacing the working tree with the base
tag's contents. Base tags predating #2980 (notably v1.39.0, the most
likely next hotfix base) don't have scripts/diff-touches-shipped-paths.cjs
at all — `node <missing>` exits non-zero — `if !` skips every commit —
empty hotfix branch published. Strictly worse than the original #2980
push-rejection, which at least failed loudly.

Stage the classifier from the dispatched ref's working tree into
$RUNNER_TEMP at the top of the run script (before any working-tree-
mutating git command). The cherry-pick loop now references $CLASSIFIER
(staged) instead of the in-tree path. Sanity guards: refuse to start
if scripts/diff-touches-shipped-paths.cjs is missing in the dispatched
ref, refuse to proceed if cp didn't materialize $CLASSIFIER.

The cherry-pick loop captures node's exit via ${PIPESTATUS[1]} and
dispatches via explicit case:
  0  proceed with cherry-pick
  1  skip into NON_SHIPPED_SKIPPED
  *  emit ::error:: + exit "$CLASSIFIER_RC"

(3) Drop the merge-back PR step

Auto-cherry-pick only picks commits already on main (`git cherry HEAD
origin/main` outputs the unmerged ones; we filter fix:/chore: from
main). By construction every code commit on the hotfix branch is
already on main. The only hotfix-branch-only commit is `chore: bump
version to X.Y.Z for hotfix`, which either no-ops against main or
rewinds main's in-progress version. The merge-back PR was vestigial.

It also failed in production on run 25232968975 with `GitHub Actions
is not permitted to create or approve pull requests (createPullRequest)`
— org policy blocks PR creation from the workflow's GH_TOKEN. Even
without that block, the PR would have nothing useful to merge.

Step removed. The `pull-requests: write` permission granted solely
for the merge-back step has been dropped from the release job
(least-privilege).

Regression coverage

tests/bug-2983-classifier-exit-codes-and-base-tag-staging.test.cjs
adds 12 assertions across two describe blocks:

  - 5 classifier behavioral: exit 0/1 preserved, exit 2 on missing
    package.json, exit 2 on malformed JSON, exit-code constants
    exported.
  - 7 workflow contract: classifier staged before checkout, target
    is $RUNNER_TEMP, missing-source guard, missing-staged guard,
    PIPESTATUS-based dispatch, error branch fails workflow, loop uses
    staged path (not in-tree).

tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs updated
where it asserted the pre-#2983 `if ! ... ; then` shape: now accepts
the post-#2983 case-dispatch form. The test still proves the
classifier participates; bug-2983 enforces the specific shape.

Run summary references for the curious reviewer:

  - Run 25232010071 — original #2980 trigger (workflow-file push
    rejection)
  - Run 25232968975 — failed merge-back step that prompted the
    "is this even useful?" question that drove the removal

Closes #2983

* fix(#2983): address CodeRabbit findings on PR #2984

Two findings, both real, both fixed.

(1) [Critical] PIPESTATUS capture clobbered by `|| true`

Pre-fix shape:
  git diff-tree ... | node "$CLASSIFIER" || true
  CLASSIFIER_RC="${PIPESTATUS[1]}"

When the classifier exits 1 ("not shipped" — common case) or 2
(error), `|| true` triggers the right-hand side. `true` is a
one-command "pipeline" that overwrites PIPESTATUS to (0).
${PIPESTATUS[1]} on the next line is therefore unset (or stale
under set -u). The case dispatch then matched the empty string —
falling into `*)` and failing the workflow on every non-shipped
commit, OR matching `0)` after some shells default-init unset
to 0 and silently picking commits that don't ship.

Local repro confirms the issue:

  $ bash -c 'set -euo pipefail; false | sh -c "exit 7" || true; \
       echo "PIPESTATUS: ${PIPESTATUS[*]}"; \
       echo "[1]: ${PIPESTATUS[1]:-<unset>}"'
  PIPESTATUS: 0
  [1]: <unset>

Fix: bracket the pipeline in `set +e`/`set -e`, snapshot
PIPESTATUS into a local array on the very next line, then
dispatch on the snapshot:

  set +e
  git diff-tree ... | node "$CLASSIFIER"
  PIPE_RC=("${PIPESTATUS[@]}")
  set -e
  DIFFTREE_RC="${PIPE_RC[0]}"
  CLASSIFIER_RC="${PIPE_RC[1]}"

The snapshot must happen on the first line after the pipeline;
any intervening simple command resets PIPESTATUS. The array form
is invariant against that.

Bonus from the new shape: $DIFFTREE_RC is now also captured.
git diff-tree is unlikely to fail on a known-good $SHA, but if
it does, we no longer feed partial/empty input to the classifier
and call it "not shipped." A non-zero DIFFTREE_RC emits
::error::git diff-tree failed and exits.

(2) [Minor] Stale "Merge-back PR opened against main" summary line

The hotfix run summary still printed:
  echo "- Merge-back PR opened against main"

But the merge-back step itself was removed in the previous commit
on this branch. Operators reading the summary would expect a PR
that doesn't exist. Replaced with explicit non-action text:

  echo "- No merge-back PR (auto-picked commits are already on main)"

Test coverage

bug-2983 test file gains 3 assertions:
  - PIPE_RC array-snapshot pattern is required (regex matches the
    exact `PIPE_RC=("${PIPESTATUS[@]}")` form).
  - The `pipeline || true; ${PIPESTATUS[1]}` antipattern is
    explicitly forbidden via assert.doesNotMatch.
  - DIFFTREE_RC is captured from PIPE_RC[0] and a non-zero value
    triggers ::error::git diff-tree failed.
  - Run summary forbids `Merge-back PR opened against main` and
    requires the new non-action sentence.

bug-2964 test's loop-anchor window bumped 6 KB → 8 KB to
accommodate the additional pre-pick scaffolding (the test's own
comment had already anticipated this kind of growth, citing prior
precedents from #2970 and #2980).

Mark CodeRabbit comments resolved post-commit.

Refs CR finding ids 3175253571, 3175253578 on PR #2984.
2026-05-01 17:25:20 -04:00
Tom Boucher
7424271aa0 fix(#2980): hotfix cherry-pick only picks commits that change what ships (#2981)
* fix(#2980): pre-skip workflow-file cherry-picks in release-sdk hotfix loop

The default GITHUB_TOKEN issued to the release-sdk run lacks the
`workflow` scope, so the prepare job's `git push origin "$BRANCH"` is
rejected by GitHub when any cherry-picked commit modifies a file under
`.github/workflows/`:

  ! [remote rejected] hotfix/X.YY.Z -> hotfix/X.YY.Z
    (refusing to allow a GitHub App to create or update workflow ...
     without `workflows` permission)

Pre-#2980 behavior: the auto_cherry_pick loop happily picked
workflow-file commits, then the trailing push exploded with no clear
signal which commit was the culprit. v1.39.1 hit this on PR #2977
(run 25232010071) — earlier release-sdk fixes (#2965, #2967, #2970)
had been skipped on conflict so their workflow-file changes never
reached the push step, masking the bug; #2977 was the first
workflow-file commit to apply cleanly and the push immediately
exploded.

Fix: pre-pick guard in the cherry-pick loop. Inspect each candidate
commit's file list via `git diff-tree --no-commit-id --name-only -r`
BEFORE attempting the pick. If any path matches `^\.github/workflows/`,
skip the commit, emit a `::warning::` annotation naming the dropped
commit, and append to a new `WORKFLOW_SKIPPED` bucket. The run summary
surfaces this bucket in its own section, distinct from `CONFLICT_SKIPPED`
(real merge conflicts) and `POLICY_SKIPPED` (feat/refactor exclusions),
so operators reviewing the run never confuse the remediation paths.

The loud-warning piece is non-negotiable: silent drops were explicitly
rejected as a failure mode during the option-1/2/3 tradeoff discussion.
If a workflow-file fix genuinely needs to ship in a hotfix, the
operator applies it manually on the hotfix branch using a token with
`workflow` scope, or lands it on main and re-cuts the release.

Regression covered by tests/bug-2980-skip-workflow-file-cherrypicks.test.cjs
(5 assertions: pre-pick guard exists, uses `git diff-tree`, emits
`::warning::`, lands in dedicated bucket, surfaces in summary).

The bug-2964 test's 4 KB window after the cherry-pick-loop anchor was
nudged to 6 KB to accommodate the new pre-pick scaffolding — the test's
own comment had already anticipated this kind of growth (citing #2970's
merge-commit pre-skip as prior precedent).

Closes #2980

* refactor(#2980): replace workflow-file pre-skip with shipped-paths filter

The previous commit on this branch caught only the .github/workflows/*
subset of the bug, treating the symptom (push rejection on workflow-file
changes) rather than the root cause (the fix:/chore: filter is too broad
— it picks any commit with that conventional-commit type even when the
diff cannot affect the published npm package).

CI-only fixes (release-sdk.yml itself, hotfix tooling, test-only
commits) shouldn't flow through hotfix runs at all — they cannot change
what `npm install get-shit-done-cc@X.YY.Z` produces. The
.github/workflows/* push rejection is just the loudest of these
"shouldn't have been picked" cases; tests/, docs/, .planning/ commits
get picked silently with the same lack of effect on consumers.

Replace the workflow-file pre-skip with a shipped-paths filter:

- New scripts/diff-touches-shipped-paths.cjs reads package.json `files`,
  plus package.json itself (always-shipped per `npm pack` semantics),
  and exits 0 iff any input path is in the shipped set. Lockfile is
  not shipped (npm pack excludes it unless explicitly in `files`).
- Workflow loop now pipes `git diff-tree --no-commit-id --name-only -r`
  through the classifier; on exit 1 the commit is skipped and
  appended to a new NON_SHIPPED_SKIPPED bucket (replaces
  WORKFLOW_SKIPPED).
- Run summary surfaces NON_SHIPPED_SKIPPED as informational — no
  ::warning:: annotation. A non-shipping commit cannot affect the
  package, so a yellow alert would imply remediation is possible
  and would mislead operators.

The classifier in a separate .cjs file (rather than inline bash
heredoc) is so its rules — directory-prefix vs exact-match,
package.json-always-shipped, lockfile-not-shipped — are unit-testable
in tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs (11 new
assertions: 4 static workflow + 6 classifier behavioral + 1 mixed-
diff edge case).

Why this dissolves the original push-rejection bug: workflow files
aren't in `files`, so workflow-only commits are skipped pre-pick.
The push step never sees them.

If a workflow-file fix genuinely needs to ship in a hotfix release
(extremely rare — the hotfix workflow is read from main's ref, not
the hotfix branch's), the operator applies it manually using a token
with `workflow` scope. The pre-skip puts that requirement in the run
summary explicitly.

Closes #2980
2026-05-01 16:59:49 -04:00
Tom Boucher
7a416b10d4 fix(#2976): allow same-version bump in release-sdk hotfix release job (#2977)
The release job's "Bump in-tree version (not committed)" step ran
`npm version "$VERSION" --no-git-tag-version` without --allow-same-version,
so on real hotfix runs it failed with `npm error Version not changed` —
because the prepare job had already committed the bump on the hotfix
branch (the release job checks out BRANCH on real runs vs BASE_TAG on
dry-runs, which is why dry-run never caught it).

Pass --allow-same-version to both bumps, matching release.yml:326.

Closes #2976
2026-05-01 16:32:18 -04:00
Tom Boucher
ef43f5161f 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).
2026-05-01 16:14:39 -04:00
Tom Boucher
e9a66da1e7 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.
2026-05-01 16:10:30 -04:00
Tom Boucher
b8d9bd69b2 fix(release-sdk): skip all cherry-pick conflicts in hotfix loop (full automation) (#2970)
* fix(release-sdk): skip all cherry-pick conflicts in hotfix loop

Full-automation policy: any conflict the cherry-pick can't auto-resolve
— context-missing (#2966) or real merge conflict — is now skipped, not
aborted. The hotfix run completes with whatever applies cleanly; the
SKIPPED list in the run summary becomes the operator's post-hoc review
queue.

Surfaced in run 25227493387 (1.39.1 dry-run): commit 0fb992d
("fix(git): add git.base_branch config") produced real conflicts in
config.cjs / ship.md / complete-milestone.md / tests/config.test.cjs.
v1.39.0 was tagged on the feat/hermes-runtime-2841 branch (#2920),
which restructured those files. 0fb992d was authored against the
pre-restructure shape, so cherry-pick can't auto-resolve.

Pre-#2968 behavior: the workflow distinguished context-missing (skip)
from real (abort + push partial + exit 1). Real conflicts blocked every
hotfix from a base tag whose lineage diverged from main — exactly the
v1.39.x situation. The user has called explicitly for full automation:
"this needs to be fully automated, no one is going to sit there and
tag fixes."

Behavior change:
  - Both classification branches now `git cherry-pick --skip` and
    append to SKIPPED with a reason category:
      * "context absent at base" — empty-HEAD markers (#2966)
      * "merge conflict — manual review" — non-empty HEAD (#2968)
  - Removed: `git cherry-pick --abort`, partial-state push,
    "Cherry-pick conflict" GITHUB_STEP_SUMMARY block, `exit 1`.
  - Operator's manual recovery path via `auto_cherry_pick=false`
    remains intact.

Trade-off (acknowledged in #2968): a critical fix can be silently
dropped if no one reviews the SKIPPED list. The release job's
install-smoke + full test suite still runs and would catch any
test-covered regression. Fixes that aren't test-covered could ship
missing — accepted cost of full automation per the issue.

Tests:
  - tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs (new) —
    extracts the cherry-pick failure block via bash if/fi nesting walk
    (no raw-text grep) and asserts the abort path is removed, --skip
    is unconditional, and "merge conflict" + "context absent at base"
    annotations both exist.
  - tests/bug-2966-cherry-pick-context-missing.test.cjs (renamed
    describe + first test name) — assertions still valid since the
    classifier survives for skip-reason annotation.
  - tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs — unchanged
    and still green.

Local: `node --test tests/bug-2964-...test.cjs tests/bug-2966-...test.cjs
tests/bug-2968-...test.cjs` → 8/8 pass.
Local: `npm run lint:tests` → 0 violations.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* fix(release-sdk): split cherry-pick conflict skips from policy skips

CodeRabbit flagged on PR #2970 that conflict skips and policy skips
share the SKIPPED bucket. The run summary heading
"Skipped (feat/refactor/etc — not auto-included)" buries manual-review
conflicts (which the operator must triage) under the same list as
intentional policy exclusions (commits that don't match fix/chore by
design and need no action). Operators reviewing the summary can't
distinguish the two without reading every entry.

Split into two variables:
  - POLICY_SKIPPED — feat/refactor/docs/etc filtered out by the
    fix/chore regex (informational, no action needed)
  - CONFLICT_SKIPPED — fix/chore commits whose cherry-pick failed and
    were skipped per the full-automation policy (#2968) (manual
    review queue)

Run summary now emits two sections with distinct headings:
  - "Skipped — cherry-pick conflict (manual review)"
  - "Not auto-included (feat/refactor/docs/etc)"

The new bug-2968 test asserts both buckets are populated correctly:
  - failure path appends to CONFLICT_SKIPPED, not SKIPPED
  - both bucket variables are echoed in the summary
  - both section headings are present

Local: `node --test tests/bug-2964-...test.cjs tests/bug-2966-...test.cjs
tests/bug-2968-...test.cjs` → 9/9 pass.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* fix(release-sdk): handle merge commits and guard cherry-pick --skip

CodeRabbit flagged a real major issue on PR #2970: merge commits with
fix:/chore: titles fail BEFORE entering cherry-pick state because they
need `-m <parent>` to specify the diff base. Without it, the cherry-pick
errors out and CHERRY_PICK_HEAD is never created. The unconditional
`git cherry-pick --skip` call that follows then fails too (no in-progress
cherry-pick to skip), bricking the loop — defeating the full-automation
policy this PR set out to deliver.

Two guards added:

1. Pre-skip merge commits before invoking cherry-pick. The loop checks
   parent count via `git rev-list --parents -n 1 "$SHA"`; if > 1, the
   commit goes straight to CONFLICT_SKIPPED with reason "merge commit —
   manual -m parent selection required". Operator decides which parent
   to keep when reviewing the run summary.

2. Guard `git cherry-pick --skip` with a CHERRY_PICK_HEAD existence
   check. Catches any other failure mode where the cherry-pick aborts
   before entering conflict state (unreadable commit, ref problems,
   etc.) so the loop still continues cleanly.

Also bumped the bug-2964 test's regex slice window from 2000 to 4000
chars so the merge-commit pre-skip block doesn't push the cherry-pick
line out of the test's match range.

Tests added in tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs:
  - merge-commit detection: workflow must call
    `git rev-list --parents -n 1 "$SHA"` before cherry-pick and annotate
    skips with the distinct "manual -m parent selection required"
    reason.
  - guard: failure block must check CHERRY_PICK_HEAD before --skip.

Local: `node --test tests/bug-2964-...test.cjs tests/bug-2966-...test.cjs
tests/bug-2968-...test.cjs` → 11/11 pass.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* fix(release-sdk): guard awk classifier against degenerate unmerged paths

CodeRabbit raised two issues on PR #2970:

1. Major (workflow): the `awk` classifier runs under `set -euo pipefail`.
   If a CONFLICTED path is missing/unreadable, awk exits non-zero and
   terminates the entire step — bricking the loop on a degenerate file.
   Also, an unmerged path with no `<<<<<<< ` markers (path-level conflict
   or anomalous git state) was misclassified as "context absent at base"
   (the auto-skip path), letting potentially-real conflicts skip silently.

   Fix: before invoking awk, check `[ ! -r "$CONFLICTED" ]` and
   `grep -q '^<<<<<<< ' "$CONFLICTED"`. Either failure marks
   ALL_EMPTY_HEAD=false → REASON falls through to "merge conflict —
   manual review", landing the pick in the operator review queue.
   Also added `2>/dev/null || echo "real"` on the awk call so a
   transient awk failure can't slip into the auto-skip bucket.

2. Nitpick (tests): regex assertions on `failureBlock` could match
   commented lines (e.g. comment text mentioning "CONFLICT_SKIPPED"
   or "git cherry-pick --skip" satisfied the assertions without the
   real command being present).

   Fix: anchor with `^\s*...` + `m` flag so only executable shell lines
   count.

Plus a new test asserting all three workflow guards
(`[ ! -r "$CONFLICTED" ]`, `grep -q '^<<<<<<< '`, `awk ... || echo
"real"`) are present in the failure block.

Local: `node --test tests/bug-2964-...test.cjs tests/bug-2966-...test.cjs
tests/bug-2968-...test.cjs` → 12/12 pass.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 15:15:20 -04:00
Tom Boucher
0d25ef0c47 fix(release-sdk): skip cherry-picks whose target context is absent at base (#2967)
* fix(release-sdk): skip cherry-picks whose target context is absent at base

When auto_cherry_pick processed a fix:/chore: commit whose patch modified
code that didn't exist at the hotfix base tag — typically because the
surrounding infrastructure was added later in a feat/refactor commit
excluded by the filter — `git cherry-pick` failed with a conflict that
no operator could meaningfully resolve, and the loop bricked the run.

Discovered re-running the 1.39.1 dry-run after #2965 merged: cherry-pick
of `a3467792` (the #2965 merge itself) failed because the auto_cherry_pick
block it modifies was added in #2956 ("Add automated cherry-pick + SDK-
bundle parity to hotfix flow") — an Add/feat commit, so the fix/chore
filter excludes it. v1.39.0 has no such block, so the patch had no
anchor.

The conflict is unmistakably distinguishable from a real content conflict:
git emits marker blocks where every `<<<<<<< HEAD ... =======` HEAD
section is empty (no anchor lines to reconcile against), while real
conflicts have content on both sides.

After cherry-pick fails:
  1. List unmerged paths via `git diff --diff-filter=U`.
  2. For each, scan conflict markers with awk. If every HEAD section is
     blank/whitespace-only across every block, classify as
     context-missing.
  3. Context-missing → `git cherry-pick --skip` and append to SKIPPED
     list with reason "(context absent at base)".
  4. Otherwise fall through to the existing abort/push-partial/error
     path that surfaces the conflict for operator resolution.

Real conflicts still surface with the same workflow as before.

Tests in tests/bug-2966-cherry-pick-context-missing.test.cjs cover:
  - Static — extracts the "Prepare hotfix branch" run block via
    indentation-aware YAML parsing (no raw-text grep) and asserts the
    classification predicate, --skip call, and skipped-reason annotation
    are present.
  - Behavioral — synthetic repo reproducing the real shape of the
    failure, asserts cherry-pick exits non-zero and produces the
    empty-HEAD marker shape.
  - Predicate — pulls the awk script out of the deployed workflow and
    feeds it sample conflict shapes (empty-HEAD, real, mixed,
    whitespace-only); asserts each is classified as the workflow will
    behave.

Local: `node --test tests/bug-2966-...test.cjs` → 3/3 pass.
Local: `npm run lint:tests` → 0 violations.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* fix(release-sdk): pin merge.conflictStyle=merge on hotfix cherry-pick

CodeRabbit flagged on #2967 that the awk classifier introduced for #2966
assumes default conflict-marker style (plain `<<<<<<< HEAD ... ======= ...
>>>>>>>`). If a runner has merge.conflictStyle=diff3 or zdiff3 set
(globally, repo-config, or via git defaults shift), the marker emits an
extra `||||||| ancestor` section between HEAD and =======. The awk's
`in_head` mode would accumulate that ancestor content into the HEAD
buffer, and a context-missing conflict would misclassify as real —
sending the workflow into the abort path on a pick that should be
silently skipped.

Pass `-c merge.conflictStyle=merge` on the cherry-pick command itself
(scoped to that one git invocation; doesn't leak to other commands).
This guarantees marker shape regardless of the runner's git config.

Updated the existing static assertion in
tests/bug-2966-cherry-pick-context-missing.test.cjs to require the pin —
a future edit dropping it fails the test.

Local: `node --test tests/bug-2966-...test.cjs` → 3/3 pass.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* test(#2964): allow git options between `git` and `cherry-pick`

The previous commit on this branch (d6530190) added
`git -c merge.conflictStyle=merge cherry-pick ...` to release-sdk.yml.
The bug-2964 static test's regex `/git cherry-pick[^\n]*"\$SHA"/`
required `cherry-pick` to be the literal next token after `git`, so it
no longer matched the line and CI failed on Node 22 / Node 24 / macOS.

Loosen to `/git\b[^\n]*?cherry-pick[^\n]*"\$SHA"/` so any options
between `git` and `cherry-pick` (e.g. `-c key=value`) are tolerated.
The flag assertions on the matched line still verify --allow-empty and
--keep-redundant-commits are present, which is what bug-2964 actually
guards.

Local: `node --test tests/bug-2964-...test.cjs tests/bug-2966-...test.cjs`
→ 5/5 pass.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* test(#2966): pin merge.conflictStyle in test git wrapper, assert awk status

CodeRabbit raised two issues on PR #2967:

1. The synthetic-repo cherry-pick reproducer asserted `<<<<<<< HEAD ...`
   blocks have empty HEAD sections, but the cherry-pick itself didn't
   pin `merge.conflictStyle`. A developer or CI runner with global
   diff3/zdiff3 config would inject `||||||| ancestor` lines into the
   HEAD scan and the test would fail for environment reasons rather
   than the bug premise. Pin the style on the test's `git()` wrapper
   so every git operation in the test is deterministic regardless of
   user config.

2. `classify()` ran awk and consumed `r.stdout.trim()` without checking
   `r.status` or `r.error`. A failed awk invocation (missing binary,
   syntax error, signal) returns empty stdout, which would falsely
   classify as "context-missing" and the test would silently pass on
   broken predicates. Add `assert.ok(!r.error, ...)` and
   `assert.equal(r.status, 0, ...)` before reading stdout.

Local: `node --test tests/bug-2966-...test.cjs tests/bug-2964-...test.cjs`
→ 5/5 pass.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 14:35:18 -04:00
Tom Boucher
a346779213 fix(release-sdk): allow empty/redundant commits during hotfix cherry-pick (#2965) 2026-05-01 13:56:24 -04:00
Tom Boucher
0d6abb87ac fix(#2954): align help.md with post-#2824 skill consolidation (#2959) 2026-05-01 13:36:44 -04:00
Tom Boucher
c5dfdbe42e 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.
2026-05-01 13:04:39 -04:00
javeroff
9d0d085a17 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>
2026-05-01 12:21:06 -04:00
Tom Boucher
53cda93a01 Add automated cherry-pick + SDK-bundle parity to hotfix flow (#2956)
* feat(workflows): hotfix auto-cherry-pick + SDK-bundle parity (#2955)

hotfix.yml:
- create: auto-cherry-picks fix:/chore: commits from origin/main since
  BASE_TAG, oldest-first. Patch-equivalents skipped via git cherry.
  feat:/refactor: never auto-included. Conflicts halt with offending SHA.
- finalize: install-smoke gate, sdk-bundle/gsd-sdk.tgz parity with
  release-sdk.yml, tightened next dist-tag re-point, --latest on gh
  release create. SDK package.json bumped in lockstep.

release-sdk.yml:
- New action input (publish | hotfix) and auto_cherry_pick boolean.
- New prepare job branches hotfix/X.YY.Z from highest vX.YY.* tag,
  cherry-picks same logic as hotfix.yml, outputs effective ref.
- install-smoke and release consume prepare.outputs.ref.
- Hotfix mode forces tag=latest, opens merge-back PR. Idempotent if
  branch already exists.

VERSIONING.md: documents the cumulative-tag invariant
(vX.YY.Z anchors vX.YY.{Z+1}) and both workflow paths.

Closes #2955

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

* 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).

* docs(changelog): hotfix flow auto-cherry-pick + SDK bundle parity (#2955)

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

* fix(workflows): address CodeRabbit findings on hotfix flow (#2955)

5 findings, all real:

1. BASE_TAG selection used lexicographic awk compare, breaking on
   multi-digit patches (v1.27.10 wrongly < v1.27.2). Fixed in both
   hotfix.yml and release-sdk.yml: append TARGET_TAG to candidate list,
   sort -V, take preceding entry. Semver-correct.

2,4. Cherry-pick conflict aborted locally with no remote branch to
   resolve from. Now the skeleton branch is pushed up-front (real runs);
   on conflict we abort, push the partial-pick state with
   --force-with-lease, and emit operator instructions in the run summary.

3. release-sdk.yml dry_run exited before cherry-pick, defeating the
   purpose. Now dry_run still applies cherry-picks locally (catches
   conflicts), just skips push. Downstream install-smoke runs against
   BASE_TAG; the cherry-pick verification itself is the dry-run signal.

5. release-sdk.yml release job missing pull-requests: write — gh pr
   create for the merge-back PR would have failed under restricted
   token defaults. Permission added.

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

* fix(workflows): CR round 2 — dry-run signal + post-publish reconciliation (#2955)

3 findings, all real:

6. hotfix.yml create dry_run skipped every step (branch creation,
   cherry-pick, version bump) — a green dry-run gave no signal at all.
   Now the local checkout/cherry-pick/bump always runs; only the git
   push calls are gated on dry_run. Conflicts surface in dry-run too.

7,8. "Refuse if version already on npm" preflight hard-failed reruns,
   so a transient failure between npm publish and a later step (tag
   push, GH release, merge-back PR, dist-tag re-point) left the release
   half-shipped with no path to reconcile. Replaced with a
   prior_publish detect step that warns and sets skip_publish=true; the
   publish step is gated on that flag, but tag/release/PR/dist-tag
   continue. GitHub Release create is now idempotent (edit --latest if
   already exists).

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

* fix(workflows): CR round 3 — preserve dry-run cherry-pick history in conflict guidance (#2955)

Dry-run conflict path discarded successful picks with the runner, but
the message told operators to rerun with auto_cherry_pick=false — which
recreates the branch from BASE_TAG and silently loses every pick that
had succeeded before the conflict.

Updated both hotfix.yml and release-sdk.yml: dry-run conflict summary
now lists the lost SHAs and recommends re-running with
auto_cherry_pick=true (real, not dry-run) to materialize the partial
branch on origin. Real-run guidance unchanged.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 11:51:45 -04:00
Tom Boucher
ec07861228 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>
2026-05-01 11:25:26 -04:00
Tom Boucher
3ba17e872e 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>
2026-05-01 11:25:10 -04:00
Tom Boucher
4d628b306a 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>
2026-05-01 11:06:24 -04:00
Tom Boucher
b328f3269f 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).
2026-05-01 10:28:05 -04:00
Tom Boucher
e2792536d9 feat(workflows): atomic Write+commit ordering for SUMMARY.md (#2806) (#2939)
* feat(workflows): add atomic Write+commit ordering directive for SUMMARY.md

Adds explicit prompt-ordering language to executor spawn prompts and
plan-execution steps so agents commit SUMMARY.md before emitting any
concluding narrative. Mitigates the truncation-between-Write-and-commit
failure mode that has made the #2070 rescue net load-bearing.

Refs #2806

* fix(workflows): condense REQUIRED ORDER blocks to fit XL budget

The two REQUIRED ORDER directives added in bd1956df pushed
execute-phase.md to 1712 lines, exceeding the 1700-line XL budget.

Collapse each 6-line block into a single line that preserves the
semantic intent (Write SUMMARY.md → commit → narration; no text
between Write and commit; #2070 rescue is not primary defense).

File is now exactly 1700 lines; workflow-size-budget test passes.

* fix(execute-plan): move self-check before commit to preserve atomic Write+commit (#2939)
2026-05-01 09:32:21 -04:00
Tom Boucher
7cc6358f91 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.
2026-05-01 09:23:20 -04:00
Tom Boucher
8de8acee46 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
2026-05-01 09:23:02 -04:00
Tom Boucher
2cc8796265 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>
2026-05-01 09:22:45 -04:00
Tom Boucher
faee0287a0 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>
2026-05-01 09:22:13 -04:00
Tom Boucher
7e9477bb30 docs(#2935): refresh README highlights for v1.39.0 across all languages (#2936)
Replaces stale v1.32/v1.37 highlight blocks with v1.39.0 highlights in
README.md and four translations, adds /gsd-edit-phase to phase-management
tables, documents workstream config inheritance, the post-merge build gate,
and per-runtime review.models.<cli> selection.

Closes #2935
2026-04-30 23:21:31 -04:00
Tom Boucher
5abf46ac1c Merge pull request #2920 from gsd-build/feat/hermes-runtime-2841
feat(install): add Hermes Agent runtime support
2026-04-30 23:02:15 -04:00
Tom Boucher
372d3453f5 fix(install): tokenize before ALL_RUNTIMES_OPTION check + isolate HERMES_HOME in test
Two CodeRabbit findings on PR #2920:

1. parseRuntimeInput previously only matched the bare "16" exactly for
   the all-runtimes shortcut. Inputs the prompt explicitly encourages —
   "16,", "16 1", "1,16" — fell through to per-token parsing and
   silently installed only Claude or a partial subset. Move the
   ALL_RUNTIMES_OPTION check after tokenization so any token equal to
   "16" expands. Added regression coverage in
   tests/multi-runtime-select.test.cjs for the four mixed-input forms.

2. The "maps Hermes to ~/.hermes for global installs" test invoked
   getGlobalDir('hermes') without isolating HERMES_HOME. On a developer
   machine that exports HERMES_HOME the assertion would fail even
   though getGlobalDir was behaving correctly. Save/clear/restore the
   env var around the assertion, mirroring the pattern the later
   describe block already uses.

Full suite: 6128/6128 pass.
2026-04-30 22:48:08 -04:00
Tom Boucher
c9d6306981 fix(hermes): rewrite CLAUDE.md → HERMES.md (revert from .hermes.md per spec)
Per the issue spec for #2841 and CodeRabbit feedback on PR #2920, the
project-context filename rewrite should produce HERMES.md, not
.hermes.md. Reverts the earlier .hermes.md change at all 5 substitution
sites in bin/install.js and updates the corresponding regression test
in tests/hermes-install.test.cjs to assert HERMES.md.

Full suite: 6127/6127 pass.
2026-04-30 22:30:16 -04:00
Tom Boucher
1168e9f59a Merge pull request #2921 from gsd-build/fix/2916-handle-branching-default-base
fix(#2916): branch new phases off origin/HEAD instead of current HEAD
2026-04-30 22:25:03 -04:00
Tom Boucher
3ed8980519 fix(#2916): drop unreachable post-creation merge-base guard
CodeRabbit pointed out the post-creation guard is structurally
unreachable: immediately after `git checkout -b X origin/$DEFAULT_BRANCH`,
HEAD == origin/$DEFAULT_BRANCH, so both the merge-base form (`MB == DT`)
and the alternative "ahead-of" count form (`AHEAD == 0`) are sentinels
that always pass on a successful fresh checkout. With the explicit base
arg + fail-fast on the checkout, the guard cannot catch anything new.

Removing it (rather than swapping in another no-op that satisfies the
linter but adds no actual coverage) is the honest fix. Comment retained
to explain why no post-creation guard is needed: the explicit base
argument to `git checkout -b` is the single source of correctness for
#2916.

Same simplification mirrored in get-shit-done/workflows/quick.md.

Full suite: 6102/6102.
2026-04-30 22:18:34 -04:00
Tom Boucher
c3aef27aa6 fix(#2916): fail-fast on switch/checkout, gate fork-point warning to fresh branches
Two CodeRabbit findings on PR #2921 (review 4209533909 + comment
3171721073, both still unresolved):

A. Branch switch and create steps now abort on non-zero exit. Previously
   `git switch "$BRANCH_NAME"` and `git checkout -b "$BRANCH_NAME"
   "origin/$DEFAULT_BRANCH"` could fail (locked worktree, dirty tree
   refusing the checkout, etc.) and the workflow would silently continue
   on the wrong branch — sending the phase's later commits to the wrong
   place. Both calls now `|| { echo "ERROR: …" >&2; exit 1; }`.

B. The fork-point base-warning is now scoped to the creation arm of
   the if/else. Previously it ran for the resume path too, so a
   legitimate resumed branch where origin/$DEFAULT_BRANCH had advanced
   since first creation would falsely warn ("does not fork from
   origin/<DEFAULT_BRANCH>"). Moving the check inside the else arm
   means it only runs immediately after a fresh `git checkout -b`, when
   the merge-base check is meaningful.

Same fix mirrored in get-shit-done/workflows/quick.md.

execute-phase.md stays at the 1700-line XL budget. Full suite: 6102/6102.
2026-04-30 22:07:46 -04:00
Tom Boucher
ace61869d0 test(#2916): parameterize fixtures so both main and trunk are exercised
Two follow-ups on commit 80f14cac (which hardened quick-branching with a
trunk fixture):

1. quick-branching.test.cjs: add a `defaultBranch` parameter to
   setupFixture and run the "branches off origin/HEAD" assertion against
   both `main` and `trunk`. The wholesale switch to trunk in 80f14cac
   removed coverage of the conventional `main` path; parameterizing
   restores it without giving up the symbolic-ref guarantee.

2. bug-2916-handle-branching-default-base.test.cjs: apply the same
   parameterization here. handle_branching has the same default-branch
   detection logic as Step 2.5, so it deserves the same trunk regression
   guard. Previously this file only exercised `main`.

A regression that silently defaults to `main` instead of consulting
`git symbolic-ref refs/remotes/origin/HEAD` now fails the `trunk`
variant in both files.

Tests: 10/10 in the touched suites.
2026-04-30 21:57:27 -04:00
Tom Boucher
80f14cac1f test(#2916): scope branch_name scan to init step and harden fixture
- Restrict the "init parse list includes branch_name" assertion to
  the bash blocks inside Step 2 (Initialize) so an unrelated step
  that mentions branch_name cannot mask the contract.
- Switch the fixture's default branch from main to trunk so the
  symbolic-ref code path is locked in: a regression that silently
  defaults to "main" instead of consulting origin/HEAD now fails.

Addresses CodeRabbit review on PR #2921.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 21:48:43 -04:00
Tom Boucher
2256e4c9a3 fix(#2916): use fork-point detection for non-default-base warning
Replace the "ahead-of" heuristic with a structural check that compares
the HEAD↔origin/$DEFAULT_BRANCH merge-base to origin/$DEFAULT_BRANCH
itself. The previous count-based warning fired on legitimate WIP that
was simply ahead of the default branch — the correct signal is that
the branch did not fork from the default branch in the first place.

Addresses CodeRabbit review on PR #2921.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 21:48:36 -04:00
Tom Boucher
e5cd523e7b test(hermes): use parseFrontmatter for agent assertion (CR #2920) 2026-04-30 21:44:12 -04:00
Tom Boucher
b5777572f7 docs(readme): add Hermes uninstall examples (CR #2920) 2026-04-30 21:44:12 -04:00
Tom Boucher
861a7d972b test(install): replace source-grep prompt assertions with structured checks
Two test files were asserting installer prompt behavior by regex/.includes()
against bin/install.js source. Per CONTRIBUTING.md "no-source-grep"
testing standard, replace with structured assertions:

- tests/kilo-install.test.cjs: import runtimeMap and buildRuntimePromptText
  from the install module; assert runtimeMap['11'] === 'kilo' and that the
  rendered prompt lists Kilo above OpenCode without marketing copy.

- tests/multi-runtime-select.test.cjs: import runtimeMap, allRuntimes,
  parseRuntimeInput, buildRuntimePromptText. Assert exported runtimeMap
  matches the canonical option list, allRuntimes contains every runtime
  exactly once, prompt text lists Hermes (10), Qwen Code (13), Trae (14),
  All (16), and parser splits/dedupes by exercising parseRuntimeInput
  rather than regexing source code.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 21:30:48 -04:00
Tom Boucher
bd0511988b fix(hermes): nest GSD skills under skills/gsd/ category (#2841)
Per spec in #2841, all 86 GSD skills must collapse into a single "gsd"
category in Hermes' system prompt. Previous code passed skills/ as the
install root, producing a flat skills/gsd-*/ layout that inflated
Hermes' loader output to 86 top-level entries.

Changes:
- Install path now writes to skills/gsd/{DESCRIPTION.md, gsd-*/SKILL.md}
- Uninstall removes the entire skills/gsd/ category dir plus any leftover
  flat-layout gsd-*/ from older installs (graceful migration)
- writeManifest emits skills/gsd/<skill>/<file> paths for Hermes
- --skills-root hermes returns the nested category path so /gsd-sync-skills
  syncs into the right directory
- DESCRIPTION.md at category root carries name/version/description so
  Hermes' skill loader surfaces the GSD category in the system prompt

Also extracts promptRuntime's runtimeMap, allRuntimes, parseRuntimeInput,
and buildRuntimePromptText to module scope and exports them so tests can
assert structurally instead of grepping bin/install.js source.

Existing hermes-install tests updated to expect the nested layout and
to verify the category DESCRIPTION.md frontmatter (name, version,
description) using the shared parseFrontmatter helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 21:30:48 -04:00
Tom Boucher
4a5f36df5e Merge pull request #2919 from gsd-build/fix/2911-audit-open-output-references
fix(#2911): audit-open emits raw human report and parseable JSON
2026-04-30 21:23:30 -04:00
Tom Boucher
840f2b349e Merge pull request #2918 from gsd-build/worktree-agent-a4db9db3f3106d4d7
fix(progress): explicit context-authority directive in report step
2026-04-30 21:23:12 -04:00
Tom Boucher
140d334dab test(#2916): replace string-grep assertions with behavioral fixture test
CodeRabbit nitpick (per project policy `feedback_no_source_grep_tests`):
the prior `tests/quick-branching.test.cjs` asserted branching correctness
by `.includes()`-grepping the raw markdown content for literal command
substrings. Those assertions stayed green even when the underlying
behavior regressed (e.g. when `git checkout -b` was unconditionally run
from the wrong HEAD).

Replace with the same pattern as `bug-2916-handle-branching-default-base
.test.cjs`:
  - Structurally extract the Step 2.5 bash block from quick.md by
    walking the markdown for fenced ```bash blocks under the heading
    (no regex on prose).
  - Spin up a fixture git repo with a bare origin, a clone whose
    `origin/HEAD` points at `main`, and a checked-out previous-task
    branch carrying its own unmerged commit.
  - Execute the extracted bash block via `bash -c` and assert that
    the new branch's tip equals `origin/main` (0 commits inherited
    from the previous-task HEAD).
  - Add a reuse test that pre-creates the target branch with its own
    commit and verifies the script switches back to it without a
    rebase or reset.

The two informational tests (workflow file exists, branching runs
before task-directory creation) are retained, plus the `branch_name`
parsing assertion is rewritten to walk fenced bash blocks rather than
substring-grep arbitrary content.
2026-04-30 21:22:56 -04:00
Tom Boucher
6e4fad7acc Merge pull request #2933 from gsd-build/chore/2932-coderabbit-docstring-off
chore(ci): disable CodeRabbit docstring coverage check
2026-04-30 21:22:55 -04:00
Tom Boucher
4e2f1105d9 fix(#2916): pin new-branch base to origin/$DEFAULT_BRANCH explicitly
Address CodeRabbit HIGH findings on PR #2921. The previous fix had three
unconditional code paths where `git checkout -b "$BRANCH_NAME"` would run
from the *current* HEAD when the upstream sync failed silently:
  - the dirty-tree warn-and-continue path,
  - the clean path where `git switch` / `git merge --ff-only` errors were
    swallowed by `2>/dev/null` (still falling through to checkout -b),
  - any case where `git fetch` failed but the script continued.

This rewrites both `execute-phase.md` (handle_branching) and `quick.md`
(Step 2.5) to:
  1. Fetch origin/$DEFAULT_BRANCH; if fetch fails AND no local copy of
     origin/$DEFAULT_BRANCH exists, abort with a clear ERROR (exit 1)
     rather than create the branch off arbitrary HEAD.
  2. Always create the new branch with an explicit start point:
     `git checkout -b "$BRANCH_NAME" "origin/$DEFAULT_BRANCH"`. The base
     is now deterministic regardless of which branch is currently
     checked out, regardless of whether the optional local fast-forward
     succeeded, and regardless of dirty-tree state.
  3. Carry uncommitted changes onto the new (origin-pinned) branch
     instead of inheriting the previous-phase HEAD as a fallback base.

The post-creation INHERITED check now references origin/$DEFAULT_BRANCH
rather than the (possibly-stale) local default branch, so the warning
fires accurately even when the local fast-forward was skipped.
2026-04-30 21:22:44 -04:00
Tom Boucher
4ce72cdee7 fix(hermes): align with Hermes Agent conventions per docs review
Four fixes from review of hermes-agent.nousresearch.com docs:

1. SKILL.md frontmatter now declares `version` (required field per
   Hermes spec). Plumbed through `convertClaudeCommandToClaudeSkill`
   gated on runtime='hermes' so other runtimes' frontmatter is unchanged.

2. Project-context filename rewrite changed from `HERMES.md` (not
   discovered by Hermes) to `.hermes.md` (top of Hermes' discovery list:
   .hermes.md → AGENTS.md → CLAUDE.md → .cursorrules).

3. README + finishInstall now show `/gsd-help` and `/gsd-new-project`
   for Hermes; per docs, Hermes auto-exposes skills as slash commands.

4. Hermes tests now parse SKILL.md frontmatter structurally via the
   shared parseFrontmatter helper instead of substring-matching source
   text, and assert the version/name/description shape required by
   Hermes' skill_view().

Full suite: 6128/6128 pass (3 new structural assertions).
2026-04-30 21:22:36 -04:00
Tom Boucher
198022f58d chore(ci): disable CodeRabbit docstring coverage check (#2932)
The docstring coverage pre-merge check (default: warning at 80% threshold)
produces false-positive warnings on PRs whose new code is entirely test
files: it counts test(...) / beforeEach / afterEach arrow-function
callbacks as functions and reports 0% coverage because nothing has JSDoc.

CR's documented schema for reviews.pre_merge_checks.docstrings only
accepts `mode` and `threshold` — there is no per-check path filter that
would let us exclude tests/** while keeping the check active elsewhere.
The top-level path_filters approach would silence ALL CR review on test
files (security scans, out-of-scope checks, the substantive line-level
findings) which we want to keep.

Disabling the check entirely is the right call for this repo because:
  - GSD ships a CLI + agent runtime, not a documented public library
  - The internal helpers that warrant JSDoc already have it
  - The other CR pre-merge checks (out-of-scope, security, title) are
    meaningful for this codebase and stay enabled

Closes #2932
2026-04-30 21:13:55 -04:00
Tom Boucher
ac100ae17b test: assert reportStep present before extractBlockquotes (CR #2918)
Two existing tests called extractBlockquotes(reportStep) without first
asserting reportStep was non-null. If the workflow file ever loses its
`<step name="report">` block, the test would fail with a confusing
TypeError on the destructuring inside extractBlockquotes instead of a
clear "report step must exist" assertion.

Add assert.ok(reportStep, ...) guards at the two missing call sites
(lines 100 and 130). The other two call sites (lines 75-83) already
had guards.

Addresses CodeRabbit comment on PR #2918.
2026-04-30 21:08:26 -04:00
Tom Boucher
002db4dd2b Merge pull request #2931 from gsd-build/feat/2929-release-sdk-parity
ci(release-sdk): bring CI gates to parity with release.yml
2026-04-30 21:04:12 -04:00
Tom Boucher
0e0f6952c5 ci(release-sdk): bring CI gates to parity with release.yml (#2929)
Ports the pre-publish CI gates that release.yml applies into release-sdk.yml,
so the stopgap workflow ships releases at the same quality bar as the
canonical workflow (minus the @gsd-build/sdk publish, still intentionally
omitted, and the release-branch ceremony, intentionally omitted).

Changes (all mechanical copies of release.yml patterns):

  - install-smoke as needs: dependency. The reusable workflow at
    .github/workflows/install-smoke.yml runs the cross-platform install
    matrix (Ubuntu 22/24, macOS 24, packed-vs-unpacked). Publish job
    won't start until install-smoke passes for the dispatched ref.

  - npm test → npm run test:coverage. Full coverage gate, matching
    release.yml's pre-publish test step.

  - Tolerant tag-existence check. The previous upfront "refuse if tag
    exists" was too strict — operators re-running after a mid-flight
    publish-step failure would be blocked by the tag they successfully
    pushed last time. New behavior matches release.yml: skip the tag
    step if the tag points at HEAD; error only if it points elsewhere.

  - Tag-and-push step gets the same skip-if-at-HEAD pattern.

  - New "Re-point next dist-tag at the new latest" step, gated on
    tag=latest. Matches release.yml#finalize "Clean up next dist-tag" —
    keeps @next from going stale relative to @latest.

  - New "Create GitHub Release" step. Per-tag flag selection:
      tag=dev, tag=next  → --prerelease (won't be highlighted on repo home)
      tag=latest         → --latest (becomes the highlighted release)
    All use --generate-notes so the release body auto-fills from commits.

  - Summary updated to mention the GitHub Release and dist-tag re-point.

Out of scope per #2929:
  - canary.yml, release.yml unchanged (verified by file diff)
  - bin/install.js unchanged (install path already uses bundled SDK)
  - No @gsd-build/sdk publish anywhere
  - No release/X.Y.Z branch ceremony (this stopgap targets dispatched
    ref directly)
2026-04-30 20:59:37 -04:00
Tom Boucher
bdead2ee6a Merge pull request #2927 from gsd-build/feat/2925-release-sdk-main
feat(ci): release-sdk.yml stopgap workflow for dev/next/latest CC publishes
2026-04-30 20:51:11 -04:00
Tom Boucher
e107bb35d4 feat(ci): add release-sdk.yml stopgap workflow for dev/next/latest CC publishes (#2925)
Adds a workflow_dispatch-only release path that publishes get-shit-done-cc
to ONE chosen dist-tag per run (dev | next | latest), with the SDK
bundled inside the CC tarball both as the existing loose sdk/dist/ tree
and as a fresh sdk-bundle/gsd-sdk.tgz npm-installable artifact.

Why: @gsd-build/sdk publishes from canary.yml and release.yml fail because
the @gsd-build npm token is currently unavailable. CC users don't consume
@gsd-build/sdk directly — bin/gsd-sdk.js resolves sdk/dist/cli.js from
inside the installed CC package. This workflow ships only get-shit-done-cc
(which we hold the token for) and bundles the SDK two ways so any future
install path can pick whichever shape it needs.

The new sdk-bundle/ directory is added to the CC files whitelist in-tree
at build time only — never committed. Existing canary.yml and release.yml
are intentionally untouched; restore them to primary use once the
@gsd-build/sdk token is recovered.

Per-tag version derivation when the version input is empty:
  - dev    → <base>-dev.N (next sequential, scanning v<base>-dev.* tags)
  - next   → <base>-rc.N (matches release.yml convention)
  - latest → <base> (clean, no suffix)

Refuses to publish when the version already exists on npm or has an
existing git tag (no accidental overwrites). Verifies the publish landed
on the registry and the dist-tag resolves correctly before marking the
run successful.
2026-04-30 20:46:31 -04:00
Tom Boucher
294564b951 fix(#2916): branch new phases off origin/HEAD instead of current HEAD
handle_branching in execute-phase.md (and the equivalent step in quick.md)
created the per-phase branch from whatever branch happened to be checked
out — typically the previous phase's still-unmerged feature branch — so
consecutive phases compounded on top of each other and stayed unpushed.

Detect the default branch via git symbolic-ref refs/remotes/origin/HEAD,
fast-forward it from origin, and fork the new phase branch off that tip.
Existing branches are still reused as-is. Dirty working trees fall back
to current HEAD with a loud warning, and a post-creation guard reports
any inherited commits.

Regression test extracts the bash from the <step name="handle_branching">
block structurally and runs it against a fixture repo where HEAD sits on
a previous-phase branch with extra commits.
2026-04-30 17:30:52 -04:00
Tom Boucher
9a13d2fc0b fix(#2911): audit-open emits raw human report and parseable JSON
Two bugs in the audit-open dispatch case in bin/gsd-tools.cjs:

  1. Bare output(...) calls (only core.output is in scope) threw
     ReferenceError: output is not defined on every invocation,
     blocking the first step of /gsd-complete-milestone.
  2. Even after switching to core.output(formattedReport, raw), the
     human-readable branch JSON-stringified the formatted text because
     core.output only bypasses JSON encoding when called as
     core.output(null, true, rawValue).

Fix:
  - --json path:  core.output(result, raw)   — pass the object,
    let core.output JSON-stringify (don't pre-stringify).
  - text path:    core.output(null, true, formatAuditReport(result))
    — use the rawValue form to emit verbatim section dividers and
    item lists.

Adds tests/bug-2911-audit-open-output-shape.test.cjs which parses
both modes structurally — line-by-line for text mode (asserting the
report headers exist as standalone lines, not as escaped \n inside a
JSON quoted string), and JSON.parse + key-by-key shape assertions for
--json mode (matching the contract returned by auditOpenArtifacts).
2026-04-30 17:30:19 -04:00
Tom Boucher
d29822c1da fix(progress): add explicit context-authority directive to report step
The report step in workflows/progress.md had no directive establishing
PROJECT.md/STATE.md/ROADMAP.md as the authoritative sources for the
progress report. When init.progress returned project_exists: false (e.g.
invoked from a subdirectory without .planning/), the model fell back to
whatever was in its session context — including stale CLAUDE.md
## Project blocks — and produced routing output citing the wrong
milestone/phase.

Add a blockquote directive at the top of the report step that names
PROJECT.md, STATE.md, and ROADMAP.md as authoritative and forbids using
the CLAUDE.md ## Project block as a source for any progress report field.

Fixes #2912
2026-04-30 17:27:37 -04:00