mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-05-05 06:42:14 +02:00
Compare commits
14 Commits
fix/2997-s
...
v1.39.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b78b63db1 | ||
|
|
a9bc9cdd9f | ||
|
|
5fc802dd22 | ||
|
|
d94ecc6b3b | ||
|
|
ab5df34cab | ||
|
|
295af8b550 | ||
|
|
92bc13e3ee | ||
|
|
da969c1059 | ||
|
|
7bc150cd37 | ||
|
|
e8706cd686 | ||
|
|
4d47f2de7b | ||
|
|
4f2a29aaa5 | ||
|
|
9e1b49d7b7 | ||
|
|
952ef2507a |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -6,6 +6,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased](https://github.com/gsd-build/get-shit-done/compare/v1.38.5...HEAD)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`gsd-sdk query agent-skills` emits raw `<agent_skills>` block instead of JSON-wrapped string** — workflows that embed via `$(gsd-sdk query agent-skills <agent>)` were receiving a JSON-quoted string literal mid-prompt (e.g. `"<agent_skills>\n…"`), silently breaking all `<agent_skills>` injection into spawned subagents. The CLI dispatcher now honors an opt-in `format: 'text'` field on `QueryResult` and writes such results raw via `process.stdout.write`; `--pick` always returns JSON regardless. (#2917)
|
||||
- **`sketch --wrap-up` now dispatches correctly** — `/gsd-sketch --wrap-up` was silently no-oping because the flag dispatch wiring was omitted when the micro-skill entry point was absorbed in #2790. (#2949)
|
||||
- **`help.md` no longer advertises eight slash commands removed by the #2824 consolidation** — `/gsd-do`, `/gsd-note`, `/gsd-check-todos`, `/gsd-plant-seed`, `/gsd-research-phase`, `/gsd-list-phase-assumptions`, `/gsd-plan-milestone-gaps`, and `/gsd-join-discord` were removed when 86 skills were folded into 59. `help.md` was not updated alongside, so users typing the documented commands hit *Unknown command*. Each entry is now either rewritten to the surviving flag-based dispatcher (e.g., `/gsd-do …` → `/gsd-progress --do "…"`, `/gsd-note` → `/gsd-capture --note`, `/gsd-plant-seed` → `/gsd-capture --seed`, `/gsd-check-todos` → `/gsd-capture --list`) or removed for skills with no replacement. A regression test now asserts every `/gsd-*` reference in `help.md` has a matching `commands/gsd/*.md` stub. (#2954)
|
||||
- **`--sdk` install on Windows now writes a callable `gsd-sdk` shim** — `npx get-shit-done-cc@latest --claude --global --sdk` on Windows previously left `gsd-sdk` off PATH because `trySelfLinkGsdSdk` returned `null` unconditionally on `win32` (a missed gap from #2775's POSIX self-link, not an intentional deferral). The function now dispatches to a Windows counterpart that writes the standard npm shim triple (`gsd-sdk.cmd`, `gsd-sdk.ps1`, and a Bash wrapper) to npm's global bin, so `gsd-sdk` resolves in a fresh shell across cmd.exe, PowerShell, and Cygwin/MSYS/Git-Bash. A new regression guard in `tests/no-unconditional-win32-skip.test.cjs` blocks any future `if (process.platform === 'win32') return null;` skip-only branches in `bin/install.js`. (#2962)
|
||||
- **`/gsd-reapply-patches` Step 5 gate is now deterministic — no more silent content drops** — the prior gate parsed a Claude-generated *Hunk Verification Table* whose `verified: yes` rows were filled in without actually checking content presence, leading to merged files that lost user-added blocks (e.g., a `<visual_companion>` section, an `--execute-only` flag block) while the workflow reported success. The gate now invokes a Node script (`scripts/verify-reapply-patches.cjs`) that diffs each backup against the pristine baseline, computes the user-added significant lines, and asserts each one is present in the merged file. Exits non-zero with a per-file diagnostic on any miss; the workflow halts and surfaces the JSON output to the user. The verifier ignores low-signal lines (too short, pure whitespace, decorative comments) so trivial differences don't trigger false failures. Out of scope here: the manifest-baseline tightening described in #2969 Failure 1 — that's separate work. (#2969)
|
||||
|
||||
### Added — 1.40.0-rc.1
|
||||
- **Six namespace meta-skills with keyword-tag descriptions** — replace the flat 86-skill
|
||||
listing with two-stage hierarchical routing. Model sees 6 namespace routers
|
||||
@@ -38,7 +46,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
now auto-closes PRs opened without a closing keyword that links a tracking issue,
|
||||
posting a comment that points to the contribution guide. (#2872)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stale deleted command references updated across workflow files** — `help.md`, `do.md`, `settings.md`, `discuss-phase.md`, `new-project.md`, `plan-phase.md`, `spike.md`, and `sketch.md` referenced command names removed in #2790; updated to new consolidated equivalents. (#2950)
|
||||
|
||||
### Fixed — 1.40.0-rc.1
|
||||
- **`spike --wrap-up` now dispatches correctly** — `/gsd-spike --wrap-up` was silently no-oping because the flag dispatch wiring was omitted when the micro-skill entry point was absorbed in #2790. (#2948)
|
||||
- **`config-get context_window` returns `200000` when key absent** — querying an unset `context_window` previously exited 1 with "Key not found", surfacing a confusing error in planning logs even though the workflow fallback worked correctly. `cmdConfigGet` now consults a `SCHEMA_DEFAULTS` map and returns the documented default (`200000`, exit 0) for absent schema-defaulted keys; unknown absent keys still error as before. (#2943)
|
||||
- **`gap-analysis` now parses non-`REQ-` requirement IDs and ignores traceability table headers** — `parseRequirements()` no longer hard-codes the `REQ-` prefix and now accepts uppercase prefixed IDs such as `TST-01`, `BACK-07`, and `INSP-04`; markdown table header rows (for example `| REQ-ID | ... |`) are excluded so header tokens are not reported as phantom uncovered requirements. Added regression coverage for mixed-prefix REQUIREMENTS files with traceability tables. (#2897)
|
||||
- **Gemini slash commands namespaced as `/gsd:<cmd>` instead of `/gsd-<cmd>`** —
|
||||
Gemini CLI namespaces commands under `gsd:`, so `/gsd-plan-phase` was unexecutable.
|
||||
|
||||
@@ -281,6 +281,7 @@ Some tests legitimately read source files. There are six recognized categories:
|
||||
| `docs-parity` | A reference doc must stay in sync with source-defined constants (e.g., `CONFIG_DEFAULTS`). The source is the canonical list; there is no runtime API to enumerate it. |
|
||||
| `integration-test-input` | A source file is used as a real fixture input to a transformation function under test — the file is not inspected for strings but passed as data. |
|
||||
| `structural-implementation-guard` | A feature's interception or wiring point is not reachable end-to-end via `runGsdTools`. Used temporarily until a behavioral path exists. |
|
||||
| `pending-migration-to-typed-ir` | **Tracked for correction, not exempted.** Test was identified by the lint as carrying a raw-text-matching pattern that contradicts the rule above. Each annotated file MUST cite the open migration issue (e.g. `// allow-test-rule: pending-migration-to-typed-ir [#NNNN]`) so the tracking is auditable. New tests cannot use this category — they must refactor production to expose typed IR. The annotation is removed when the test is corrected. |
|
||||
|
||||
Annotate with a standalone `//` comment before the file's opening block comment:
|
||||
|
||||
@@ -296,6 +297,68 @@ Annotate with a standalone `//` comment before the file's opening block comment:
|
||||
|
||||
The annotation **must** be a standalone `// allow-test-rule:` line, not inside a `/** */` block comment — the CI linter scans for the pattern `// allow-test-rule:`.
|
||||
|
||||
### Prohibited: Raw Text Matching on Test Outputs (file content, stdout, stderr)
|
||||
|
||||
**Source-grep is not just `readFileSync` of a `.cjs` file.** The same anti-pattern shows up wherever a test pattern-matches against text that a system-under-test produced, regardless of whether that text came from a source file, a rendered shim, a child process's stdout, or a free-form `reason` string. **All forms are forbidden.**
|
||||
|
||||
The following are all violations of the same rule:
|
||||
|
||||
```javascript
|
||||
// BAD — substring match on text written by the code under test
|
||||
const cmdContent = fs.readFileSync(path.join(tmpDir, 'gsd-sdk.cmd'), 'utf8');
|
||||
assert.ok(cmdContent.includes(`@node ${jsonQuoted} %*`), '.cmd embeds shim path');
|
||||
|
||||
// BAD — regex match on a child process's human-readable stdout formatter
|
||||
const r = cp.spawnSync(SCRIPT, ['--patches-dir', dir]);
|
||||
assert.match(r.stdout, /Failures: 1/);
|
||||
assert.match(r.stdout, /not a regular file/);
|
||||
|
||||
// BAD — "structured parser" that hides string ops behind a function wrapper
|
||||
function parseCmdShim(content) {
|
||||
const lines = content.split('\r\n').filter((l) => l.length > 0);
|
||||
return { header: lines[0], usesCRLF: content.includes('\r\n') };
|
||||
}
|
||||
|
||||
// BAD — assert.match on a free-form `reason` string from a JSON report
|
||||
assert.ok(/not a regular file/.test(report.results[0].reason));
|
||||
```
|
||||
|
||||
Each of these passes on accidental near-matches (a comment containing `@node` somewhere, a stack trace that happens to say `Failures: 1`, a mis-typed reason that still contains the substring you're matching) and fails on harmless reformatting (changing `Failures: 1` to `1 failure`, swapping CRLF rendering style, rewording the error prose).
|
||||
|
||||
#### The rule
|
||||
|
||||
> **Tests assert on typed structured values. If the code under test produces text, the code under test must also expose a structured intermediate representation, and the test must assert on that IR — never on the rendered text.**
|
||||
|
||||
Concretely: for any system-under-test that produces text output (a file renderer, a CLI formatter, an error-message builder), the production code MUST expose a typed alternative that the test consumes:
|
||||
|
||||
| Output kind | Required structured surface | What the test asserts on |
|
||||
|---|---|---|
|
||||
| Rendered file (shim, template, generated code) | A pure builder function returning the IR (`{ invocation, eol, fileNames, render }`) | `triple.invocation.target === expected`, `triple.eol.cmd === '\r\n'` |
|
||||
| CLI human-formatter output | A `--json` mode that emits the same data structurally | `report.results[0].reason === REASON.FAIL_INSTALLED_NOT_REGULAR_FILE` |
|
||||
| Error / status / reason | A frozen enum (`Object.freeze({ FAIL_X: 'fail_x', ... })`) | `assert.equal(result.reason, REASON.FAIL_X)` |
|
||||
| File presence after a write | `fs.statSync().isFile()`, `.size > 0`, `.mtimeMs` advances | Filesystem facts; never read the file content back |
|
||||
|
||||
#### Concrete examples from this repo
|
||||
|
||||
`buildWindowsShimTriple(shimSrc)` in `bin/install.js` is the canonical IR pattern: pure function, no I/O, returns `{ invocation, eol, fileNames, render }`. `trySelfLinkGsdSdkWindows` calls it and writes `triple.render[kind]()` to disk. Tests assert on `triple.invocation.target`, `triple.eol.cmd`, `Object.keys(triple).sort()` — never on the rendered text. Filesystem-level tests assert `fs.statSync(target).size === Buffer.byteLength(triple.render.cmd())` to prove the writer writes what the renderer produces, **without comparing content**.
|
||||
|
||||
`scripts/verify-reapply-patches.cjs` exposes a frozen `REASON` enum and emits it through `--json`. Tests assert `report.results[0].reason === REASON.FAIL_USER_LINES_MISSING`. The human formatter exists for operator console output only — tests must not depend on its prose. Adding a new reason code requires updating the `REASON` enum, the `--json` output, AND the test that locks `Object.keys(REASON).sort()` — three coordinated changes that prevent the code surface from drifting from the test surface.
|
||||
|
||||
#### Hiding grep behind a function is still grep
|
||||
|
||||
`parseCmdShim`, `parsePs1Invocation`, etc. that internally do `content.split(...)`, `lines[1].trim()`, `content.includes(...)` are still string manipulation. The fact that the entry point looks like a parser doesn't change what's happening underneath — the test is still asserting on the lexical shape of rendered text. The fix is not "wrap the grep in a function with a typed-looking return value." The fix is to **eliminate the rendered text from the test path entirely** by surfacing the IR.
|
||||
|
||||
#### When you cannot eliminate text matching
|
||||
|
||||
There are exactly two cases where text content is the legitimate object of a test, both already covered by the existing exemption matrix:
|
||||
|
||||
1. `source-text-is-the-product` — workflow `.md` / agent `.md` / command `.md` files where the deployed text IS what the runtime loads.
|
||||
2. `docs-parity` — a reference doc must mirror source-defined constants and there is no runtime enumeration API.
|
||||
|
||||
For everything else, if a test reaches for `.includes()` / `.startsWith()` / `assert.match(text, /…/)`, the production code is missing a typed surface. **Add the typed surface; do not work around it.**
|
||||
|
||||
**CI enforcement:** `scripts/lint-no-source-grep.cjs` is being extended (see issue tracker for the latest scope) to flag `String#includes`/`String#startsWith`/`String#endsWith`/`assert.match` on `readFileSync` results and on `cp.spawnSync` stdout/stderr in test files, with the same `// allow-test-rule:` exemption mechanism.
|
||||
|
||||
### Node.js Version Compatibility
|
||||
|
||||
**Node 22 is the minimum supported version.** Node 24 is the primary CI target. All tests must pass on both.
|
||||
|
||||
@@ -358,6 +358,30 @@ If RED or GREEN gate commits are missing, add a warning to SUMMARY.md under a `#
|
||||
<task_commit_protocol>
|
||||
After each task completes (verification passed, done criteria met), commit immediately.
|
||||
|
||||
**0. Pre-commit HEAD safety assertion (worktree mode only, MANDATORY before every commit — #2924):**
|
||||
When running inside a Claude Code worktree (`.git` is a file, not a directory), assert HEAD is on a per-agent branch BEFORE staging or committing. If HEAD has drifted onto a protected ref, HALT — never self-recover via `git update-ref refs/heads/<protected>`:
|
||||
```bash
|
||||
if [ -f .git ]; then # worktree
|
||||
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
|
||||
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
# Deny-list: never commit on a protected ref.
|
||||
if [ "$HEAD_REF" = "DETACHED" ] || \
|
||||
echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
|
||||
echo "FATAL: refusing to commit — worktree HEAD is on '$ACTUAL_BRANCH' (expected per-agent branch)." >&2
|
||||
echo "DO NOT use 'git update-ref' to rewind the protected branch — surface as blocker (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Positive allow-list: HEAD must be on the canonical Claude Code worktree-agent
|
||||
# branch namespace (`worktree-agent-<id>`). This catches feature/* and any other
|
||||
# arbitrary branch that the deny-list would silently allow (#2924).
|
||||
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
|
||||
echo "FATAL: refusing to commit — worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace." >&2
|
||||
echo "Agent commits must live on per-agent branches; surface as blocker (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
**1. Check modified files:** `git status --short`
|
||||
|
||||
**2. Stage task-related files individually** (NEVER `git add .` or `git add -A`):
|
||||
@@ -426,6 +450,15 @@ back, those deletions appear on the main branch, destroying prior-wave work (#20
|
||||
- `git rm` on files not explicitly created by the current task
|
||||
- `git checkout -- .` or `git restore .` (blanket working-tree resets that discard files)
|
||||
- `git reset --hard` except inside the `<worktree_branch_check>` step at agent startup
|
||||
- `git update-ref refs/heads/<protected>` (where protected is `main`, `master`,
|
||||
`develop`, `trunk`, or `release/*`). This is an absolute prohibition (#2924).
|
||||
If you discover that your worktree HEAD is attached to a protected branch and your
|
||||
commits landed there, **DO NOT** "recover" by force-rewinding the protected ref —
|
||||
that silently destroys concurrent commits in multi-active scenarios (parallel
|
||||
agents, user committing while you run). HALT and surface a blocker. The setup-time
|
||||
`<worktree_branch_check>` and per-commit `<pre_commit_head_assertion>` are the
|
||||
correct prevention; if either fails, the workflow MUST stop, not self-heal.
|
||||
- `git push --force` / `git push -f` to any branch you did not create.
|
||||
|
||||
If you need to discard changes to a specific file you modified during this task, use:
|
||||
```bash
|
||||
|
||||
146
bin/install.js
146
bin/install.js
@@ -6732,7 +6732,12 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
||||
if (USER_OWNED_ARTIFACTS.includes(rel)) continue;
|
||||
manifest.files['get-shit-done/' + rel] = hash;
|
||||
}
|
||||
if (isGemini && fs.existsSync(commandsDir)) {
|
||||
// Record commands/gsd/ for any runtime that emits it (Gemini globally,
|
||||
// Claude Code locally — see #2923). Manifest must reflect everything on
|
||||
// disk so saveLocalPatches() can detect user edits and so per-runtime
|
||||
// assertions about minimal-mode emit can read manifest.files instead of
|
||||
// re-walking the dir.
|
||||
if (fs.existsSync(commandsDir)) {
|
||||
const cmdHashes = generateManifest(commandsDir);
|
||||
for (const [rel, hash] of Object.entries(cmdHashes)) {
|
||||
manifest.files['commands/gsd/' + rel] = hash;
|
||||
@@ -8145,6 +8150,20 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
||||
if (runtime === 'cline') command = '/gsd-new-project';
|
||||
if (runtime === 'qwen') command = '/gsd-new-project';
|
||||
if (runtime === 'hermes') command = '/gsd-new-project';
|
||||
|
||||
// Claude Code global installs use the skills/ format (CC 2.1.88+).
|
||||
// Restart is required for CC to pick up newly-installed skills, and the
|
||||
// slash-menu surface depends on CC version — so the instruction needs to
|
||||
// cover both invocation paths to avoid #2957-style "no commands appear".
|
||||
if (runtime === 'claude' && isGlobal) {
|
||||
console.log(`
|
||||
${green}Done!${reset} Restart ${program}, then in any directory either type ${cyan}${command}${reset} or ask Claude to run the ${cyan}gsd-new-project${reset} skill.
|
||||
|
||||
${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`
|
||||
${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
|
||||
|
||||
@@ -8749,7 +8768,9 @@ function isGsdSdkOnPath() {
|
||||
* primitive there; we don't try to fabricate a .cmd shim).
|
||||
*/
|
||||
function trySelfLinkGsdSdk(shimSrc) {
|
||||
if (process.platform === 'win32') return null;
|
||||
if (process.platform === 'win32') {
|
||||
return trySelfLinkGsdSdkWindows(shimSrc);
|
||||
}
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const home = os.homedir();
|
||||
@@ -8806,6 +8827,123 @@ function trySelfLinkGsdSdk(shimSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2962: Windows counterpart to trySelfLinkGsdSdk. Prior to this, the function
|
||||
* unconditionally returned null on Windows ("we don't try to fabricate a .cmd
|
||||
* shim there"), which left `--sdk --global` installs without a callable
|
||||
* `gsd-sdk` on PATH despite the installer reporting success.
|
||||
*
|
||||
* Strategy: discover npm's global bin directory via `npm prefix -g` (which on
|
||||
* Windows IS the bin dir, no `bin/` suffix — see line 8721) and write the same
|
||||
* three-file shim set npm itself emits: `gsd-sdk.cmd` (cmd.exe), `gsd-sdk.ps1`
|
||||
* (PowerShell), and a Bash wrapper named `gsd-sdk` (for Cygwin/MSYS/Git-Bash).
|
||||
* Each shim invokes `node "<absolute path to bin/gsd-sdk.js>"` with passed
|
||||
* args so the shim location is decoupled from the SDK location — same logical
|
||||
* structure as the POSIX wrapper-via-require() fallback above.
|
||||
*
|
||||
* Returns the .cmd file path on success (the primary handle the installer's
|
||||
* onPath check looks for), null otherwise.
|
||||
*/
|
||||
/**
|
||||
* Pure builder: compute the structured Windows shim triple from a shimSrc path.
|
||||
* No filesystem I/O, no spawn — produces the IR that `trySelfLinkGsdSdkWindows`
|
||||
* then renders to disk. Exposed for tests so assertions can run against typed
|
||||
* fields (interpreter, shimAbs, eol, fileNames) instead of substring matches
|
||||
* over rendered shim text.
|
||||
*/
|
||||
function buildWindowsShimTriple(shimSrc) {
|
||||
const path = require('path');
|
||||
const shimAbs = path.resolve(shimSrc);
|
||||
// JSON.stringify produces a double-quoted string with backslash+quote
|
||||
// escaping — the safe quoting form for cmd.exe and PowerShell paths alike.
|
||||
const shimQuoted = JSON.stringify(shimAbs);
|
||||
|
||||
const invocation = {
|
||||
interpreter: 'node',
|
||||
target: shimAbs,
|
||||
};
|
||||
|
||||
// Renderers are template literals — the only place text is constructed.
|
||||
// Tests do not parse these strings; they assert on the typed fields above.
|
||||
const renderCmd = () =>
|
||||
'@ECHO OFF\r\n@SETLOCAL\r\n@node ' + shimQuoted + ' %*\r\n';
|
||||
const renderPs1 = () =>
|
||||
'#!/usr/bin/env pwsh\n& node ' + shimQuoted + ' $args\nexit $LASTEXITCODE\n';
|
||||
const renderSh = () =>
|
||||
'#!/usr/bin/env sh\nexec node ' + shimQuoted + ' "$@"\n';
|
||||
|
||||
return {
|
||||
invocation,
|
||||
eol: { cmd: '\r\n', ps1: '\n', sh: '\n' },
|
||||
fileNames: { cmd: 'gsd-sdk.cmd', ps1: 'gsd-sdk.ps1', sh: 'gsd-sdk' },
|
||||
render: { cmd: renderCmd, ps1: renderPs1, sh: renderSh },
|
||||
};
|
||||
}
|
||||
|
||||
function trySelfLinkGsdSdkWindows(shimSrc) {
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const cp = require('child_process');
|
||||
|
||||
let npmPrefix;
|
||||
try {
|
||||
// On Windows, `npm` is `npm.cmd` — Node's child_process docs explicitly
|
||||
// call out that .cmd/.bat files cannot be spawned via execFile/execFileSync
|
||||
// without a shell ("Spawning .bat and .cmd files on Windows" section).
|
||||
// Match the existing convention at line ~8718 which uses execSync for the
|
||||
// same `npm prefix -g` lookup. Inputs here are static literals, so shell
|
||||
// interpolation is not an injection vector.
|
||||
npmPrefix = cp
|
||||
.execSync('npm prefix -g', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!npmPrefix || !fs.existsSync(npmPrefix)) return null;
|
||||
|
||||
// Verify writability before producing partial shim sets.
|
||||
try {
|
||||
fs.mkdirSync(npmPrefix, { recursive: true });
|
||||
const probe = path.join(npmPrefix, '.gsd-sdk-write-probe');
|
||||
fs.writeFileSync(probe, '');
|
||||
fs.unlinkSync(probe);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const triple = buildWindowsShimTriple(shimSrc);
|
||||
const targets = {
|
||||
cmd: path.join(npmPrefix, triple.fileNames.cmd),
|
||||
ps1: path.join(npmPrefix, triple.fileNames.ps1),
|
||||
sh: path.join(npmPrefix, triple.fileNames.sh),
|
||||
};
|
||||
|
||||
try {
|
||||
// Replace any existing shims — they may be stale (prior install of an
|
||||
// older version pointing at a now-absent shim path).
|
||||
for (const target of Object.values(targets)) {
|
||||
try { fs.unlinkSync(target); } catch {}
|
||||
}
|
||||
fs.writeFileSync(targets.cmd, triple.render.cmd());
|
||||
fs.writeFileSync(targets.ps1, triple.render.ps1());
|
||||
fs.writeFileSync(targets.sh, triple.render.sh());
|
||||
// chmod is a no-op on Windows-native node but harmless; sets exec bit on
|
||||
// WSL-mounted filesystems where Bash users live.
|
||||
try { fs.chmodSync(targets.sh, 0o755); } catch {}
|
||||
return targets.cmd;
|
||||
} catch {
|
||||
// Partial-write on permission flap — best-effort cleanup so the next run
|
||||
// starts from a clean slate.
|
||||
for (const target of Object.values(targets)) {
|
||||
try { fs.unlinkSync(target); } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GSD for all selected runtimes
|
||||
*/
|
||||
@@ -8935,6 +9073,10 @@ if (process.env.GSD_TEST_MODE) {
|
||||
restoreUserArtifacts,
|
||||
USER_OWNED_ARTIFACTS,
|
||||
finishInstall,
|
||||
trySelfLinkGsdSdk,
|
||||
trySelfLinkGsdSdkWindows,
|
||||
buildWindowsShimTriple,
|
||||
isGsdSdkOnPath,
|
||||
homePathCoveredByRc,
|
||||
maybeSuggestPathExport,
|
||||
runtimeMap,
|
||||
|
||||
@@ -30,6 +30,7 @@ Does not require `/gsd-new-project` — auto-creates `.planning/sketches/` if ne
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/sketch.md
|
||||
@~/.claude/get-shit-done/workflows/sketch-wrap-up.md
|
||||
@~/.claude/get-shit-done/references/ui-brand.md
|
||||
@~/.claude/get-shit-done/references/sketch-theme-system.md
|
||||
@~/.claude/get-shit-done/references/sketch-interactivity.md
|
||||
@@ -50,6 +51,9 @@ Design idea: $ARGUMENTS
|
||||
</context>
|
||||
|
||||
<process>
|
||||
Execute the sketch workflow from @~/.claude/get-shit-done/workflows/sketch.md end-to-end.
|
||||
Parse the first token of $ARGUMENTS:
|
||||
- If it is `--wrap-up`: strip the flag, execute the sketch-wrap-up workflow from @~/.claude/get-shit-done/workflows/sketch-wrap-up.md end-to-end.
|
||||
- Otherwise: execute the sketch workflow from @~/.claude/get-shit-done/workflows/sketch.md end-to-end.
|
||||
|
||||
Preserve all workflow gates (intake, decomposition, target stack research, variant evaluation, MANIFEST updates, commit patterns).
|
||||
</process>
|
||||
|
||||
@@ -30,6 +30,7 @@ Does not require `/gsd-new-project` — auto-creates `.planning/spikes/` if need
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/spike.md
|
||||
@~/.claude/get-shit-done/workflows/spike-wrap-up.md
|
||||
@~/.claude/get-shit-done/references/ui-brand.md
|
||||
</execution_context>
|
||||
|
||||
@@ -47,6 +48,9 @@ Idea: $ARGUMENTS
|
||||
</context>
|
||||
|
||||
<process>
|
||||
Execute the spike workflow from @~/.claude/get-shit-done/workflows/spike.md end-to-end.
|
||||
Parse the first token of $ARGUMENTS:
|
||||
- If it is `--wrap-up`: strip the flag, execute the spike-wrap-up workflow from @~/.claude/get-shit-done/workflows/spike-wrap-up.md.
|
||||
- Otherwise: pass all of $ARGUMENTS as the idea to the spike workflow from @~/.claude/get-shit-done/workflows/spike.md end-to-end.
|
||||
|
||||
Preserve all workflow gates (prior spike check, decomposition, research, risk ordering, observability assessment, verification, MANIFEST updates, commit patterns).
|
||||
</process>
|
||||
|
||||
@@ -191,6 +191,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
|
||||
| `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 |
|
||||
| `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 |
|
||||
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31 |
|
||||
| `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). |
|
||||
| `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review-fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 |
|
||||
| `workflow.code_review_depth` | string | `standard` | Default review depth for `/gsd-code-review`: `quick` (pattern-matching only), `standard` (per-file analysis), or `deep` (cross-file with import graphs). Can be overridden per-run with `--depth=`. Added in v1.34 |
|
||||
| `workflow.plan_bounce` | boolean | `false` | Run external validation script against generated plans. When enabled, the plan-phase orchestrator pipes each PLAN.md through the script specified by `plan_bounce_script` and blocks on non-zero exit. Added in v1.36 |
|
||||
|
||||
@@ -1070,6 +1070,7 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
'agents',
|
||||
path.join('commands', 'gsd'),
|
||||
'hooks',
|
||||
'skills',
|
||||
];
|
||||
|
||||
function walkDir(dir, baseDir) {
|
||||
|
||||
@@ -26,6 +26,7 @@ const VALID_CONFIG_KEYS = new Set([
|
||||
'workflow.skip_discuss',
|
||||
'workflow.auto_prune_state',
|
||||
'workflow.use_worktrees',
|
||||
'workflow.worktree_skip_hooks',
|
||||
'workflow.code_review',
|
||||
'workflow.code_review_depth',
|
||||
'workflow.code_review_command',
|
||||
|
||||
@@ -377,6 +377,15 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
||||
output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-level defaults for well-known config keys.
|
||||
* When a key is absent from config.json and no --default flag was supplied,
|
||||
* cmdConfigGet checks here before emitting "Key not found".
|
||||
*/
|
||||
const SCHEMA_DEFAULTS = {
|
||||
'context_window': 200000,
|
||||
};
|
||||
|
||||
function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
|
||||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||||
const hasDefault = defaultValue !== undefined;
|
||||
@@ -406,6 +415,11 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
|
||||
for (const key of keys) {
|
||||
if (current === undefined || current === null || typeof current !== 'object') {
|
||||
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
const def = SCHEMA_DEFAULTS[keyPath];
|
||||
output(def, raw, String(def));
|
||||
return;
|
||||
}
|
||||
error(`Key not found: ${keyPath}`);
|
||||
}
|
||||
current = current[key];
|
||||
@@ -413,6 +427,11 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
|
||||
|
||||
if (current === undefined) {
|
||||
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
const def = SCHEMA_DEFAULTS[keyPath];
|
||||
output(def, raw, String(def));
|
||||
return;
|
||||
}
|
||||
error(`Key not found: ${keyPath}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,11 @@ gsd-sdk query commit "docs: initialize [project-name] ([N] phases)" --files .pla
|
||||
Each task gets its own commit immediately after completion.
|
||||
|
||||
> **Parallel agents:** When running as a parallel executor (spawned by execute-phase),
|
||||
> use `--no-verify` on all commits to avoid pre-commit hook lock contention.
|
||||
> The orchestrator validates hooks once after all agents complete.
|
||||
> run commits normally — let pre-commit hooks run. Do NOT pass `--no-verify` by default
|
||||
> (#2924). Hooks should fire on the introducing commit; silent bypass violates project
|
||||
> CLAUDE.md guidance. If a project explicitly opts out via
|
||||
> `workflow.worktree_skip_hooks=true`, the orchestrator surfaces that flag in the
|
||||
> executor prompt; absent that signal, hooks run normally.
|
||||
|
||||
```
|
||||
{type}({phase}-{plan}): {task-name}
|
||||
|
||||
@@ -252,7 +252,7 @@ RAW_SKETCHES=$(ls .planning/sketches/MANIFEST.md 2>/dev/null)
|
||||
|
||||
If findings skills exist, read SKILL.md and reference files; extract validated patterns, landmines, constraints, design decisions. Add them to `<prior_decisions>`.
|
||||
|
||||
If raw spikes/sketches exist but no findings skill, note: `⚠ Unpackaged spikes/sketches detected — run /gsd-spike-wrap-up or /gsd-sketch-wrap-up to make findings available.`
|
||||
If raw spikes/sketches exist but no findings skill, note: `⚠ Unpackaged spikes/sketches detected — run /gsd-spike --wrap-up or /gsd-sketch --wrap-up to make findings available.`
|
||||
|
||||
Build internal `<prior_decisions>` with sections for Project-Level (from PROJECT.md / REQUIREMENTS.md), From Prior Phases (per-phase decisions), and From Spike/Sketch Findings (validated patterns, landmines, design decisions).
|
||||
|
||||
|
||||
@@ -44,29 +44,29 @@ Evaluate `$ARGUMENTS` against these routing rules. Apply the **first matching**
|
||||
| A bug, error, crash, failure, or something broken | `/gsd-debug` | Needs systematic investigation |
|
||||
| Spiking, "test if", "will this work", "experiment", "prove this out", validate feasibility | `/gsd-spike` | Throwaway experiment to validate feasibility |
|
||||
| Sketching, "mockup", "what would this look like", "prototype the UI", "design this", explore visual direction | `/gsd-sketch` | Throwaway HTML mockups to explore design |
|
||||
| Wrapping up spikes, "package the spikes", "consolidate spike findings" | `/gsd-spike-wrap-up` | Package spike findings into reusable skill |
|
||||
| Wrapping up sketches, "package the designs", "consolidate sketch findings" | `/gsd-sketch-wrap-up` | Package sketch findings into reusable skill |
|
||||
| Exploring, researching, comparing, or "how does X work" | `/gsd-research-phase` | Domain research before planning |
|
||||
| Wrapping up spikes, "package the spikes", "consolidate spike findings" | `/gsd-spike --wrap-up` | Package spike findings into reusable skill |
|
||||
| Wrapping up sketches, "package the designs", "consolidate sketch findings" | `/gsd-sketch --wrap-up` | Package sketch findings into reusable skill |
|
||||
| Exploring, researching, comparing, or "how does X work" | `/gsd-explore` | Socratic ideation and idea routing |
|
||||
| Discussing vision, "how should X look", brainstorming | `/gsd-discuss-phase` | Needs context gathering |
|
||||
| A complex task: refactoring, migration, multi-file architecture, system redesign | `/gsd-add-phase` | Needs a full phase with plan/build cycle |
|
||||
| A complex task: refactoring, migration, multi-file architecture, system redesign | `/gsd-phase` | Needs a full phase with plan/build cycle |
|
||||
| Planning a specific phase or "plan phase N" | `/gsd-plan-phase` | Direct planning request |
|
||||
| Executing a phase or "build phase N", "run phase N" | `/gsd-execute-phase` | Direct execution request |
|
||||
| Running all remaining phases automatically | `/gsd-autonomous` | Full autonomous execution |
|
||||
| A review or quality concern about existing work | `/gsd-verify-work` | Needs verification |
|
||||
| Checking progress, status, "where am I" | `/gsd-progress` | Status check |
|
||||
| Resuming work, "pick up where I left off" | `/gsd-resume-work` | Session restoration |
|
||||
| A note, idea, or "remember to..." | `/gsd-add-todo` | Capture for later |
|
||||
| A note, idea, or "remember to..." | `/gsd-capture` | Capture for later |
|
||||
| Adding tests, "write tests", "test coverage" | `/gsd-add-tests` | Test generation |
|
||||
| Completing a milestone, shipping, releasing | `/gsd-complete-milestone` | Milestone lifecycle |
|
||||
| A specific, actionable, small task (add feature, fix typo, update config) | `/gsd-quick` | Self-contained, single executor |
|
||||
|
||||
**Requires `.planning/` directory:** All routes except `/gsd-new-project`, `/gsd-map-codebase`, `/gsd-spike`, `/gsd-sketch`, `/gsd-help`, and `/gsd-join-discord`. If the project doesn't exist and the route requires it, suggest `/gsd-new-project` first.
|
||||
**Requires `.planning/` directory:** All routes except `/gsd-new-project`, `/gsd-map-codebase`, `/gsd-spike`, `/gsd-sketch`, and `/gsd-help`. If the project doesn't exist and the route requires it, suggest `/gsd-new-project` first.
|
||||
|
||||
**Ambiguity handling:** If the text could reasonably match multiple routes, ask the user via AskUserQuestion with the top 2-3 options. For example:
|
||||
|
||||
```
|
||||
"Refactor the authentication system" could be:
|
||||
1. /gsd-add-phase — Full planning cycle (recommended for multi-file refactors)
|
||||
1. /gsd-phase — Full planning cycle (recommended for multi-file refactors)
|
||||
2. /gsd-quick — Quick execution (if scope is small and clear)
|
||||
|
||||
Which approach fits better?
|
||||
|
||||
@@ -506,40 +506,37 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
</objective>
|
||||
|
||||
<worktree_branch_check>
|
||||
FIRST ACTION before any other work: verify this worktree's branch is based on the correct commit.
|
||||
|
||||
Run:
|
||||
FIRST ACTION: HEAD assertion MUST run before any reset/checkout. Worktrees
|
||||
spawned by Claude Code's `isolation="worktree"` use the `worktree-agent-<id>`
|
||||
namespace. If HEAD is on a protected ref (main/master/develop/trunk/release/*)
|
||||
or detached, HALT — do NOT self-recover by force-rewinding via `git update-ref`,
|
||||
that destroys concurrent commits in multi-active scenarios (#2924). Only after
|
||||
Step 1 passes is `git reset --hard` safe (#2015 — affects all platforms).
|
||||
```bash
|
||||
ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE})
|
||||
```
|
||||
|
||||
If `ACTUAL_BASE` != `{EXPECTED_BASE}` (i.e. the worktree branch was created from an older
|
||||
base such as `main` instead of the feature branch HEAD), hard-reset to the correct base:
|
||||
```bash
|
||||
# Safe: this runs before any agent work, so no uncommitted changes to lose
|
||||
git reset --hard {EXPECTED_BASE}
|
||||
# Verify correction succeeded
|
||||
if [ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ]; then
|
||||
echo "ERROR: Could not correct worktree base — aborting to prevent data loss"
|
||||
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
|
||||
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$HEAD_REF" = "DETACHED" ] || echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
|
||||
echo "FATAL: worktree HEAD on '$ACTUAL_BRANCH' (expected worktree-agent-*); refusing to self-recover via 'git update-ref' (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
|
||||
echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace; refusing to commit (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE})
|
||||
if [ "$ACTUAL_BASE" != "{EXPECTED_BASE}" ]; then
|
||||
git reset --hard {EXPECTED_BASE}
|
||||
[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && { echo "ERROR: could not correct worktree base"; exit 1; }
|
||||
fi
|
||||
```
|
||||
|
||||
`reset --hard` is safe here because this is a fresh worktree with no user changes. It
|
||||
resets both the HEAD pointer AND the working tree to the correct base commit (#2015).
|
||||
|
||||
If `ACTUAL_BASE` == `{EXPECTED_BASE}`: the branch base is correct, proceed immediately.
|
||||
|
||||
This check fixes a known issue where `EnterWorktree` creates branches from
|
||||
`main` instead of the current feature branch HEAD (affects all platforms).
|
||||
Per-commit HEAD assertion lives in `agents/gsd-executor.md` `<task_commit_protocol>` step 0.
|
||||
</worktree_branch_check>
|
||||
|
||||
<parallel_execution>
|
||||
You are running as a PARALLEL executor agent in a git worktree.
|
||||
Use --no-verify on all git commits to avoid pre-commit hook contention
|
||||
with other agents. The orchestrator validates hooks once after all agents complete.
|
||||
For `gsd-sdk query commit` (or legacy `gsd-tools.cjs` commit): add --no-verify flag when needed.
|
||||
For direct git commits: use git commit --no-verify -m "..."
|
||||
Run `git commit` normally — hooks run by default. Do NOT pass `--no-verify`
|
||||
unless the orchestrator surfaces `workflow.worktree_skip_hooks=true` in this
|
||||
prompt; silent bypass violates project CLAUDE.md guidance (#2924).
|
||||
|
||||
IMPORTANT: Do NOT modify STATE.md or ROADMAP.md. execute-plan.md
|
||||
auto-detects worktree mode (`.git` is a file, not a directory) and skips
|
||||
@@ -656,13 +653,16 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
**This fallback applies automatically to all runtimes.** Claude Code's Task() normally
|
||||
returns synchronously, but the fallback ensures resilience if it doesn't.
|
||||
|
||||
5. **Post-wave hook validation (parallel mode only):**
|
||||
|
||||
When agents committed with `--no-verify`, run pre-commit hooks once after the wave:
|
||||
5. **Post-wave hook validation (parallel mode only):** Hooks run on every executor commit by default (#2924); this post-wave run only fires when `workflow.worktree_skip_hooks=true` opted out of per-commit hooks:
|
||||
```bash
|
||||
# Run project's pre-commit hooks on the current state
|
||||
git diff --cached --quiet || git stash # stash any unstaged changes
|
||||
git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing"
|
||||
SKIP_HOOKS=$(gsd-sdk query config-get workflow.worktree_skip_hooks 2>/dev/null || echo "false")
|
||||
if [ "$SKIP_HOOKS" = "true" ]; then
|
||||
# Stash uncommitted changes under a named ref so we always pop (bare `git stash` strands them on hook/script failure).
|
||||
STASHED=false
|
||||
if (! git diff --quiet || ! git diff --cached --quiet) && git stash push -u -m "gsd-post-wave-hook-$$" >/dev/null 2>&1; then STASHED=true; fi
|
||||
git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing"
|
||||
[ "$STASHED" = "true" ] && (git stash pop >/dev/null 2>&1 || echo "⚠ Could not pop gsd-post-wave-hook stash — recover manually")
|
||||
fi
|
||||
```
|
||||
If hooks fail: report the failure and ask "Fix hook issues now?" or "Continue to next wave?"
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ Otherwise: Apply checkpoint-based routing below.
|
||||
| Verify-only | B (segmented) | Segments between checkpoints. After none/human-verify → SUBAGENT. After decision/human-action → MAIN |
|
||||
| Decision | C (main) | Execute entirely in main context |
|
||||
|
||||
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, include a `<worktree_branch_check>` block in the prompt** instructing the executor to run `git merge-base HEAD {EXPECTED_BASE}` and, if the result differs from `{EXPECTED_BASE}`, hard-reset the branch with `git reset --hard {EXPECTED_BASE}` before starting work (safe — runs before any agent work), then verify with `[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && exit 1`. This corrects a known issue where `EnterWorktree` creates branches from `main` instead of the feature branch HEAD (affects all platforms).
|
||||
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, include a `<worktree_branch_check>` block in the prompt** instructing the executor to: (1) FIRST assert `git symbolic-ref HEAD` resolves to a per-agent branch (NOT a protected ref like `main`/`master`/`develop`/`trunk`/`release/*`) and HALT with a blocker if not — never self-recover via `git update-ref refs/heads/<protected>` (#2924); (2) only after that assertion passes, run `git merge-base HEAD {EXPECTED_BASE}` and, if the result differs from `{EXPECTED_BASE}`, hard-reset the branch with `git reset --hard {EXPECTED_BASE}` before starting work, then verify with `[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && exit 1`. The HEAD assertion (Step 1) MUST run before any reset/checkout. This corrects a known issue where `EnterWorktree` creates branches from `main` instead of the feature branch HEAD (affects all platforms — #2015) and prevents the destructive HEAD-on-master self-recovery path (#2924).
|
||||
|
||||
**Pattern B:** Execute segment-by-segment. Autonomous segments: spawn subagent for assigned tasks only (no SUMMARY/commit). Checkpoints: main context. After all segments: aggregate, create SUMMARY, commit. See segment_execution.
|
||||
|
||||
@@ -239,7 +239,12 @@ See `~/.claude/get-shit-done/references/tdd.md` for structure.
|
||||
Your commits may trigger pre-commit hooks. Auto-fix hooks handle themselves transparently — files get fixed and re-staged automatically.
|
||||
|
||||
**If running as a parallel executor agent (spawned by execute-phase):**
|
||||
Use `--no-verify` on all commits. Pre-commit hooks cause build lock contention when multiple agents commit simultaneously (e.g., cargo lock fights in Rust projects). The orchestrator validates once after all agents complete.
|
||||
Run commits normally — let pre-commit hooks run. Do NOT use `--no-verify` by default
|
||||
(#2924). Hooks should run so issues surface at the introducing commit, and silent
|
||||
bypass violates project CLAUDE.md guidance. If a project explicitly opts out via
|
||||
`workflow.worktree_skip_hooks=true`, the orchestrator will surface that flag in the
|
||||
prompt; absent that signal, hooks run normally. If a hook fails, follow the
|
||||
sequential-mode handling below.
|
||||
|
||||
**If running as the sole executor (sequential mode):**
|
||||
If a commit is BLOCKED by a hook:
|
||||
|
||||
@@ -48,9 +48,13 @@ Creates all `.planning/` artifacts:
|
||||
|
||||
Usage: `/gsd-new-project`
|
||||
|
||||
**`/gsd-map-codebase`**
|
||||
**`/gsd-map-codebase [--fast] [--focus <area>] [--query <term>]`**
|
||||
Map an existing codebase for brownfield projects.
|
||||
|
||||
- `--fast` — rapid lightweight assessment (replaces the former `gsd-scan`)
|
||||
- `--focus <area>` — scope the map to a specific area
|
||||
- `--query <term>` — query the codebase intelligence index in `.planning/intel/` (replaces the former `gsd-intel`)
|
||||
|
||||
- Analyzes codebase with parallel Explore agents
|
||||
- Creates `.planning/codebase/` with 7 focused documents
|
||||
- Covers stack, architecture, structure, conventions, testing, integrations, concerns
|
||||
@@ -60,9 +64,13 @@ Usage: `/gsd-map-codebase`
|
||||
|
||||
### Phase Planning
|
||||
|
||||
**`/gsd-discuss-phase <number>`**
|
||||
**`/gsd-discuss-phase <number> [--chain | --analyze | --power] [--batch[=N]]`**
|
||||
Help articulate your vision for a phase before planning.
|
||||
|
||||
- `--chain` — chained-prompt discuss flow
|
||||
- `--analyze` — deep assumption analysis pass
|
||||
- `--power` — power-user mode with extended question set
|
||||
|
||||
- Captures how you imagine this phase working
|
||||
- Creates CONTEXT.md with your vision, essentials, and boundaries
|
||||
- Use when you have ideas about how something should look/feel
|
||||
@@ -72,28 +80,15 @@ Usage: `/gsd-discuss-phase 2`
|
||||
Usage: `/gsd-discuss-phase 2 --batch`
|
||||
Usage: `/gsd-discuss-phase 2 --batch=3`
|
||||
|
||||
**`/gsd-research-phase <number>`**
|
||||
Comprehensive ecosystem research for niche/complex domains.
|
||||
|
||||
- Discovers standard stack, architecture patterns, pitfalls
|
||||
- Creates RESEARCH.md with "how experts build this" knowledge
|
||||
- Use for 3D, games, audio, shaders, ML, and other specialized domains
|
||||
- Goes beyond "which library" to ecosystem knowledge
|
||||
|
||||
Usage: `/gsd-research-phase 3`
|
||||
|
||||
**`/gsd-list-phase-assumptions <number>`**
|
||||
See what Claude is planning to do before it starts.
|
||||
|
||||
- Shows Claude's intended approach for a phase
|
||||
- Lets you course-correct if Claude misunderstood your vision
|
||||
- No files created - conversational output only
|
||||
|
||||
Usage: `/gsd-list-phase-assumptions 3`
|
||||
|
||||
**`/gsd-plan-phase <number>`**
|
||||
**`/gsd-plan-phase <number> [--skip-research] [--gaps] [--skip-verify] [--tdd] [--mvp]`**
|
||||
Create detailed execution plan for a specific phase.
|
||||
|
||||
- `--skip-research` — bypass the research subagent
|
||||
- `--gaps` — focus only on closing gaps from a prior plan-check
|
||||
- `--skip-verify` — skip the post-plan verifier loop
|
||||
- `--tdd` — plan in test-driven order (tests before code)
|
||||
- `--mvp` — vertical-slice MVP planning mode
|
||||
|
||||
- Generates `.planning/phases/XX-phase-name/XX-YY-PLAN.md`
|
||||
- Breaks phase into concrete, actionable tasks
|
||||
- Includes verification criteria and success measures
|
||||
@@ -106,9 +101,13 @@ Result: Creates `.planning/phases/01-foundation/01-01-PLAN.md`
|
||||
|
||||
### Execution
|
||||
|
||||
**`/gsd-execute-phase <phase-number>`**
|
||||
**`/gsd-execute-phase <phase-number> [--wave N] [--gaps-only] [--tdd]`**
|
||||
Execute all plans in a phase, or run a specific wave.
|
||||
|
||||
- `--wave N` — execute only wave N (see *Plans within each wave* below)
|
||||
- `--gaps-only` — re-run only plans flagged as gaps by a prior verifier
|
||||
- `--tdd` — enforce test-driven order during execution
|
||||
|
||||
- Groups plans by wave (from frontmatter), executes waves sequentially
|
||||
- Plans within each wave run in parallel via Task tool
|
||||
- Optional `--wave N` flag executes only Wave `N` and stops unless the phase is now fully complete
|
||||
@@ -120,7 +119,7 @@ Usage: `/gsd-execute-phase 5 --wave 2`
|
||||
|
||||
### Smart Router
|
||||
|
||||
**`/gsd-do <description>`**
|
||||
**`/gsd-progress --do "<description>"`**
|
||||
Route freeform text to the right GSD command automatically.
|
||||
|
||||
- Analyzes natural language input to find the best matching GSD command
|
||||
@@ -128,9 +127,9 @@ Route freeform text to the right GSD command automatically.
|
||||
- Resolves ambiguity by asking you to pick between top matches
|
||||
- Use when you know what you want but don't know which `/gsd-*` command to run
|
||||
|
||||
Usage: `/gsd-do fix the login button`
|
||||
Usage: `/gsd-do refactor the auth system`
|
||||
Usage: `/gsd-do I want to start a new milestone`
|
||||
Usage: `/gsd-progress --do "fix the login button"`
|
||||
Usage: `/gsd-progress --do "refactor the auth system"`
|
||||
Usage: `/gsd-progress --do "I want to start a new milestone"`
|
||||
|
||||
### Quick Mode
|
||||
|
||||
@@ -172,26 +171,26 @@ Usage: `/gsd-fast "add .env to gitignore"`
|
||||
|
||||
### Roadmap Management
|
||||
|
||||
**`/gsd-add-phase <description>`**
|
||||
**`/gsd-phase <description>`**
|
||||
Add new phase to end of current milestone.
|
||||
|
||||
- Appends to ROADMAP.md
|
||||
- Uses next sequential number
|
||||
- Updates phase directory structure
|
||||
|
||||
Usage: `/gsd-add-phase "Add admin dashboard"`
|
||||
Usage: `/gsd-phase "Add admin dashboard"`
|
||||
|
||||
**`/gsd-insert-phase <after> <description>`**
|
||||
**`/gsd-phase --insert <after> <description>`**
|
||||
Insert urgent work as decimal phase between existing phases.
|
||||
|
||||
- Creates intermediate phase (e.g., 7.1 between 7 and 8)
|
||||
- Useful for discovered work that must happen mid-milestone
|
||||
- Maintains phase ordering
|
||||
|
||||
Usage: `/gsd-insert-phase 7 "Fix critical auth bug"`
|
||||
Usage: `/gsd-phase --insert 7 "Fix critical auth bug"`
|
||||
Result: Creates Phase 7.1
|
||||
|
||||
**`/gsd-remove-phase <number>`**
|
||||
**`/gsd-phase --remove <number>`**
|
||||
Remove a future phase and renumber subsequent phases.
|
||||
|
||||
- Deletes phase directory and all references
|
||||
@@ -199,9 +198,15 @@ Remove a future phase and renumber subsequent phases.
|
||||
- Only works on future (unstarted) phases
|
||||
- Git commit preserves historical record
|
||||
|
||||
Usage: `/gsd-remove-phase 17`
|
||||
Usage: `/gsd-phase --remove 17`
|
||||
Result: Phase 17 deleted, phases 18-20 become 17-19
|
||||
|
||||
**`/gsd-phase --edit <number> [--force]`**
|
||||
Edit any field of an existing roadmap phase in place, preserving number and position.
|
||||
|
||||
- Updates title, description, requirements, dependencies in `ROADMAP.md`
|
||||
- `--force` allows editing already-started phases (use with caution)
|
||||
|
||||
### Milestone Management
|
||||
|
||||
**`/gsd-new-milestone <name>`**
|
||||
@@ -230,7 +235,7 @@ Usage: `/gsd-complete-milestone 1.0.0`
|
||||
|
||||
### Progress Tracking
|
||||
|
||||
**`/gsd-progress`**
|
||||
**`/gsd-progress [--next | --forensic | --do "<description>"]`**
|
||||
Check project status and intelligently route to next action.
|
||||
|
||||
- Shows visual progress bar and completion percentage
|
||||
@@ -240,7 +245,15 @@ Check project status and intelligently route to next action.
|
||||
- Offers to execute next plan or create it if missing
|
||||
- Detects 100% milestone completion
|
||||
|
||||
Modes:
|
||||
- **default** — progress report + intelligent routing
|
||||
- **`--next`** — auto-advance to the next logical step (use `--next --force` to bypass safety gates)
|
||||
- **`--forensic`** — append a 6-check integrity audit after the progress report
|
||||
- **`--do "<text>"`** — smart router: dispatch freeform intent to the matching `/gsd-*` command (see *Smart Router* above)
|
||||
|
||||
Usage: `/gsd-progress`
|
||||
Usage: `/gsd-progress --next`
|
||||
Usage: `/gsd-progress --forensic`
|
||||
|
||||
### Session Management
|
||||
|
||||
@@ -264,9 +277,11 @@ Usage: `/gsd-pause-work`
|
||||
|
||||
### Debugging
|
||||
|
||||
**`/gsd-debug [issue description]`**
|
||||
**`/gsd-debug [issue description] [--diagnose]`**
|
||||
Systematic debugging with persistent state across context resets.
|
||||
|
||||
- `--diagnose` — run a one-shot diagnostic pass without opening a persistent debug session
|
||||
|
||||
- Gathers symptoms through adaptive questioning
|
||||
- Creates `.planning/debug/[slug].md` to track investigation
|
||||
- Investigates using scientific method (evidence → hypothesis → test)
|
||||
@@ -305,7 +320,7 @@ Rapidly sketch UI/design ideas using throwaway HTML mockups with multi-variant e
|
||||
Usage: `/gsd-sketch "dashboard layout for the admin panel"`
|
||||
Usage: `/gsd-sketch --quick "form card grouping"`
|
||||
|
||||
**`/gsd-spike-wrap-up`**
|
||||
**`/gsd-spike --wrap-up`**
|
||||
Package spike findings into a persistent project skill.
|
||||
|
||||
- Curates each spike one-at-a-time (include/exclude/partial/UAT)
|
||||
@@ -314,9 +329,9 @@ Package spike findings into a persistent project skill.
|
||||
- Writes summary to `.planning/spikes/WRAP-UP-SUMMARY.md`
|
||||
- Adds auto-load routing line to project CLAUDE.md
|
||||
|
||||
Usage: `/gsd-spike-wrap-up`
|
||||
Usage: `/gsd-spike --wrap-up`
|
||||
|
||||
**`/gsd-sketch-wrap-up`**
|
||||
**`/gsd-sketch --wrap-up`**
|
||||
Package sketch design findings into a persistent project skill.
|
||||
|
||||
- Curates each sketch one-at-a-time (include/exclude/partial/revisit)
|
||||
@@ -325,27 +340,12 @@ Package sketch design findings into a persistent project skill.
|
||||
- Writes summary to `.planning/sketches/WRAP-UP-SUMMARY.md`
|
||||
- Adds auto-load routing line to project CLAUDE.md
|
||||
|
||||
Usage: `/gsd-sketch-wrap-up`
|
||||
Usage: `/gsd-sketch --wrap-up`
|
||||
|
||||
### Quick Notes
|
||||
### Capturing Ideas, Notes, and Todos
|
||||
|
||||
**`/gsd-note <text>`**
|
||||
Zero-friction idea capture — one command, instant save, no questions.
|
||||
|
||||
- Saves timestamped note to `.planning/notes/` (or `~/.claude/notes/` globally)
|
||||
- Three subcommands: append (default), list, promote
|
||||
- Promote converts a note into a structured todo
|
||||
- Works without a project (falls back to global scope)
|
||||
|
||||
Usage: `/gsd-note refactor the hook system`
|
||||
Usage: `/gsd-note list`
|
||||
Usage: `/gsd-note promote 3`
|
||||
Usage: `/gsd-note --global cross-project idea`
|
||||
|
||||
### Todo Management
|
||||
|
||||
**`/gsd-add-todo [description]`**
|
||||
Capture idea or task as todo from current conversation.
|
||||
**`/gsd-capture [description]`**
|
||||
Capture an idea or task as a structured todo from current conversation.
|
||||
|
||||
- Extracts context from conversation (or uses provided description)
|
||||
- Creates structured todo file in `.planning/todos/pending/`
|
||||
@@ -353,20 +353,33 @@ Capture idea or task as todo from current conversation.
|
||||
- Checks for duplicates before creating
|
||||
- Updates STATE.md todo count
|
||||
|
||||
Usage: `/gsd-add-todo` (infers from conversation)
|
||||
Usage: `/gsd-add-todo Add auth token refresh`
|
||||
Usage: `/gsd-capture` (infers from conversation)
|
||||
Usage: `/gsd-capture Add auth token refresh`
|
||||
|
||||
**`/gsd-check-todos [area]`**
|
||||
**`/gsd-capture --note <text>`**
|
||||
Zero-friction note capture — one command, instant save, no questions.
|
||||
|
||||
- Saves timestamped note to `.planning/notes/` (or `~/.claude/notes/` globally)
|
||||
- Three subcommands: append (default), list, promote
|
||||
- Promote converts a note into a structured todo
|
||||
- Works without a project (falls back to global scope)
|
||||
|
||||
Usage: `/gsd-capture --note refactor the hook system`
|
||||
Usage: `/gsd-capture --note list`
|
||||
Usage: `/gsd-capture --note promote 3`
|
||||
Usage: `/gsd-capture --note --global cross-project idea`
|
||||
|
||||
**`/gsd-capture --list [area]`**
|
||||
List pending todos and select one to work on.
|
||||
|
||||
- Lists all pending todos with title, area, age
|
||||
- Optional area filter (e.g., `/gsd-check-todos api`)
|
||||
- Optional area filter (e.g., `/gsd-capture --list api`)
|
||||
- Loads full context for selected todo
|
||||
- Routes to appropriate action (work now, add to phase, brainstorm)
|
||||
- Moves todo to done/ when work begins
|
||||
|
||||
Usage: `/gsd-check-todos`
|
||||
Usage: `/gsd-check-todos api`
|
||||
Usage: `/gsd-capture --list`
|
||||
Usage: `/gsd-capture --list api`
|
||||
|
||||
### User Acceptance Testing
|
||||
|
||||
@@ -420,14 +433,23 @@ Usage: `/gsd-pr-branch` or `/gsd-pr-branch main`
|
||||
|
||||
---
|
||||
|
||||
**`/gsd-plant-seed [idea]`**
|
||||
**`/gsd-capture --seed [idea]`**
|
||||
Capture a forward-looking idea with trigger conditions for automatic surfacing.
|
||||
|
||||
- Seeds preserve WHY, WHEN to surface, and breadcrumbs to related code
|
||||
- Auto-surfaces during `/gsd-new-milestone` when trigger conditions match
|
||||
- Better than deferred items — triggers are checked, not forgotten
|
||||
|
||||
Usage: `/gsd-plant-seed "add real-time notifications when we build the events system"`
|
||||
Usage: `/gsd-capture --seed "add real-time notifications when we build the events system"`
|
||||
|
||||
**`/gsd-capture --backlog [description]`**
|
||||
Add an idea to the backlog parking lot for future milestones.
|
||||
|
||||
- Creates a backlog item under 999.x numbering in ROADMAP.md
|
||||
- Reserves ideas without committing to the current milestone
|
||||
- Surface and promote later via `/gsd-review-backlog`
|
||||
|
||||
Usage: `/gsd-capture --backlog "real-time notifications when events ship"`
|
||||
|
||||
---
|
||||
|
||||
@@ -452,16 +474,6 @@ Audit milestone completion against original intent.
|
||||
|
||||
Usage: `/gsd-audit-milestone`
|
||||
|
||||
**`/gsd-plan-milestone-gaps`**
|
||||
Create phases to close gaps identified by audit.
|
||||
|
||||
- Reads MILESTONE-AUDIT.md and groups gaps into phases
|
||||
- Prioritizes by requirement priority (must/should/nice)
|
||||
- Adds gap closure phases to ROADMAP.md
|
||||
- Ready for `/gsd-plan-phase` on new phases
|
||||
|
||||
Usage: `/gsd-plan-milestone-gaps`
|
||||
|
||||
### Configuration
|
||||
|
||||
**`/gsd-settings`**
|
||||
@@ -473,15 +485,19 @@ Configure workflow toggles and model profile interactively.
|
||||
|
||||
Usage: `/gsd-settings`
|
||||
|
||||
**`/gsd-set-profile <profile>`**
|
||||
Quick switch model profile for GSD agents.
|
||||
**`/gsd-config [--profile <profile> | --advanced | --integrations]`**
|
||||
Configure GSD beyond the basic settings: model profile, advanced tuning, and third-party integrations.
|
||||
|
||||
- `--profile <profile>` — quick switch model profile (`quality | balanced | budget | inherit`)
|
||||
- `--advanced` — power-user tuning: plan bounce, timeouts, branch templates, cross-AI execution (replaces the former `gsd-settings-advanced`)
|
||||
- `--integrations` — third-party API keys, code-review CLI routing, agent-skill injection (replaces the former `gsd-settings-integrations`)
|
||||
|
||||
- `quality` — Opus everywhere except verification
|
||||
- `balanced` — Opus for planning, Sonnet for execution (default)
|
||||
- `budget` — Sonnet for writing, Haiku for research/verification
|
||||
- `inherit` — Use current session model for all agents (OpenCode `/model`)
|
||||
|
||||
Usage: `/gsd-set-profile budget`
|
||||
Usage: `/gsd-config --profile budget`
|
||||
|
||||
### Utility Commands
|
||||
|
||||
@@ -498,9 +514,12 @@ Usage: `/gsd-cleanup`
|
||||
**`/gsd-help`**
|
||||
Show this command reference.
|
||||
|
||||
**`/gsd-update`**
|
||||
**`/gsd-update [--sync] [--reapply]`**
|
||||
Update GSD to latest version with changelog preview.
|
||||
|
||||
- `--sync` — sync managed GSD skills across runtime roots (replaces the former `gsd-sync-skills`)
|
||||
- `--reapply` — reapply local modifications after an update (replaces the former `gsd-reapply-patches`)
|
||||
|
||||
- Shows installed vs latest version comparison
|
||||
- Displays changelog entries for versions you've missed
|
||||
- Highlights breaking changes
|
||||
@@ -509,13 +528,72 @@ Update GSD to latest version with changelog preview.
|
||||
|
||||
Usage: `/gsd-update`
|
||||
|
||||
**`/gsd-join-discord`**
|
||||
Join the GSD Discord community.
|
||||
## Additional Commands
|
||||
|
||||
- Get help, share what you're building, stay updated
|
||||
- Connect with other GSD users
|
||||
The commands above cover the most common day-to-day flows. Every command listed here is also a live `/gsd-*` slash command and is grouped by purpose.
|
||||
|
||||
Usage: `/gsd-join-discord`
|
||||
### Discovery & Specification
|
||||
|
||||
- **`/gsd-explore`** — Socratic ideation and idea routing. Think through ideas before committing to plans.
|
||||
- **`/gsd-spec-phase <phase> [--auto] [--text]`** — Clarify WHAT a phase delivers with ambiguity scoring; produces a SPEC.md before discuss-phase.
|
||||
- **`/gsd-ai-integration-phase [phase]`** — Generate an AI-SPEC.md design contract for phases that involve building AI systems.
|
||||
- **`/gsd-ui-phase [phase]`** — Generate UI design contract (UI-SPEC.md) for frontend phases.
|
||||
- **`/gsd-import --from <filepath>`** — Ingest external plans with conflict detection against project decisions before writing anything.
|
||||
- **`/gsd-ingest-docs [path] [--mode new|merge] [--manifest <file>] [--resolve auto|interactive]`** — Bootstrap or merge a `.planning/` setup from existing ADRs, PRDs, SPECs, and docs in a repo.
|
||||
|
||||
### Planning & Execution
|
||||
|
||||
- **`/gsd-ultraplan-phase [phase]`** — [BETA] Offload plan phase to Claude Code's ultraplan cloud; review in browser and import back.
|
||||
- **`/gsd-plan-review-convergence <phase> [--codex] [--gemini] [--claude] [--opencode] [--ollama] [--lm-studio] [--llama-cpp] [--all] [--text] [--ws <name>] [--max-cycles N]`** — Cross-AI plan convergence loop — replan with review feedback until no HIGH concerns remain. Supports both cloud reviewers (Codex/Gemini/Claude/OpenCode) and local model runtimes (Ollama, LM Studio, llama.cpp).
|
||||
- **`/gsd-autonomous [--from N] [--to N] [--only N] [--interactive]`** — Run all remaining phases autonomously: discuss → plan → execute per phase.
|
||||
|
||||
### Quality, Review & Verification
|
||||
|
||||
- **`/gsd-code-review <phase> [--depth=quick|standard|deep] [--files file1,file2,...] [--fix [--all] [--auto]]`** — Review source files changed during a phase for bugs, security issues, and code quality problems.
|
||||
- **`/gsd-secure-phase [phase]`** — Retroactively verify threat mitigations for a completed phase.
|
||||
- **`/gsd-validate-phase [phase]`** — Retroactively audit and fill Nyquist validation gaps for a completed phase.
|
||||
- **`/gsd-ui-review [phase]`** — Retroactive 6-pillar visual audit of implemented frontend code.
|
||||
- **`/gsd-eval-review [phase]`** — Audit an executed AI phase's evaluation coverage and produce an EVAL-REVIEW.md remediation plan.
|
||||
- **`/gsd-audit-fix --source <audit-uat> [--severity medium|high|all] [--max N] [--dry-run]`** — Autonomous audit-to-fix pipeline: find issues, classify, fix, test, commit.
|
||||
- **`/gsd-add-tests <phase> [additional instructions]`** — Generate tests for a completed phase based on UAT criteria and implementation.
|
||||
|
||||
### Diagnostics & Maintenance
|
||||
|
||||
- **`/gsd-health [--repair] [--context]`** — Diagnose planning directory health and optionally repair issues.
|
||||
- **`/gsd-forensics [problem description]`** — Post-mortem investigation for failed GSD workflows; diagnoses what went wrong.
|
||||
- **`/gsd-undo --last N | --phase NN | --plan NN-MM`** — Safe git revert. Roll back phase or plan commits using the phase manifest with dependency checks.
|
||||
- **`/gsd-docs-update [--force] [--verify-only]`** — Generate or update project documentation verified against the codebase.
|
||||
- **`/gsd-extract-learnings <phase>`** — Extract decisions, lessons, patterns, and surprises from completed phase artifacts.
|
||||
|
||||
### Knowledge & Context
|
||||
|
||||
- **`/gsd-graphify [build|query <term>|status|diff]`** — Build, query, and inspect the project knowledge graph in `.planning/graphs/`.
|
||||
- **`/gsd-thread [list [--open|--resolved] | close <slug> | status <slug> | name | description]`** — Manage persistent context threads for cross-session work.
|
||||
- **`/gsd-profile-user [--questionnaire] [--refresh]`** — Generate developer behavioral profile and create Claude-discoverable artifacts.
|
||||
- **`/gsd-stats`** — Display project statistics: phases, plans, requirements, git metrics, and timeline.
|
||||
|
||||
### Workflow & Orchestration
|
||||
|
||||
- **`/gsd-manager`** — Interactive command center for managing multiple phases from one terminal.
|
||||
- **`/gsd-workspace [--new | --list | --remove] [name]`** — Manage GSD workspaces: create, list, or remove isolated workspace environments.
|
||||
- **`/gsd-workstreams`** — Manage parallel workstreams: list, create, switch, status, progress, complete, and resume.
|
||||
- **`/gsd-review-backlog`** — Review and promote backlog items to active milestone.
|
||||
- **`/gsd-milestone-summary [version]`** — Generate a comprehensive project summary from milestone artifacts for team onboarding and review.
|
||||
|
||||
### Repository Integration
|
||||
|
||||
- **`/gsd-inbox [--issues] [--prs] [--label] [--close-incomplete] [--repo owner/repo]`** — Triage and review open GitHub issues and PRs against project templates and contribution guidelines.
|
||||
|
||||
### Namespace Routers (model-facing meta-skills)
|
||||
|
||||
These six skills exist primarily for the model to perform two-stage hierarchical routing across 60+ skills. You can invoke them directly when you want to browse a category interactively.
|
||||
|
||||
- **`/gsd-context`** — Codebase intelligence routing (map, graphify, docs, learnings).
|
||||
- **`/gsd-ideate`** — Exploration / capture routing (explore, sketch, spike, spec, capture).
|
||||
- **`/gsd-manage`** — Configuration and workspace routing (workstreams, thread, update, ship, inbox).
|
||||
- **`/gsd-project`** — Project-lifecycle routing (milestones, audits, summary).
|
||||
- **`/gsd-review`** — Quality-gate routing (code review, debug, audit, security, eval, ui).
|
||||
- **`/gsd-workflow`** — Phase-pipeline routing (discuss, plan, execute, verify, phase, progress).
|
||||
|
||||
## Files & Structure
|
||||
|
||||
@@ -627,7 +705,7 @@ Example config:
|
||||
**Adding urgent mid-milestone work:**
|
||||
|
||||
```
|
||||
/gsd-insert-phase 5 "Critical security fix"
|
||||
/gsd-phase --insert 5 "Critical security fix"
|
||||
/gsd-plan-phase 5.1
|
||||
/gsd-execute-phase 5.1
|
||||
```
|
||||
@@ -643,10 +721,12 @@ Example config:
|
||||
**Capturing ideas during work:**
|
||||
|
||||
```
|
||||
/gsd-add-todo # Capture from conversation context
|
||||
/gsd-add-todo Fix modal z-index # Capture with explicit description
|
||||
/gsd-check-todos # Review and work on todos
|
||||
/gsd-check-todos api # Filter by area
|
||||
/gsd-capture # Capture from conversation context
|
||||
/gsd-capture Fix modal z-index # Capture with explicit description
|
||||
/gsd-capture --note refactor auth system # Quick friction-free note
|
||||
/gsd-capture --seed "real-time notifications" # Forward-looking idea with triggers
|
||||
/gsd-capture --list # Review and work on todos
|
||||
/gsd-capture --list api # Filter by area
|
||||
```
|
||||
|
||||
**Debugging an issue:**
|
||||
|
||||
@@ -269,8 +269,8 @@ If any of these exist, surface them before questioning:
|
||||
⚡ Prior exploration detected:
|
||||
{if SPIKE_SKILL} ✓ Spike findings skill: {path} — validated patterns from experiments
|
||||
{if SKETCH_SKILL} ✓ Sketch findings skill: {path} — validated design decisions
|
||||
{if HAS_SPIKES && !SPIKE_SKILL} ◆ Raw spikes in .planning/spikes/ — consider `/gsd-spike-wrap-up` to package findings
|
||||
{if HAS_SKETCHES && !SKETCH_SKILL} ◆ Raw sketches in .planning/sketches/ — consider `/gsd-sketch-wrap-up` to package findings
|
||||
{if HAS_SPIKES && !SPIKE_SKILL} ◆ Raw spikes in .planning/spikes/ — consider `/gsd-spike --wrap-up` to package findings
|
||||
{if HAS_SKETCHES && !SKETCH_SKILL} ◆ Raw sketches in .planning/sketches/ — consider `/gsd-sketch --wrap-up` to package findings
|
||||
|
||||
These findings will be incorporated into project context and available to planning agents.
|
||||
```
|
||||
|
||||
@@ -996,7 +996,7 @@ rest become a follow-up phase
|
||||
|
||||
Use AskUserQuestion with these 3 options.
|
||||
|
||||
**If "Split":** Use `/gsd-insert-phase` to create the sub-phases, then replan each.
|
||||
**If "Split":** Use `/gsd-phase --insert` to create the sub-phases, then replan each.
|
||||
**If "Proceed":** Return to planner with instruction to attempt all items at full fidelity, accepting more plans/tasks.
|
||||
**If "Prioritize":** Use AskUserQuestion (multiSelect) to let user pick which items are "now" vs "later". Create CONTEXT.md for each sub-phase with the selected items.
|
||||
|
||||
@@ -1025,7 +1025,7 @@ Options:
|
||||
Use AskUserQuestion for each gap (or batch if multiple gaps).
|
||||
|
||||
**If "Add plan":** Return to planner (step 8) with instruction to add plans covering the missing items, preserving existing plans.
|
||||
**If "Split":** Use `/gsd-insert-phase` for overflow items, then replan.
|
||||
**If "Split":** Use `/gsd-phase --insert` for overflow items, then replan.
|
||||
**If "Defer":** Record in CONTEXT.md `## Deferred Ideas` with developer's confirmation. Proceed to step 10.
|
||||
|
||||
## 10. Spawn gsd-plan-checker Agent
|
||||
|
||||
@@ -637,7 +637,21 @@ if [ "${USE_WORKTREES}" != "false" ]; then
|
||||
COMMIT_DOCS=$(gsd-sdk query config-get commit_docs 2>/dev/null || echo "true")
|
||||
if [ "$COMMIT_DOCS" != "false" ]; then
|
||||
git add "${QUICK_DIR}/${quick_id}-PLAN.md"
|
||||
git commit --no-verify -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" || true
|
||||
# No-op skip if nothing actually staged (idempotent re-runs).
|
||||
if git diff --cached --quiet -- "${QUICK_DIR}/${quick_id}-PLAN.md"; then
|
||||
echo "ℹ Pre-dispatch PLAN.md commit skipped (no staged changes)"
|
||||
else
|
||||
# Run hooks normally (#2924). If a project opts out via
|
||||
# workflow.worktree_skip_hooks=true, honor that opt-in only.
|
||||
SKIP_HOOKS=$(gsd-sdk query config-get workflow.worktree_skip_hooks 2>/dev/null || echo "false")
|
||||
if [ "$SKIP_HOOKS" = "true" ]; then
|
||||
git commit --no-verify -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" \
|
||||
|| { echo "ERROR: pre-dispatch PLAN.md commit failed (--no-verify path). Aborting before executor dispatch." >&2; exit 1; }
|
||||
else
|
||||
git commit -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" \
|
||||
|| { echo "ERROR: pre-dispatch PLAN.md commit failed — likely a pre-commit hook failure. Fix the hook output above (or set workflow.worktree_skip_hooks=true to bypass) and re-run." >&2; exit 1; }
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
```
|
||||
@@ -660,12 +674,31 @@ Execute quick task ${quick_id}.
|
||||
|
||||
${USE_WORKTREES !== "false" ? `
|
||||
<worktree_branch_check>
|
||||
FIRST ACTION before any other work: verify this worktree branch is based on the correct commit.
|
||||
Run: git merge-base HEAD ${EXPECTED_BASE}
|
||||
If the result differs from ${EXPECTED_BASE}, hard-reset to the correct base (safe — runs before any agent work):
|
||||
git reset --hard ${EXPECTED_BASE}
|
||||
Then verify: if [ "$(git rev-parse HEAD)" != "${EXPECTED_BASE}" ]; then echo "ERROR: Could not correct worktree base"; exit 1; fi
|
||||
This corrects a known issue where EnterWorktree creates branches from main instead of the feature branch HEAD (affects all platforms).
|
||||
FIRST ACTION before any other work: verify this worktree's HEAD is bound to a per-agent
|
||||
branch and that the branch is based on the correct commit.
|
||||
|
||||
Step 1 — HEAD attachment assertion (MANDATORY, runs before any reset/commit):
|
||||
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
|
||||
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$HEAD_REF" = "DETACHED" ] || echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
|
||||
echo "FATAL: worktree HEAD is on '$ACTUAL_BRANCH' (expected per-agent branch like worktree-agent-*)." >&2
|
||||
echo "Refusing to commit/reset on a protected ref. DO NOT self-recover via 'git update-ref refs/heads/$ACTUAL_BRANCH' — that destroys concurrent work (#2924)." >&2
|
||||
echo "Aborting before any commits. Surface as a blocker for human review." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
|
||||
echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace (Claude Code's per-agent worktree branch namespace)." >&2
|
||||
echo "Refusing to commit; surface as blocker (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Step 2 — Base correctness (only after Step 1 passes):
|
||||
Run: git merge-base HEAD ${EXPECTED_BASE}
|
||||
If the result differs from ${EXPECTED_BASE}, hard-reset to the correct base (safe — Step 1 confirmed HEAD is on a per-agent branch and the worktree is fresh):
|
||||
git reset --hard ${EXPECTED_BASE}
|
||||
Then verify: if [ "$(git rev-parse HEAD)" != "${EXPECTED_BASE}" ]; then echo "ERROR: Could not correct worktree base"; exit 1; fi
|
||||
|
||||
This corrects a known issue where EnterWorktree creates branches from main instead of the feature branch HEAD (#2015) and prevents the destructive HEAD-on-master self-recovery path (#2924).
|
||||
</worktree_branch_check>
|
||||
` : ''}
|
||||
|
||||
|
||||
@@ -269,17 +269,80 @@ After writing each merged file, verify that user modifications survived the merg
|
||||
|
||||
## Step 5: Hunk Verification Gate
|
||||
|
||||
Before proceeding to cleanup, evaluate the Hunk Verification Table produced in Step 4.
|
||||
Two layered gates. Both must pass before proceeding to cleanup.
|
||||
|
||||
**If the Hunk Verification Table is absent** (Step 4 did not produce it), STOP immediately and report to the user:
|
||||
```
|
||||
ERROR: Hunk Verification Table is missing. Post-merge verification was not completed.
|
||||
Rerun /gsd-update --reapply to retry with full verification.
|
||||
### 5a: Deterministic verifier (binding gate, #2969)
|
||||
|
||||
Run the deterministic verifier script. Do NOT rely solely on the free-text `verified: yes/no` Hunk Verification Table from Step 4 — bug #2969 traced repeated false-positive `verified: yes` reports to that table being filled in without an actual content-presence check. The script performs the check structurally and exits non-zero on any miss.
|
||||
|
||||
Run the verifier as a child process (the gsd-tools binary directory is not required — the script ships under `scripts/` in the source repo and is also exposed via the SDK at `sdk/dist/cli.js verify-reapply` when present):
|
||||
|
||||
```bash
|
||||
PRISTINE_DIR="${CONFIG_DIR}/gsd-pristine"
|
||||
|
||||
# Build args as a bash array so paths with spaces survive expansion intact
|
||||
# (string-concat + unquoted expansion would split incorrectly on whitespace).
|
||||
VERIFY_ARGS=(
|
||||
--patches-dir "$PATCHES_DIR"
|
||||
--config-dir "$CONFIG_DIR"
|
||||
)
|
||||
if [ -d "$PRISTINE_DIR" ]; then
|
||||
VERIFY_ARGS+=(--pristine-dir "$PRISTINE_DIR")
|
||||
fi
|
||||
VERIFY_ARGS+=(--json)
|
||||
|
||||
# Capture stdout (the structured JSON report) separately from stderr so that
|
||||
# Node warnings, deprecation notices, or stack traces do not corrupt the
|
||||
# JSON parse downstream. Stderr is preserved on the controlling terminal
|
||||
# for operator visibility.
|
||||
VERIFY_OUTPUT="$(node "${GSD_HOME}/scripts/verify-reapply-patches.cjs" "${VERIFY_ARGS[@]}")"
|
||||
VERIFY_STATUS=$?
|
||||
```
|
||||
|
||||
**If any row in the Hunk Verification Table shows `verified: no`**, STOP and report to the user:
|
||||
**If `VERIFY_STATUS` is non-zero**, STOP and report to the user, parsing the JSON output:
|
||||
|
||||
```text
|
||||
ERROR: {failures} file(s) failed deterministic post-merge verification (#2969 gate).
|
||||
|
||||
The verifier compared user-added lines (computed from the diff between
|
||||
the backup and the pristine baseline) against the merged installed file.
|
||||
Lines listed below are present in the backup but absent from the merged result.
|
||||
|
||||
For each failed file:
|
||||
{file}
|
||||
missing: {first significant missing line, up to 5 per file}
|
||||
backup: {patches_dir}/{file}
|
||||
|
||||
Resolve before proceeding:
|
||||
(a) Re-merge the missing content into the installed file by hand, or
|
||||
(b) Restore from backup: cp {patches_dir}/{file} {installed_path}
|
||||
|
||||
Then re-run /gsd-update --reapply to re-verify.
|
||||
```
|
||||
ERROR: {N} hunk(s) failed verification — content may have been dropped during merge.
|
||||
|
||||
Do not proceed to cleanup until the verifier exits 0.
|
||||
|
||||
**Only when `VERIFY_STATUS` is 0** (or when all files had zero significant user-added lines, which the verifier reports as `Failures: 0`) may execution continue to gate 5b.
|
||||
|
||||
### 5b: Hunk Verification Table review (advisory gate, #1999)
|
||||
|
||||
The Hunk Verification Table produced in Step 4 must also be reviewed before proceeding. This is advisory after the script gate but is preserved as a defense-in-depth check — if the script ever has a bug or the pristine baseline is unavailable, the table-based gate still catches obvious regressions.
|
||||
|
||||
**If the Hunk Verification Table is absent** (Step 4 silently produced nothing), STOP and report:
|
||||
|
||||
```
|
||||
ERROR: Hunk Verification Table is missing — Step 4 did not produce it.
|
||||
The deterministic verifier (5a) may still have passed, but a missing table
|
||||
means post-merge verification was not fully completed. Rerun
|
||||
/gsd-update --reapply to retry with full verification.
|
||||
```
|
||||
|
||||
A missing table absent from the workflow output cannot bypass this gate.
|
||||
|
||||
**If any row in the Hunk Verification Table shows `verified: no`**, STOP and report:
|
||||
|
||||
```
|
||||
ERROR: {N} hunk(s) failed Step 5b verification — content may have been dropped during merge.
|
||||
|
||||
Unverified hunks:
|
||||
{file} hunk {hunk_id}: signature line "{signature_line}" not found in merged output
|
||||
@@ -290,9 +353,9 @@ Review the merged file manually, then either:
|
||||
(b) Restore from backup: cp {patches_dir}/{file} {installed_path}
|
||||
```
|
||||
|
||||
Do not proceed to cleanup until the user confirms they have resolved all unverified hunks.
|
||||
Do not proceed to cleanup until both gates (5a and 5b) pass.
|
||||
|
||||
**Only when all rows show `verified: yes`** (or when all files had zero user-added hunks) may execution continue to Step 6.
|
||||
**Why both gates?** 5a (the script) is the binding gate — it does the actual substring check structurally and cannot be shortcut by the LLM. 5b (the table review) is the advisory gate — it provides a redundant safety net via the Step 4 prose summary, ensuring that even a script regression or absent pristine baseline cannot silently allow a `verified: no` row to slip past, nor can a missing table go unnoticed. Layered gates favour false-positive halts (recoverable) over silent successes on lost content (unrecoverable).
|
||||
|
||||
## Step 6: Cleanup option
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Parse current values (default to `true` if not present):
|
||||
- `workflow.ui_safety_gate` — prompt to run /gsd-ui-phase before planning frontend phases (default: true if absent)
|
||||
- `workflow.ai_integration_phase` — framework selection + eval strategy for AI phases (default: true if absent)
|
||||
- `workflow.tdd_mode` — enforce RED/GREEN/REFACTOR gate sequence during execute-phase (default: false if absent)
|
||||
- `workflow.code_review` — enable /gsd-code-review and /gsd-code-review-fix commands (default: true if absent)
|
||||
- `workflow.code_review` — enable /gsd-code-review and /gsd-code-review --fix commands (default: true if absent)
|
||||
- `workflow.code_review_depth` — default depth for /gsd-code-review: `quick`, `standard`, or `deep` (default: `"standard"` if absent; only relevant when `code_review` is on)
|
||||
- `workflow.ui_review` — run visual quality audit (/gsd-ui-review) in autonomous mode (default: true if absent)
|
||||
- `commit_docs` — whether `.planning/` files are committed to git (default: true if absent)
|
||||
@@ -150,7 +150,7 @@ AskUserQuestion([
|
||||
]
|
||||
},
|
||||
{
|
||||
question: "Enable Code Review? (/gsd-code-review and /gsd-code-review-fix commands)",
|
||||
question: "Enable Code Review? (/gsd-code-review and /gsd-code-review --fix commands)",
|
||||
header: "Code Review",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
@@ -457,12 +457,12 @@ Display:
|
||||
These settings apply to future /gsd-plan-phase and /gsd-execute-phase runs.
|
||||
|
||||
Quick commands:
|
||||
- /gsd-settings-integrations — configure API keys (Brave/Firecrawl/Exa), review.models CLI routing, and agent_skills injection
|
||||
- /gsd-set-profile <profile> — switch model profile
|
||||
- /gsd-config --integrations — configure API keys (Brave/Firecrawl/Exa), review.models CLI routing, and agent_skills injection
|
||||
- /gsd-config --profile <profile> — switch model profile
|
||||
- /gsd-plan-phase --research — force research
|
||||
- /gsd-plan-phase --skip-research — skip research
|
||||
- /gsd-plan-phase --skip-verify — skip plan check
|
||||
- /gsd-settings-advanced — power-user tuning (plan bounce, timeouts, branch templates, cross-AI, context window)
|
||||
- /gsd-config --advanced — power-user tuning (plan bounce, timeouts, branch templates, cross-AI, context window)
|
||||
```
|
||||
</step>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<purpose>
|
||||
Explore design directions through throwaway HTML mockups before committing to implementation.
|
||||
Each sketch produces 2-3 variants for comparison. Saves artifacts to `.planning/sketches/`.
|
||||
Companion to `/gsd-sketch-wrap-up`.
|
||||
Companion to `/gsd-sketch --wrap-up`.
|
||||
|
||||
Supports two modes:
|
||||
- **Idea mode** (default) — user describes a design idea to sketch
|
||||
@@ -331,7 +331,7 @@ After all sketches complete:
|
||||
|
||||
**Package findings** — wrap design decisions into a reusable skill
|
||||
|
||||
`/gsd-sketch-wrap-up`
|
||||
`/gsd-sketch --wrap-up`
|
||||
|
||||
───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<purpose>
|
||||
Spike an idea through experiential exploration — build focused experiments to feel the pieces
|
||||
of a future app, validate feasibility, and produce verified knowledge for the real build.
|
||||
Saves artifacts to `.planning/spikes/`. Companion to `/gsd-spike-wrap-up`.
|
||||
Saves artifacts to `.planning/spikes/`. Companion to `/gsd-spike --wrap-up`.
|
||||
|
||||
Supports two modes:
|
||||
- **Idea mode** (default) — user describes an idea to spike
|
||||
@@ -421,7 +421,7 @@ gsd-sdk query commit "docs(spikes): update conventions" --files .planning/spikes
|
||||
|
||||
**Package findings** — wrap spike knowledge into an implementation blueprint
|
||||
|
||||
`/gsd-spike-wrap-up`
|
||||
`/gsd-spike --wrap-up`
|
||||
|
||||
───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "get-shit-done-cc",
|
||||
"version": "1.39.0-rc.4",
|
||||
"version": "1.39.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "get-shit-done-cc",
|
||||
"version": "1.39.0-rc.4",
|
||||
"version": "1.39.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.84",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "get-shit-done-cc",
|
||||
"version": "1.39.0-rc.4",
|
||||
"version": "1.39.1",
|
||||
"description": "A meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini and Codex by TÂCHES.",
|
||||
"bin": {
|
||||
"get-shit-done-cc": "bin/install.js",
|
||||
|
||||
@@ -39,6 +39,29 @@ const READ_WITH_CONST_RE = /readFileSync\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,/gm;
|
||||
// Matches readFileSync with an inline path.join(.cjs) as first arg
|
||||
const READ_WITH_INLINE_CJS_RE = /readFileSync\s*\([^,)]*path\.join\s*\([^)]*(?:'bin'|"bin"|'lib'|"lib"|'get-shit-done'|"get-shit-done")[^)]*['"][^'"]*\.cjs['"]/;
|
||||
|
||||
/**
|
||||
* #2962-class violations: raw text matching against process output or file
|
||||
* content. The rule from CONTRIBUTING.md "Prohibited: Raw Text Matching on
|
||||
* Test Outputs": tests assert on typed structured fields, never on rendered
|
||||
* text. Patterns below are the obvious anti-patterns; subtler hidden forms
|
||||
* (e.g. wrapping the same logic in a parser function) are still forbidden
|
||||
* by the prose rule but cannot be detected lexically without an AST.
|
||||
*/
|
||||
const RAW_MATCH_PATTERNS = [
|
||||
{
|
||||
re: /assert\.(?:match|doesNotMatch)\s*\(\s*[A-Za-z_$][A-Za-z0-9_$]*\.(?:stdout|stderr)\b/,
|
||||
label: 'assert.match/doesNotMatch on .stdout/.stderr (emit --json from the SUT and assert on typed fields)',
|
||||
},
|
||||
{
|
||||
re: /\.(?:stdout|stderr)\.(?:includes|startsWith|endsWith)\s*\(/,
|
||||
label: '.stdout/.stderr substring match (emit --json and assert on typed fields)',
|
||||
},
|
||||
{
|
||||
re: /readFileSync\s*\([^)]*\)\s*\.(?:includes|startsWith|endsWith)\s*\(/,
|
||||
label: 'readFileSync(...).<includes|startsWith|endsWith> (expose an IR from production code; assert on its fields)',
|
||||
},
|
||||
];
|
||||
|
||||
function setFromMatches(content, re) {
|
||||
const found = new Set();
|
||||
let m;
|
||||
@@ -53,13 +76,14 @@ function check(filepath) {
|
||||
|
||||
if (ALLOW_ANNOTATION.test(content)) return null;
|
||||
|
||||
const violations = [];
|
||||
|
||||
// Pattern A: readFileSync(path.join(..., 'foo.cjs'), ...)
|
||||
if (READ_WITH_INLINE_CJS_RE.test(content)) {
|
||||
return {
|
||||
file: rel,
|
||||
violations.push({
|
||||
reason: 'readFileSync with inline .cjs path literal',
|
||||
fix: 'Replace with runGsdTools() behavioral test, or add // allow-test-rule: <reason>',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern B: const FOO_PATH = path.join(..., 'foo.cjs') + readFileSync(FOO_PATH, ...)
|
||||
@@ -68,15 +92,26 @@ function check(filepath) {
|
||||
const readConsts = setFromMatches(content, READ_WITH_CONST_RE);
|
||||
const overlap = [...cjsConsts].filter(c => readConsts.has(c));
|
||||
if (overlap.length > 0) {
|
||||
return {
|
||||
file: rel,
|
||||
violations.push({
|
||||
reason: `source .cjs path constant(s) used in readFileSync: ${overlap.join(', ')}`,
|
||||
fix: 'Replace with runGsdTools() behavioral test, or add // allow-test-rule: <reason>',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
// Patterns C..E: raw text matching against process output or file content.
|
||||
// See CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs".
|
||||
for (const { re, label } of RAW_MATCH_PATTERNS) {
|
||||
if (re.test(content)) {
|
||||
violations.push({
|
||||
reason: label,
|
||||
fix: 'Expose typed IR from production code; assert on structured fields. Or add // allow-test-rule: <reason>',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) return null;
|
||||
return { file: rel, violations };
|
||||
}
|
||||
|
||||
function findTestFiles(dir) {
|
||||
@@ -101,12 +136,17 @@ if (violations.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stderr.write(`\nERROR lint-no-source-grep: ${violations.length} violation(s) found\n\n`);
|
||||
for (const v of violations) {
|
||||
process.stderr.write(` ${v.file}\n`);
|
||||
process.stderr.write(` Problem : ${v.reason}\n`);
|
||||
process.stderr.write(` Fix : ${v.fix}\n\n`);
|
||||
const totalIssues = violations.reduce((n, v) => n + v.violations.length, 0);
|
||||
process.stderr.write(`\nERROR lint-no-source-grep: ${totalIssues} violation(s) across ${violations.length} file(s)\n\n`);
|
||||
for (const f of violations) {
|
||||
process.stderr.write(` ${f.file}\n`);
|
||||
for (const v of f.violations) {
|
||||
process.stderr.write(` Problem : ${v.reason}\n`);
|
||||
process.stderr.write(` Fix : ${v.fix}\n`);
|
||||
}
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
process.stderr.write('See CONTRIBUTING.md "Prohibited: Source-Grep Tests" for guidance.\n');
|
||||
process.stderr.write('See CONTRIBUTING.md "Prohibited: Source-Grep Tests" and\n');
|
||||
process.stderr.write('"Prohibited: Raw Text Matching on Test Outputs" for guidance.\n');
|
||||
process.stderr.write('Structural tests that legitimately read source files: add // allow-test-rule: <reason>\n\n');
|
||||
process.exit(1);
|
||||
|
||||
247
scripts/verify-reapply-patches.cjs
Executable file
247
scripts/verify-reapply-patches.cjs
Executable file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Deterministic verifier for the /gsd-reapply-patches Step 5 "Hunk Verification
|
||||
* Gate". For each backed-up patch file, asserts that the user's added lines
|
||||
* (computed from a real diff against the pristine baseline, not from the
|
||||
* LLM's prose summary) survive into the merged output.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-reapply-patches.cjs \
|
||||
* --patches-dir <path> \ # gsd-local-patches/
|
||||
* --config-dir <path> \ # ~/.claude (or runtime equivalent)
|
||||
* [--pristine-dir <path>] # gsd-pristine/; if absent, falls back to
|
||||
* # treating every significant backup line as
|
||||
* # required (over-broad but safe for #2969:
|
||||
* # false-positive halts beat silent successes
|
||||
* # on lost content)
|
||||
* [--json] # emit JSON report instead of human text
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — every user-added line is present in the merged file (gate passes)
|
||||
* 1 — at least one missing line in at least one file (gate fails)
|
||||
* 2 — usage / structural error (e.g. patches dir missing)
|
||||
*
|
||||
* Bug #2969: the Step 5 gate previously trusted Claude's free-text "verified:
|
||||
* yes/no" reporting per hunk. The LLM was filling in `yes` even when content
|
||||
* had been silently dropped. Moving the check to a deterministic script is the
|
||||
* durability fix.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SIGNIFICANT_MIN_CHARS = 12;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--patches-dir') opts.patchesDir = argv[++i];
|
||||
else if (arg === '--config-dir') opts.configDir = argv[++i];
|
||||
else if (arg === '--pristine-dir') opts.pristineDir = argv[++i];
|
||||
else if (arg === '--json') opts.json = true;
|
||||
else if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(
|
||||
'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json]\n',
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write(`unknown argument: ${arg}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function isSignificantLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length < SIGNIFICANT_MIN_CHARS) return false;
|
||||
// Pure punctuation / closing brackets carry too little structural info to
|
||||
// reliably distinguish a survived hunk from incidental similarity.
|
||||
if (/^[\s})\];,]+$/.test(trimmed)) return false;
|
||||
// Generic decorative comments like `// ----` similarly fail the test.
|
||||
if (/^[\s\-=#*/]+$/.test(trimmed)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a directory, returning every file's path relative to the root.
|
||||
*/
|
||||
function walk(rootDir, relPrefix = '') {
|
||||
const out = [];
|
||||
if (!fs.existsSync(rootDir)) return out;
|
||||
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
||||
const rel = relPrefix ? path.join(relPrefix, entry.name) : entry.name;
|
||||
const abs = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...walk(abs, rel));
|
||||
} else if (entry.isFile()) {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the set of "user-added" lines: lines present in the backup but
|
||||
* absent from the pristine baseline. If no pristine is provided, falls back
|
||||
* to using every significant line in the backup (over-broad but safe — favours
|
||||
* false-positive failures over silent successes, which is the right side to
|
||||
* err on for #2969).
|
||||
*/
|
||||
function computeUserAddedLines(backupContent, pristineContent) {
|
||||
const backupLines = backupContent.split(/\r?\n/);
|
||||
if (!pristineContent) {
|
||||
return backupLines.filter(isSignificantLine);
|
||||
}
|
||||
const pristineSet = new Set(pristineContent.split(/\r?\n/));
|
||||
return backupLines.filter((line) => isSignificantLine(line) && !pristineSet.has(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable reason codes for the per-file result. Tests assert via
|
||||
* `assert.equal(result.reason, REASON.X)` rather than regex-matching prose,
|
||||
* so the diagnostic surface is a typed enum, not free text.
|
||||
*
|
||||
* Adding a new reason requires updating the REASON map AND the tests'
|
||||
* shape assertion that locks the documented set of codes.
|
||||
*/
|
||||
const REASON = Object.freeze({
|
||||
OK_NO_USER_LINES_VS_PRISTINE: 'ok_no_user_lines_vs_pristine',
|
||||
OK_NO_SIGNIFICANT_BACKUP_LINES: 'ok_no_significant_backup_lines',
|
||||
FAIL_INSTALLED_MISSING: 'fail_installed_missing',
|
||||
FAIL_INSTALLED_NOT_REGULAR_FILE: 'fail_installed_not_regular_file',
|
||||
FAIL_READ_ERROR: 'fail_read_error',
|
||||
FAIL_USER_LINES_MISSING: 'fail_user_lines_missing',
|
||||
});
|
||||
|
||||
function verifyFile({ relPath, patchesDir, configDir, pristineDir }) {
|
||||
const backupPath = path.join(patchesDir, relPath);
|
||||
const installedPath = path.join(configDir, relPath);
|
||||
const result = { file: relPath, status: 'ok', missing: [], reason: null };
|
||||
|
||||
if (!fs.existsSync(backupPath) || !fs.statSync(backupPath).isFile()) {
|
||||
return result; // walked entry no longer exists — non-fatal
|
||||
}
|
||||
|
||||
// Installed path checks: must exist, must be a regular file, must be
|
||||
// readable. Anything else is a fail-with-diagnostic, not a crash that
|
||||
// aborts the whole gate run and drops structured output.
|
||||
let installedStat;
|
||||
try {
|
||||
installedStat = fs.statSync(installedPath);
|
||||
} catch {
|
||||
result.status = 'fail';
|
||||
result.reason = REASON.FAIL_INSTALLED_MISSING;
|
||||
return result;
|
||||
}
|
||||
if (!installedStat.isFile()) {
|
||||
result.status = 'fail';
|
||||
result.reason = REASON.FAIL_INSTALLED_NOT_REGULAR_FILE;
|
||||
return result;
|
||||
}
|
||||
|
||||
let backupContent;
|
||||
let installedContent;
|
||||
try {
|
||||
backupContent = fs.readFileSync(backupPath, 'utf8');
|
||||
installedContent = fs.readFileSync(installedPath, 'utf8');
|
||||
} catch {
|
||||
result.status = 'fail';
|
||||
result.reason = REASON.FAIL_READ_ERROR;
|
||||
return result;
|
||||
}
|
||||
|
||||
let pristineContent = null;
|
||||
if (pristineDir) {
|
||||
const pristinePath = path.join(pristineDir, relPath);
|
||||
try {
|
||||
const stat = fs.statSync(pristinePath);
|
||||
if (stat.isFile()) {
|
||||
pristineContent = fs.readFileSync(pristinePath, 'utf8');
|
||||
}
|
||||
} catch {
|
||||
// Pristine missing or unreadable — fall through to over-broad mode.
|
||||
}
|
||||
}
|
||||
|
||||
const userAdded = computeUserAddedLines(backupContent, pristineContent);
|
||||
if (userAdded.length === 0) {
|
||||
// Backup and pristine match exactly (or no significant content) — nothing
|
||||
// to verify but also nothing to lose. Report as ok with diagnostic code.
|
||||
result.reason = pristineContent
|
||||
? REASON.OK_NO_USER_LINES_VS_PRISTINE
|
||||
: REASON.OK_NO_SIGNIFICANT_BACKUP_LINES;
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const line of userAdded) {
|
||||
if (!installedContent.includes(line)) {
|
||||
result.missing.push(line.trim());
|
||||
}
|
||||
}
|
||||
if (result.missing.length > 0) {
|
||||
result.status = 'fail';
|
||||
result.reason = REASON.FAIL_USER_LINES_MISSING;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
if (!opts.patchesDir || !opts.configDir) {
|
||||
process.stderr.write('--patches-dir and --config-dir are required\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!fs.existsSync(opts.patchesDir)) {
|
||||
process.stderr.write(`patches dir not found: ${opts.patchesDir}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (!fs.existsSync(opts.configDir)) {
|
||||
process.stderr.write(`config dir not found: ${opts.configDir}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const files = walk(opts.patchesDir).filter((f) => !f.endsWith('backup-meta.json'));
|
||||
const results = files.map((relPath) =>
|
||||
verifyFile({
|
||||
relPath,
|
||||
patchesDir: opts.patchesDir,
|
||||
configDir: opts.configDir,
|
||||
pristineDir: opts.pristineDir,
|
||||
}),
|
||||
);
|
||||
|
||||
const failures = results.filter((r) => r.status === 'fail');
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ checked: results.length, failures: failures.length, results }, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`# Hunk Verification Gate (#2969)\n\n`);
|
||||
process.stdout.write(`Checked: ${results.length} file(s)\n`);
|
||||
process.stdout.write(`Failures: ${failures.length}\n\n`);
|
||||
if (failures.length > 0) {
|
||||
process.stdout.write(`## Files with missing user-added content\n\n`);
|
||||
for (const r of failures) {
|
||||
process.stdout.write(`- ${r.file}\n`);
|
||||
if (r.reason) process.stdout.write(` reason: ${r.reason}\n`);
|
||||
for (const line of r.missing.slice(0, 5)) {
|
||||
process.stdout.write(` missing: ${line}\n`);
|
||||
}
|
||||
if (r.missing.length > 5) {
|
||||
process.stdout.write(` …and ${r.missing.length - 5} more line(s)\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(failures.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON };
|
||||
4
sdk/package-lock.json
generated
4
sdk/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@gsd-build/sdk",
|
||||
"version": "1.39.0-rc.4",
|
||||
"version": "1.39.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gsd-build/sdk",
|
||||
"version": "1.39.0-rc.4",
|
||||
"version": "1.39.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.84",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@gsd-build/sdk",
|
||||
"version": "1.39.0-rc.4",
|
||||
"version": "1.39.1",
|
||||
"description": "GSD SDK — programmatic interface for running GSD plans via the Agent SDK",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -443,7 +443,13 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
output = extractField(output, pickField);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
// Handlers can signal format:'text' to emit a raw string (e.g. agent-skills
|
||||
// emits an <agent_skills> XML block workflows embed via $(...) substitution).
|
||||
if (!pickField && result.format === 'text' && typeof output === 'string') {
|
||||
process.stdout.write(output);
|
||||
} else {
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'workflow.skip_discuss',
|
||||
'workflow.auto_prune_state',
|
||||
'workflow.use_worktrees',
|
||||
'workflow.worktree_skip_hooks',
|
||||
'workflow.code_review',
|
||||
'workflow.code_review_depth',
|
||||
'workflow.code_review_command',
|
||||
|
||||
@@ -8,11 +8,15 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { agentSkills } from './skills.js';
|
||||
|
||||
const CLI = resolve(fileURLToPath(import.meta.url), '../../../dist/cli.js');
|
||||
|
||||
async function writeSkill(rootDir: string, name: string) {
|
||||
const skillDir = join(rootDir, name);
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
@@ -120,4 +124,75 @@ describe('agentSkills', () => {
|
||||
const r = await agentSkills(['gsd-planner'], tmpDir);
|
||||
expect(r.data).toBe('');
|
||||
});
|
||||
|
||||
it('signals format:"text" for non-empty blocks (used by CLI dispatcher)', async () => {
|
||||
await writeSkill(join(tmpDir, '.claude', 'skills'), 'a-skill');
|
||||
await writeConfig(tmpDir, {
|
||||
agent_skills: { 'gsd-planner': '.claude/skills/a-skill' },
|
||||
});
|
||||
|
||||
const r = await agentSkills(['gsd-planner'], tmpDir);
|
||||
expect(r.format).toBe('text');
|
||||
});
|
||||
|
||||
it('does not signal format:"text" for empty result', async () => {
|
||||
const r = await agentSkills(['gsd-planner'], tmpDir);
|
||||
expect(r.format).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CLI stdout integration ─────────────────────────────────────────────────
|
||||
// Regression guard for the JSON-wrapping bug (#2914): the CLI must emit the
|
||||
// raw <agent_skills> block to stdout, not a JSON-quoted string. Spawns the
|
||||
// CLI as a child process so the full dispatch path (including cli.ts format
|
||||
// handling) is exercised.
|
||||
|
||||
describe('agentSkills CLI stdout', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skills-cli-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes raw <agent_skills> block to stdout — not JSON-wrapped', async () => {
|
||||
const skillDir = join(tmpDir, '.claude', 'skills', 'cli-skill');
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# cli-skill\n');
|
||||
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ agent_skills: { 'gsd-planner': '.claude/skills/cli-skill' } }),
|
||||
);
|
||||
|
||||
const stdout = execSync(
|
||||
`node "${CLI}" query --project-dir "${tmpDir}" agent-skills gsd-planner`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
expect(stdout).toBe(
|
||||
'<agent_skills>\nRead these user-configured skills:\n- @.claude/skills/cli-skill/SKILL.md\n</agent_skills>',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits empty output (no JSON null) when agent type is unmapped', async () => {
|
||||
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ agent_skills: { 'gsd-executor': ['.claude/skills/foo'] } }),
|
||||
);
|
||||
|
||||
const stdout = execSync(
|
||||
`node "${CLI}" query --project-dir "${tmpDir}" agent-skills gsd-planner`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
// Unmapped agent → empty string → CLI falls through to JSON (""), not raw
|
||||
// text. This is acceptable: workflows that embed an empty var are no-ops.
|
||||
// The important invariant is that a MAPPED agent never gets JSON-wrapped.
|
||||
expect(stdout.trim()).toBe('""');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,9 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
|
||||
if (validEntries.length === 0) return { data: '' };
|
||||
|
||||
const lines = validEntries.map((e) => `- @${e.ref}`).join('\n');
|
||||
return {
|
||||
data: `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`,
|
||||
};
|
||||
const block = `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`;
|
||||
// Signal the CLI dispatcher to write raw text — workflows embed the result
|
||||
// with `$(gsd-sdk query agent-skills …)` and need the XML block verbatim, not
|
||||
// a JSON-quoted string (see cli.ts QueryResult.format handling).
|
||||
return { data: block, format: 'text' };
|
||||
};
|
||||
|
||||
@@ -24,6 +24,16 @@ import { GSDError, ErrorClassification } from '../errors.js';
|
||||
/** Structured result returned by all query handlers. */
|
||||
export interface QueryResult<T = unknown> {
|
||||
data: T;
|
||||
/**
|
||||
* Output format hint for the CLI dispatcher.
|
||||
* `'text'` — write `data` as-is to stdout (no JSON-stringify).
|
||||
* `'json'` (default) — JSON-stringify as usual.
|
||||
*
|
||||
* Only meaningful when `data` is a string and the consumer is the CLI.
|
||||
* Used by `agent-skills` so workflows embedding `$(gsd-sdk query …)` receive
|
||||
* a raw `<agent_skills>` XML block rather than a JSON-quoted string.
|
||||
*/
|
||||
format?: 'json' | 'text';
|
||||
}
|
||||
|
||||
/** Signature for a query handler function. */
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { test, describe, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
/**
|
||||
* Regression test for #2687 — loadConfig must not emit "unknown config key"
|
||||
* warnings for keys that are registered in DYNAMIC_KEY_PATTERNS (e.g. review,
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
|
||||
463
tests/bug-2924-worktree-head-attachment.test.cjs
Normal file
463
tests/bug-2924-worktree-head-attachment.test.cjs
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* Regression tests for #2924: worktree HEAD attaches to a protected branch
|
||||
* (master/main) so agent commits land there; the workflow then "self-recovers"
|
||||
* by force-rewinding the protected branch via `git update-ref refs/heads/master`,
|
||||
* destroying concurrent work in multi-active scenarios.
|
||||
*
|
||||
* Fixes asserted by these tests (parsed structurally — not via raw content
|
||||
* regex/includes — per project test policy):
|
||||
*
|
||||
* 1. The <worktree_branch_check> block in execute-phase.md and quick.md
|
||||
* contains a HEAD-attachment assertion (symbolic-ref + protected-branch
|
||||
* check) that runs BEFORE any `git reset --hard`.
|
||||
* 2. The parallel-execution prompt in execute-phase.md and execute-plan.md
|
||||
* no longer mandates `--no-verify` as the default for worktree-mode commits.
|
||||
* 3. gsd-executor.md prohibits `git update-ref refs/heads/<protected>` as a
|
||||
* "recovery" path and includes a pre-commit HEAD assertion in the task
|
||||
* commit protocol.
|
||||
* 4. No workflow file in get-shit-done/workflows/ contains an unconditional
|
||||
* `git update-ref refs/heads/master` (or main/develop/trunk) call.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-phase.md');
|
||||
const EXECUTE_PLAN_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-plan.md');
|
||||
const QUICK_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'quick.md');
|
||||
const EXECUTOR_AGENT_PATH = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
|
||||
const GIT_INTEGRATION_PATH = path.join(REPO_ROOT, 'get-shit-done', 'references', 'git-integration.md');
|
||||
|
||||
/**
|
||||
* Extract the inner body of a named XML-like block (e.g. <worktree_branch_check>...</worktree_branch_check>)
|
||||
* from a markdown document. Returns null when not found.
|
||||
*/
|
||||
function extractNamedBlock(markdown, blockName) {
|
||||
const open = `<${blockName}>`;
|
||||
const close = `</${blockName}>`;
|
||||
const start = markdown.indexOf(open);
|
||||
if (start === -1) return null;
|
||||
const end = markdown.indexOf(close, start + open.length);
|
||||
if (end === -1) return null;
|
||||
return markdown.slice(start + open.length, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all fenced code blocks (```...```) from a markdown chunk.
|
||||
* Returns array of { lang, body } objects.
|
||||
*/
|
||||
function extractFencedCodeBlocks(markdown) {
|
||||
const blocks = [];
|
||||
const lines = markdown.split('\n');
|
||||
let inFence = false;
|
||||
let fenceLang = '';
|
||||
let buffer = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('```')) {
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceLang = trimmed.slice(3).trim();
|
||||
buffer = [];
|
||||
} else {
|
||||
blocks.push({ lang: fenceLang, body: buffer.join('\n') });
|
||||
inFence = false;
|
||||
fenceLang = '';
|
||||
buffer = [];
|
||||
}
|
||||
} else if (inFence) {
|
||||
buffer.push(line);
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a shell-like script into individual statements (split on `;`, `&&`, `||`, newlines)
|
||||
* and return commands as arrays of word tokens. Handles `$(cmd ...)` command substitution
|
||||
* and `VAR=$(cmd ...)` assignments by extracting the inner command. This is intentionally
|
||||
* simple — adequate for asserting on the presence of well-known git invocations.
|
||||
*/
|
||||
function shellStatements(script) {
|
||||
const statements = [];
|
||||
const lines = script.split('\n');
|
||||
for (let raw of lines) {
|
||||
const line = raw.replace(/#.*$/, '').trim();
|
||||
if (!line) continue;
|
||||
// Split on shell statement separators
|
||||
const parts = line.split(/(?:&&|\|\||;)/);
|
||||
for (const part of parts) {
|
||||
let trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
// Strip leading `VAR=` assignments so the substituted command surfaces as cmd[0].
|
||||
// Then unwrap `$(...)` command substitution.
|
||||
const assignMatch = trimmed.match(/^[A-Za-z_][A-Za-z0-9_]*=(.*)$/);
|
||||
if (assignMatch) trimmed = assignMatch[1];
|
||||
const subMatch = trimmed.match(/^\$\((.*?)\)?$/);
|
||||
if (subMatch) trimmed = subMatch[1];
|
||||
// Also handle leading `$(` without closing paren (paren may have been split off)
|
||||
if (trimmed.startsWith('$(')) trimmed = trimmed.slice(2);
|
||||
// Strip trailing closing parens left over from substitution
|
||||
trimmed = trimmed.replace(/\)+\s*$/, '').trim();
|
||||
if (!trimmed) continue;
|
||||
// Strip surrounding quotes on the leading word
|
||||
statements.push(trimmed.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the line index of the first command matching a predicate.
|
||||
* Returns -1 when not found.
|
||||
*/
|
||||
function findCommandIndex(statements, predicate) {
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
if (predicate(statements[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
describe('bug #2924: worktree HEAD attachment + destructive recovery', () => {
|
||||
describe('execute-phase.md worktree_branch_check', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'worktree_branch_check');
|
||||
|
||||
test('block exists', () => {
|
||||
assert.ok(block, 'execute-phase.md must contain a <worktree_branch_check> block');
|
||||
});
|
||||
|
||||
test('block invokes `git symbolic-ref` to inspect HEAD attachment', () => {
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
|
||||
const idx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
idx, -1,
|
||||
'worktree_branch_check must run `git symbolic-ref ... HEAD` to verify HEAD attachment before any reset'
|
||||
);
|
||||
});
|
||||
|
||||
test('HEAD-attachment assertion runs BEFORE `git reset --hard`', () => {
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
|
||||
const symbolicRefIdx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
|
||||
);
|
||||
const resetHardIdx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'reset' && cmd.includes('--hard')
|
||||
);
|
||||
assert.notStrictEqual(symbolicRefIdx, -1, 'symbolic-ref check must exist');
|
||||
assert.notStrictEqual(resetHardIdx, -1, 'reset --hard must exist');
|
||||
assert.ok(
|
||||
symbolicRefIdx < resetHardIdx,
|
||||
'HEAD attachment assertion (symbolic-ref) must precede `git reset --hard` so a stale HEAD never moves a protected branch'
|
||||
);
|
||||
});
|
||||
|
||||
test('block names protected branches that must NOT be the agent branch', () => {
|
||||
// The protected-branch list must be enforced by name. Parse it out of the
|
||||
// shell scripts and verify required names are present.
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const scripts = codeBlocks.map(({ body }) => body).join('\n');
|
||||
// Look for an assignment whose value is a regex/list naming protected refs.
|
||||
// Acceptable forms: PROTECTED_BRANCHES_RE='...' or grep -Eq '^(main|...)$'
|
||||
// Parse the alternation list out of the grep -E pattern so we assert
|
||||
// structurally on the protected-branch enumeration rather than via
|
||||
// raw substring matching (release/* contains regex-special chars and
|
||||
// can't be safely tested with `\b...\b`).
|
||||
const altMatch = scripts.match(/grep\s+-Eq?\s+'\^\(([^)]+)\)\$'/);
|
||||
assert.ok(
|
||||
altMatch,
|
||||
'worktree_branch_check must contain a `grep -Eq` protected-branch alternation pattern'
|
||||
);
|
||||
const branches = altMatch[1].split('|').map((b) => b.trim());
|
||||
const required = ['main', 'master', 'develop', 'trunk', 'release/.*'];
|
||||
for (const name of required) {
|
||||
assert.ok(
|
||||
branches.includes(name),
|
||||
`worktree_branch_check protected-branch alternation must include '${name}' (found: ${branches.join(', ')})`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('block enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const scripts = codeBlocks.map(({ body }) => body).join('\n');
|
||||
// Allow-list must reference the canonical Claude Code worktree-agent-<id>
|
||||
// namespace via a regex assertion (grep -Eq '^worktree-agent-...').
|
||||
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
|
||||
assert.ok(
|
||||
allowListRe.test(scripts),
|
||||
'worktree_branch_check must enforce a positive allow-list matching ^worktree-agent-* (#2924 hardening)'
|
||||
);
|
||||
});
|
||||
|
||||
test('block forbids `git update-ref` self-recovery in its guidance text', () => {
|
||||
// The forbidding statement is documentation text, not a shell command,
|
||||
// so structural shell parsing does not apply. Verify the prohibition
|
||||
// appears as standalone guidance somewhere in the block.
|
||||
assert.ok(
|
||||
block.includes('update-ref'),
|
||||
'worktree_branch_check must explicitly forbid `git update-ref` self-recovery'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute-phase.md no longer defaults to --no-verify in parallel mode', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'parallel_execution');
|
||||
|
||||
test('parallel_execution block exists', () => {
|
||||
assert.ok(block, 'execute-phase.md must contain a <parallel_execution> block');
|
||||
});
|
||||
|
||||
test('parallel_execution does NOT instruct agents to use --no-verify by default', () => {
|
||||
// Tokenize the block as plain words and look for an unconditional
|
||||
// imperative naming `--no-verify`. The acceptable presence is in a
|
||||
// negated/opt-out context (e.g. "Do NOT pass --no-verify"); reject
|
||||
// any sentence whose first verb is "Use --no-verify".
|
||||
const sentences = block
|
||||
.replace(/\n+/g, ' ')
|
||||
.split(/(?<=[.!?])\s+/);
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence.includes('--no-verify')) continue;
|
||||
const lower = sentence.toLowerCase();
|
||||
const isProhibition =
|
||||
/\b(do not|don't|never|no longer)\b/.test(lower) ||
|
||||
/\bopt[\s-]?out\b/.test(lower) ||
|
||||
/\bopt[\s-]?in\b/.test(lower) ||
|
||||
/\bif\b/.test(lower);
|
||||
assert.ok(
|
||||
isProhibition,
|
||||
`parallel_execution sentence appears to mandate --no-verify by default: "${sentence.trim()}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute-plan.md no longer mandates --no-verify for parallel executor', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'precommit_failure_handling');
|
||||
test('precommit_failure_handling block exists', () => {
|
||||
assert.ok(block, 'execute-plan.md must contain a <precommit_failure_handling> block');
|
||||
});
|
||||
|
||||
test('parallel-executor sub-section does not unconditionally mandate --no-verify', () => {
|
||||
// Locate the parallel-executor sub-section heading and parse the
|
||||
// sentences under it.
|
||||
const headingIdx = block.indexOf('parallel executor');
|
||||
assert.notStrictEqual(headingIdx, -1, 'must contain a parallel-executor sub-section');
|
||||
const endIdx = block.indexOf('**If running as the sole', headingIdx);
|
||||
assert.notStrictEqual(endIdx, -1, 'parallel-executor sub-section terminator must exist');
|
||||
const subBlock = block.slice(headingIdx, endIdx);
|
||||
assert.ok(subBlock.length > 0, 'sub-section must have content');
|
||||
const sentences = subBlock.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence.includes('--no-verify')) continue;
|
||||
const lower = sentence.toLowerCase();
|
||||
const isProhibition =
|
||||
/\b(do not|don't|never|no longer)\b/.test(lower) ||
|
||||
/\bopt[\s-]?out\b/.test(lower) ||
|
||||
/\bopt[\s-]?in\b/.test(lower) ||
|
||||
/\bif\b/.test(lower);
|
||||
assert.ok(
|
||||
isProhibition,
|
||||
`parallel-executor guidance sentence appears to mandate --no-verify: "${sentence.trim()}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick.md worktree_branch_check', () => {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'worktree_branch_check');
|
||||
|
||||
test('block exists', () => {
|
||||
assert.ok(block, 'quick.md must contain a <worktree_branch_check> block');
|
||||
});
|
||||
|
||||
test('block references `git symbolic-ref` for HEAD attachment assertion', () => {
|
||||
// quick.md uses inline `git symbolic-ref ... HEAD` rather than a fenced
|
||||
// block, so search the block as a token stream of statements.
|
||||
const statements = shellStatements(block);
|
||||
const idx = findCommandIndex(statements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
idx, -1,
|
||||
'quick.md worktree_branch_check must run `git symbolic-ref ... HEAD`'
|
||||
);
|
||||
});
|
||||
|
||||
test('HEAD assertion precedes `git reset --hard`', () => {
|
||||
const symbolicRefByteIdx = block.indexOf('symbolic-ref');
|
||||
const resetHardByteIdx = block.indexOf('reset --hard');
|
||||
assert.notStrictEqual(symbolicRefByteIdx, -1);
|
||||
assert.notStrictEqual(resetHardByteIdx, -1);
|
||||
assert.ok(
|
||||
symbolicRefByteIdx < resetHardByteIdx,
|
||||
'symbolic-ref HEAD assertion must appear before `git reset --hard` in quick.md worktree_branch_check'
|
||||
);
|
||||
});
|
||||
|
||||
test('block forbids `git update-ref` self-recovery', () => {
|
||||
assert.ok(
|
||||
block.includes('update-ref'),
|
||||
'quick.md worktree_branch_check must explicitly forbid `git update-ref` self-recovery'
|
||||
);
|
||||
});
|
||||
|
||||
test('block enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
|
||||
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
|
||||
assert.ok(
|
||||
allowListRe.test(block),
|
||||
'quick.md worktree_branch_check must enforce a positive allow-list matching ^worktree-agent-* (#2924 hardening)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick.md pre-dispatch plan commit no longer hard-codes --no-verify', () => {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
const codeBlocks = extractFencedCodeBlocks(content);
|
||||
// Find the bash block containing the pre-dispatch plan commit
|
||||
const target = codeBlocks.find(({ body }) =>
|
||||
body.includes('pre-dispatch plan') && body.includes('git commit')
|
||||
);
|
||||
test('pre-dispatch plan commit block exists', () => {
|
||||
assert.ok(target, 'quick.md must contain the pre-dispatch plan commit block');
|
||||
});
|
||||
|
||||
test('pre-dispatch plan commit gates --no-verify behind a config flag', () => {
|
||||
// The block must contain BOTH a `git commit` without --no-verify AND
|
||||
// gate any --no-verify variant inside an `if` block reading a config
|
||||
// value (workflow.worktree_skip_hooks).
|
||||
const statements = shellStatements(target.body);
|
||||
const noVerifyCommits = statements.filter((cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'commit' && cmd.includes('--no-verify')
|
||||
);
|
||||
const cleanCommits = statements.filter((cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'commit' && !cmd.includes('--no-verify')
|
||||
);
|
||||
assert.ok(
|
||||
cleanCommits.length >= 1,
|
||||
'must include at least one `git commit` without --no-verify (default path)'
|
||||
);
|
||||
// If --no-verify still appears, the block must reference the opt-in flag.
|
||||
if (noVerifyCommits.length > 0) {
|
||||
assert.ok(
|
||||
target.body.includes('worktree_skip_hooks'),
|
||||
'--no-verify commits must be gated behind workflow.worktree_skip_hooks config flag'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-executor.md prohibits update-ref self-recovery', () => {
|
||||
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'destructive_git_prohibition');
|
||||
|
||||
test('destructive_git_prohibition block exists', () => {
|
||||
assert.ok(block, 'gsd-executor.md must contain a <destructive_git_prohibition> block');
|
||||
});
|
||||
|
||||
test('block prohibits `git update-ref refs/heads/<protected>`', () => {
|
||||
assert.ok(
|
||||
block.includes('update-ref'),
|
||||
'destructive_git_prohibition must enumerate `git update-ref` as a prohibited command'
|
||||
);
|
||||
assert.ok(
|
||||
block.includes('protected') || block.includes('main') || block.includes('master'),
|
||||
'destructive_git_prohibition must call out protected branches in the update-ref prohibition'
|
||||
);
|
||||
});
|
||||
|
||||
test('block references issue #2924', () => {
|
||||
assert.ok(
|
||||
block.includes('#2924'),
|
||||
'destructive_git_prohibition should cite #2924 as the source of the update-ref prohibition'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-executor.md task_commit_protocol enforces worktree-agent-* allow-list', () => {
|
||||
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'task_commit_protocol');
|
||||
|
||||
test('task_commit_protocol block exists', () => {
|
||||
assert.ok(block, 'gsd-executor.md must contain a <task_commit_protocol> block');
|
||||
});
|
||||
|
||||
test('step 0 enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const scripts = codeBlocks.map(({ body }) => body).join('\n');
|
||||
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
|
||||
assert.ok(
|
||||
allowListRe.test(scripts),
|
||||
'task_commit_protocol step 0 must enforce a positive allow-list matching ^worktree-agent-* in addition to the protected-ref deny-list (#2924 hardening)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no workflow file performs unconditional update-ref on a protected branch', () => {
|
||||
const workflowsDir = path.join(REPO_ROOT, 'get-shit-done', 'workflows');
|
||||
const workflowFiles = fs
|
||||
.readdirSync(workflowsDir, { recursive: true })
|
||||
.filter((f) => typeof f === 'string' && f.endsWith('.md'))
|
||||
.map((f) => path.join(workflowsDir, f));
|
||||
|
||||
for (const filePath of workflowFiles) {
|
||||
test(`${path.basename(filePath)} contains no update-ref of a protected ref`, () => {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const blocks = extractFencedCodeBlocks(content);
|
||||
for (const { body } of blocks) {
|
||||
const statements = shellStatements(body);
|
||||
for (const cmd of statements) {
|
||||
if (cmd[0] !== 'git') continue;
|
||||
if (cmd[1] !== 'update-ref') continue;
|
||||
// Reject any update-ref that targets a protected ref.
|
||||
const target = cmd[2] || '';
|
||||
const protectedRe = /^refs\/heads\/(main|master|develop|trunk|release\/.+)$/;
|
||||
assert.ok(
|
||||
!protectedRe.test(target),
|
||||
`${path.basename(filePath)} contains forbidden 'git update-ref ${target}' (#2924)`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('git-integration.md guidance reflects new default', () => {
|
||||
const content = fs.readFileSync(GIT_INTEGRATION_PATH, 'utf-8');
|
||||
test('parallel-agents guidance no longer mandates --no-verify', () => {
|
||||
// Find the parallel-agents callout and parse its sentences.
|
||||
const idx = content.indexOf('Parallel agents');
|
||||
assert.notStrictEqual(idx, -1, 'must contain a "Parallel agents" callout');
|
||||
const section = content.slice(idx);
|
||||
const endMatch = section.slice(1).match(/\n#{1,6}\s/);
|
||||
assert.ok(endMatch, 'Parallel agents section must terminate at the next heading');
|
||||
const tail = section.slice(0, 1 + endMatch.index);
|
||||
const sentences = tail.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence.includes('--no-verify')) continue;
|
||||
const lower = sentence.toLowerCase();
|
||||
const isProhibition =
|
||||
/\b(do not|don't|never|no longer)\b/.test(lower) ||
|
||||
/\bopt[\s-]?out\b/.test(lower) ||
|
||||
/\bopt[\s-]?in\b/.test(lower) ||
|
||||
/\bif\b/.test(lower);
|
||||
assert.ok(
|
||||
isProhibition,
|
||||
`git-integration.md "Parallel agents" sentence appears to mandate --no-verify: "${sentence.trim()}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
177
tests/bug-2942-detect-custom-skills.test.cjs
Normal file
177
tests/bug-2942-detect-custom-skills.test.cjs
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* GSD Tools Tests — detect-custom-files misses skills/ directory (#2942)
|
||||
*
|
||||
* After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed root.
|
||||
* GSD_MANAGED_DIRS was missing 'skills', so user-added skill directories like
|
||||
* skills/custom-skill/SKILL.md were never walked and got silently destroyed
|
||||
* during /gsd-update.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
|
||||
* Each entry is also written to disk so the directory structure exists.
|
||||
*/
|
||||
function writeManifest(configDir, files) {
|
||||
const manifest = {
|
||||
version: '1.39.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
files: {}
|
||||
};
|
||||
for (const [relPath, content] of Object.entries(files)) {
|
||||
const fullPath = path.join(configDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
manifest.files[relPath] = sha256(content);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'gsd-file-manifest.json'),
|
||||
JSON.stringify(manifest, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a file inside configDir (creating parent dirs), but do NOT add it to the manifest.
|
||||
*/
|
||||
function writeCustomFile(configDir, relPath, content) {
|
||||
const fullPath = path.join(configDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIRS (#2942)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-2942-skills-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Test 1: detects custom skill in skills/<name>/SKILL.md
|
||||
test('detects custom skill file at skills/<name>/SKILL.md', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
// User-added custom skill — NOT in manifest
|
||||
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# My Custom Skill\n');
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
||||
assert.ok(json.custom_count >= 1, `custom_count should be >= 1, got ${json.custom_count}`);
|
||||
assert.ok(
|
||||
json.custom_files.includes('skills/test-custom/SKILL.md'),
|
||||
`skills/test-custom/SKILL.md should be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
});
|
||||
|
||||
// Test 2: does not flag GSD-owned skills as custom (manifest-tracked path NOT in custom_files)
|
||||
test('does not flag GSD-owned skill as custom when it is tracked in manifest', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
// No extra files — only the manifest-tracked skill exists
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
||||
assert.ok(
|
||||
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
|
||||
`GSD-owned skill should NOT be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
});
|
||||
|
||||
// Test 3: regression guard — still detects custom files in get-shit-done/workflows/
|
||||
test('regression: still detects custom files in get-shit-done/workflows/', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'get-shit-done/workflows/plan-phase.md': '# Plan Phase\n',
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
writeCustomFile(tmpDir, 'get-shit-done/workflows/custom-workflow.md', '# My Custom Workflow\n');
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
json.custom_files.includes('get-shit-done/workflows/custom-workflow.md'),
|
||||
`custom workflow should still be detected; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
});
|
||||
|
||||
// Test 4: custom_count matches custom_files.length
|
||||
test('custom_count matches custom_files.length when multiple custom skills exist', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# Custom Skill One\n');
|
||||
writeCustomFile(tmpDir, 'skills/another-custom/SKILL.md', '# Custom Skill Two\n');
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(
|
||||
json.custom_count,
|
||||
json.custom_files.length,
|
||||
`custom_count (${json.custom_count}) should equal custom_files.length (${json.custom_files.length})`
|
||||
);
|
||||
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom skill files');
|
||||
});
|
||||
|
||||
// Test 5: manifest_found: true when manifest is present
|
||||
test('manifest_found is true when manifest is present', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.manifest_found, true, 'manifest_found should be true');
|
||||
});
|
||||
});
|
||||
132
tests/bug-2943-config-get-context-window-default.test.cjs
Normal file
132
tests/bug-2943-config-get-context-window-default.test.cjs
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Regression test for bug #2943
|
||||
*
|
||||
* `gsd-tools.cjs config-get context_window` (and the SDK equivalent) threw
|
||||
* "Key not found: context_window" when the key was absent from config.json,
|
||||
* even though context_window has a documented schema default of 200000.
|
||||
*
|
||||
* Fix: `cmdConfigGet` in bin/lib/config.cjs now consults a SCHEMA_DEFAULTS map
|
||||
* before emitting "Key not found", so schema-defaulted keys always return the
|
||||
* default value (exit 0) when not explicitly set in the project config.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
describe('bug-2943: config-get returns schema default for context_window', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-2943-'));
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Run config-get with optional extra args. Returns { exitCode, stdout, stderr }.
|
||||
* Uses --raw so we get the plain scalar value, not JSON-wrapped.
|
||||
*/
|
||||
function runConfigGet(keyPath, extraArgs = []) {
|
||||
const args = [GSD_TOOLS, 'config-get', keyPath, '--raw', '--cwd', tmpDir, ...extraArgs];
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, args, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
exitCode = err.status ?? 1;
|
||||
stdout = err.stdout?.toString() ?? '';
|
||||
stderr = err.stderr?.toString() ?? '';
|
||||
}
|
||||
return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
|
||||
}
|
||||
|
||||
test('returns "200000" (exit 0) when context_window absent from config.json', () => {
|
||||
// Fixture A: config with unrelated keys, no context_window
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { auto_advance: false } })
|
||||
);
|
||||
|
||||
const result = runConfigGet('context_window');
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0 (schema default applied)');
|
||||
assert.strictEqual(result.stdout, '200000', 'should return schema default of 200000');
|
||||
});
|
||||
|
||||
test('returns configured value when context_window is explicitly set', () => {
|
||||
// Fixture B: config has context_window: 1000000
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ context_window: 1000000 })
|
||||
);
|
||||
|
||||
const result = runConfigGet('context_window');
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0 for found key');
|
||||
assert.strictEqual(result.stdout, '1000000', 'should return configured value not schema default');
|
||||
});
|
||||
|
||||
test('--default flag overrides schema default', () => {
|
||||
// config has context_window but we pass --default with a different value —
|
||||
// when key IS present, real value wins over any default
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { auto_advance: false } })
|
||||
);
|
||||
|
||||
const result = runConfigGet('context_window', ['--default', '123456']);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default provided');
|
||||
assert.strictEqual(result.stdout, '123456', 'should return the --default value, not schema default');
|
||||
});
|
||||
|
||||
test('errors with "Key not found" (exit 1) for an unknown absent key — no regression', () => {
|
||||
// An unrecognised key with no schema default still errors as before
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { auto_advance: false } })
|
||||
);
|
||||
|
||||
const result = runConfigGet('totally_unknown_key_xyz');
|
||||
|
||||
assert.strictEqual(result.exitCode, 1, 'should exit 1 for unknown absent key');
|
||||
assert.ok(
|
||||
result.stderr.includes('Key not found') || result.stdout.includes('Key not found'),
|
||||
`expected "Key not found" in output, got stderr="${result.stderr}" stdout="${result.stdout}"`
|
||||
);
|
||||
});
|
||||
|
||||
test('--default flag still works for arbitrary absent keys', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({})
|
||||
);
|
||||
|
||||
const result = runConfigGet('some.missing.key', ['--default', '200000']);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default supplied');
|
||||
assert.strictEqual(result.stdout, '200000', 'should return the explicit --default value');
|
||||
});
|
||||
});
|
||||
162
tests/bug-2948-spike-wrap-up-dispatch.test.cjs
Normal file
162
tests/bug-2948-spike-wrap-up-dispatch.test.cjs
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Regression test for bug #2948
|
||||
*
|
||||
* `/gsd-spike --wrap-up` was silently no-oping because:
|
||||
* 1. `commands/gsd/spike.md` listed `--wrap-up` as a flag but had no dispatch block.
|
||||
* 2. `workflows/spike.md` still referenced the deleted `/gsd-spike-wrap-up` entry-point
|
||||
* instead of the correct `/gsd-spike --wrap-up` form.
|
||||
*
|
||||
* Fix:
|
||||
* - `commands/gsd/spike.md` now has a dispatch block that routes `--wrap-up` to
|
||||
* spike-wrap-up.md, and spike-wrap-up.md is listed in execution_context so the
|
||||
* runtime can find it.
|
||||
* - `workflows/spike.md` companion references updated from `/gsd-spike-wrap-up` to
|
||||
* `/gsd-spike --wrap-up`.
|
||||
*/
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// commands/gsd/*.md files ARE what the runtime loads — testing their
|
||||
// frontmatter and section content tests the deployed system-prompt contract.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SPIKE_CMD_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'spike.md');
|
||||
const SPIKE_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'spike.md');
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter + body from a markdown file.
|
||||
* Returns a shallow { key: value } map of frontmatter fields plus `_body`.
|
||||
* Mirrors the parseFrontmatter utility used in enh-2792-namespace-skills.test.cjs.
|
||||
*/
|
||||
function parseFrontmatter(content) {
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
// Frontmatter must start at the very first line; a mid-file '---' is a
|
||||
// horizontal rule, not a frontmatter delimiter.
|
||||
if (lines[0]?.trim() !== '---') {
|
||||
return { _body: content };
|
||||
}
|
||||
|
||||
let closeIdx = -1;
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
if (lines[i].trim() === '---') {
|
||||
closeIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(closeIdx !== -1, 'frontmatter block must be delimited by --- on its own lines');
|
||||
const fm = {};
|
||||
for (const line of lines.slice(1, closeIdx)) {
|
||||
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
const [, key, raw] = m;
|
||||
fm[key] = raw.trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
fm._body = lines.slice(closeIdx + 1).join('\n');
|
||||
return fm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text content of a named XML-like section from a markdown body.
|
||||
* Returns null if the section is absent.
|
||||
*/
|
||||
function extractSection(body, tag) {
|
||||
const open = `<${tag}>`;
|
||||
const close = `</${tag}>`;
|
||||
const start = body.indexOf(open);
|
||||
const end = body.indexOf(close);
|
||||
if (start === -1 || end === -1) return null;
|
||||
return body.slice(start + open.length, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the @-prefixed workflow references out of an execution_context section.
|
||||
* Returns an array of resolved reference strings (@ stripped).
|
||||
*/
|
||||
function parseExecutionContextRefs(section) {
|
||||
return section
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.startsWith('@'))
|
||||
.map(l => l.slice(1).trim());
|
||||
}
|
||||
|
||||
describe('bug-2948: /gsd-spike --wrap-up dispatch wiring', () => {
|
||||
describe('commands/gsd/spike.md — frontmatter and section contract', () => {
|
||||
test('spike.md command file exists and has valid frontmatter', () => {
|
||||
assert.ok(fs.existsSync(SPIKE_CMD_PATH), 'commands/gsd/spike.md should exist');
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
|
||||
assert.ok(fm.name, 'frontmatter must have a name field');
|
||||
});
|
||||
|
||||
test('argument-hint frontmatter field advertises --wrap-up flag', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
|
||||
assert.ok(
|
||||
fm['argument-hint'] && fm['argument-hint'].includes('--wrap-up'),
|
||||
`argument-hint must advertise --wrap-up; got: "${fm['argument-hint']}"`
|
||||
);
|
||||
});
|
||||
|
||||
test('execution_context section includes spike-wrap-up workflow reference', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
|
||||
const execSection = extractSection(fm._body, 'execution_context');
|
||||
assert.ok(execSection !== null, 'spike.md must have an <execution_context> section');
|
||||
const refs = parseExecutionContextRefs(execSection);
|
||||
assert.ok(
|
||||
refs.some(r => r.includes('spike-wrap-up')),
|
||||
`execution_context must declare a spike-wrap-up reference so the runtime can load the workflow; ` +
|
||||
`declared refs: ${JSON.stringify(refs)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('process section dispatches first-token --wrap-up to spike-wrap-up workflow', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
|
||||
const processSection = extractSection(fm._body, 'process');
|
||||
assert.ok(processSection, 'spike.md must have a <process> section');
|
||||
|
||||
const rules = processSection
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const wrapUpRule = rules.find(line => line.startsWith('- If it is `--wrap-up`:'));
|
||||
const fallbackRule = rules.find(line => line.startsWith('- Otherwise:'));
|
||||
|
||||
assert.ok(
|
||||
wrapUpRule && wrapUpRule.includes('strip the flag') && wrapUpRule.includes('spike-wrap-up'),
|
||||
'process must define a --wrap-up branch that strips the flag and routes to spike-wrap-up'
|
||||
);
|
||||
assert.ok(
|
||||
fallbackRule && fallbackRule.includes('spike workflow'),
|
||||
'process must define an Otherwise fallback to the normal spike workflow'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get-shit-done/workflows/spike.md — companion references', () => {
|
||||
test('spike workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(SPIKE_WORKFLOW_PATH), 'get-shit-done/workflows/spike.md should exist');
|
||||
});
|
||||
|
||||
test('does NOT reference the deleted /gsd-spike-wrap-up entry-point', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8'));
|
||||
assert.ok(
|
||||
!fm._body.includes('/gsd-spike-wrap-up'),
|
||||
'workflows/spike.md must not reference the deleted /gsd-spike-wrap-up command; use /gsd-spike --wrap-up instead'
|
||||
);
|
||||
});
|
||||
|
||||
test('references /gsd-spike --wrap-up as the canonical wrap-up invocation', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8'));
|
||||
assert.ok(
|
||||
fm._body.includes('/gsd-spike --wrap-up'),
|
||||
'workflows/spike.md must reference /gsd-spike --wrap-up as the canonical wrap-up command'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
61
tests/bug-2949-sketch-wrap-up-dispatch.test.cjs
Normal file
61
tests/bug-2949-sketch-wrap-up-dispatch.test.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* GSD Tests — /gsd-sketch --wrap-up silently no-ops (#2949)
|
||||
*
|
||||
* The --wrap-up flag was documented in commands/gsd/sketch.md but never dispatched.
|
||||
* The sketch-wrap-up.md micro-skill entry point was deleted in #2790 and the dispatch
|
||||
* wiring was never added to the command or workflow.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SKETCH_COMMAND = path.join(ROOT, 'commands/gsd/sketch.md');
|
||||
const SKETCH_WORKFLOW = path.join(ROOT, 'get-shit-done/workflows/sketch.md');
|
||||
|
||||
describe('bug-2949: sketch --wrap-up dispatch wiring', () => {
|
||||
test('commands/gsd/sketch.md contains --wrap-up dispatch logic', () => {
|
||||
const content = fs.readFileSync(SKETCH_COMMAND, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('--wrap-up'),
|
||||
'sketch.md should contain --wrap-up dispatch logic'
|
||||
);
|
||||
// The dispatch should route to sketch-wrap-up workflow
|
||||
assert.ok(
|
||||
content.includes('sketch-wrap-up'),
|
||||
'sketch.md should reference sketch-wrap-up in dispatch logic'
|
||||
);
|
||||
});
|
||||
|
||||
test('commands/gsd/sketch.md has sketch-wrap-up in execution_context section', () => {
|
||||
const content = fs.readFileSync(SKETCH_COMMAND, 'utf8');
|
||||
// Find execution_context block
|
||||
const execCtxMatch = content.match(/<execution_context>([\s\S]*?)<\/execution_context>/);
|
||||
assert.ok(execCtxMatch, 'sketch.md must have an <execution_context> block');
|
||||
const execCtx = execCtxMatch[1];
|
||||
assert.ok(
|
||||
execCtx.includes('sketch-wrap-up'),
|
||||
`execution_context block should include sketch-wrap-up workflow; got: ${execCtx}`
|
||||
);
|
||||
});
|
||||
|
||||
test('workflows/sketch.md does NOT contain old /gsd-sketch-wrap-up form', () => {
|
||||
const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8');
|
||||
assert.ok(
|
||||
!content.includes('/gsd-sketch-wrap-up'),
|
||||
'workflows/sketch.md must not reference the old /gsd-sketch-wrap-up command'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflows/sketch.md DOES contain new /gsd-sketch --wrap-up form', () => {
|
||||
const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('/gsd-sketch --wrap-up'),
|
||||
'workflows/sketch.md should reference /gsd-sketch --wrap-up (the new form)'
|
||||
);
|
||||
});
|
||||
});
|
||||
140
tests/bug-2950-stale-command-refs.test.cjs
Normal file
140
tests/bug-2950-stale-command-refs.test.cjs
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Bug #2950: Stale deleted command references in workflow files
|
||||
*
|
||||
* Multiple workflow files referenced command names removed in #2790
|
||||
* (gsd-add-phase, gsd-insert-phase, gsd-remove-phase, gsd-add-todo,
|
||||
* gsd-set-profile, gsd-settings-integrations, gsd-settings-advanced,
|
||||
* gsd-spike-wrap-up, gsd-sketch-wrap-up, gsd-code-review-fix).
|
||||
*
|
||||
* Fix: Update every occurrence to the new consolidated forms:
|
||||
* /gsd-phase (no flag | --insert | --remove)
|
||||
* /gsd-capture
|
||||
* /gsd-config (--profile | --integrations | --advanced)
|
||||
* /gsd-spike --wrap-up
|
||||
* /gsd-sketch --wrap-up
|
||||
* /gsd-code-review --fix
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'get-shit-done', 'workflows');
|
||||
|
||||
function read(filename) {
|
||||
return fs.readFileSync(path.join(WORKFLOWS_DIR, filename), 'utf-8');
|
||||
}
|
||||
|
||||
// Deleted command names that must not appear anywhere in the fixed files.
|
||||
const DELETED_COMMANDS = [
|
||||
'/gsd-add-phase',
|
||||
'/gsd-insert-phase',
|
||||
'/gsd-remove-phase',
|
||||
'/gsd-add-todo',
|
||||
'/gsd-set-profile',
|
||||
'/gsd-settings-integrations',
|
||||
'/gsd-settings-advanced',
|
||||
'/gsd-spike-wrap-up',
|
||||
'/gsd-sketch-wrap-up',
|
||||
'/gsd-code-review-fix',
|
||||
];
|
||||
|
||||
// Per-file assertions: [file, deletedCmd, newForm]
|
||||
const FILE_ASSERTIONS = [
|
||||
// help.md
|
||||
['help.md', '/gsd-add-phase', '/gsd-phase "Add admin dashboard"'],
|
||||
['help.md', '/gsd-insert-phase', '/gsd-phase --insert 7 "Fix critical auth bug"'],
|
||||
['help.md', '/gsd-remove-phase', '/gsd-phase --remove 17'],
|
||||
['help.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
['help.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
['help.md', '/gsd-add-todo', '/gsd-capture'],
|
||||
['help.md', '/gsd-set-profile', '/gsd-config --profile budget'],
|
||||
|
||||
// do.md
|
||||
['do.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
['do.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
['do.md', '/gsd-add-phase', '/gsd-phase'],
|
||||
['do.md', '/gsd-add-todo', '/gsd-capture'],
|
||||
|
||||
// settings.md
|
||||
['settings.md', '/gsd-code-review-fix', '/gsd-code-review --fix'],
|
||||
['settings.md', '/gsd-settings-integrations', '/gsd-config --integrations'],
|
||||
['settings.md', '/gsd-set-profile', '/gsd-config --profile'],
|
||||
['settings.md', '/gsd-settings-advanced', '/gsd-config --advanced'],
|
||||
|
||||
// discuss-phase.md
|
||||
['discuss-phase.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
['discuss-phase.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
|
||||
// new-project.md
|
||||
['new-project.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
['new-project.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
|
||||
// plan-phase.md
|
||||
['plan-phase.md', '/gsd-insert-phase', '/gsd-phase --insert'],
|
||||
|
||||
// spike.md
|
||||
['spike.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
|
||||
// sketch.md
|
||||
['sketch.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
];
|
||||
|
||||
describe('bug #2950: stale deleted-command references removed from workflow files', () => {
|
||||
// Build a map of file → content to avoid re-reading
|
||||
const files = [...new Set(FILE_ASSERTIONS.map(([f]) => f))];
|
||||
const contentMap = {};
|
||||
for (const f of files) {
|
||||
contentMap[f] = read(f);
|
||||
}
|
||||
|
||||
// For each (file, deletedCmd) pair, assert the old name is absent
|
||||
for (const [file, deletedCmd] of FILE_ASSERTIONS) {
|
||||
test(`${file}: does not contain deleted command "${deletedCmd}"`, () => {
|
||||
const content = contentMap[file];
|
||||
assert.ok(
|
||||
!content.includes(deletedCmd),
|
||||
`${file} still contains deleted command "${deletedCmd}" — update to new form`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// For each (file, deletedCmd, newForm) triple, assert the new form is present
|
||||
for (const [file, , newForm] of FILE_ASSERTIONS) {
|
||||
test(`${file}: contains new form "${newForm}"`, () => {
|
||||
const content = contentMap[file];
|
||||
assert.ok(
|
||||
content.includes(newForm),
|
||||
`${file} is missing expected new form "${newForm}"`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Blanket check: no affected workflow file contains any of the deleted command names
|
||||
// (catches any we might have missed in per-file assertions above)
|
||||
const affectedFiles = [
|
||||
'help.md',
|
||||
'do.md',
|
||||
'settings.md',
|
||||
'discuss-phase.md',
|
||||
'new-project.md',
|
||||
'plan-phase.md',
|
||||
'spike.md',
|
||||
'sketch.md',
|
||||
];
|
||||
|
||||
for (const file of affectedFiles) {
|
||||
const content = read(file);
|
||||
for (const deleted of DELETED_COMMANDS) {
|
||||
test(`${file}: blanket check — "${deleted}" not present`, () => {
|
||||
assert.ok(
|
||||
!content.includes(deleted),
|
||||
`${file} contains deleted command "${deleted}"`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
165
tests/bug-2954-help-md-slash-command-stubs.test.cjs
Normal file
165
tests/bug-2954-help-md-slash-command-stubs.test.cjs
Normal file
@@ -0,0 +1,165 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2954: keep `help.md` and the live `commands/gsd/*` slash surface
|
||||
* in lockstep. Two regression tests:
|
||||
*
|
||||
* 1. help.md must not advertise any /gsd-<name> that has no shipped
|
||||
* slash command. (Caught the original #2954 regression: #2824 deleted
|
||||
* 31 stubs without updating help.md.)
|
||||
*
|
||||
* 2. Every shipped /gsd-<name> command must appear in help.md. (Caught
|
||||
* the inverse: a command lands without docs, so users never discover it.)
|
||||
*
|
||||
* The shipped slash name is parsed from frontmatter `name:` (which can be
|
||||
* either `gsd:foo` or `gsd-foo` — Claude Code surfaces both as `/gsd-foo`),
|
||||
* NOT from the filename, because some files (e.g. `ns-context.md`) ship a
|
||||
* different slash name (`gsd-context`) than their filename suggests.
|
||||
*
|
||||
* Also covers `do.md`, the dispatcher invoked at runtime by
|
||||
* `/gsd-progress --do`: any `/gsd-<name>` token in its routing table must
|
||||
* resolve to a live command, otherwise the dispatcher emits "Unknown command".
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
const HELP_MD = path.join(ROOT, 'get-shit-done', 'workflows', 'help.md');
|
||||
const DO_MD = path.join(ROOT, 'get-shit-done', 'workflows', 'do.md');
|
||||
|
||||
function parseFrontmatter(content) {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!match) return null;
|
||||
const fields = {};
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const fieldMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
|
||||
if (!fieldMatch) continue;
|
||||
const value = fieldMatch[2].trim().replace(/^["']|["']$/g, '');
|
||||
fields[fieldMatch[1]] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of slash-base-names actually shipped under commands/gsd/.
|
||||
* A "slash-base-name" is the part after `/gsd-` — e.g. for frontmatter
|
||||
* `name: gsd:foo` or `name: gsd-foo`, the slash-base-name is `foo`.
|
||||
*/
|
||||
function listShippedSlashBaseNames() {
|
||||
const names = new Set();
|
||||
for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
if (!fm || !fm.name) continue;
|
||||
const fmName = fm.name;
|
||||
let base = null;
|
||||
if (fmName.startsWith('gsd:')) base = fmName.slice(4);
|
||||
else if (fmName.startsWith('gsd-')) base = fmName.slice(4);
|
||||
if (base && /^[a-z][a-z0-9-]*$/.test(base)) names.add(base);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function extractSlashReferences(contents) {
|
||||
const names = new Set();
|
||||
const tokenRe = /\/gsd-([a-z][a-z0-9-]*)/g;
|
||||
let match;
|
||||
while ((match = tokenRe.exec(contents)) !== null) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* For every shipped command with an `argument-hint:` frontmatter entry,
|
||||
* collect the `--flag` tokens it advertises. Returns a Map<slashBaseName,
|
||||
* Set<flagName>>. Flags are recorded without their leading `--`.
|
||||
*/
|
||||
function listShippedFlagsByCommand() {
|
||||
const out = new Map();
|
||||
for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
if (!fm || !fm.name || !fm['argument-hint']) continue;
|
||||
const fmName = fm.name;
|
||||
let base = null;
|
||||
if (fmName.startsWith('gsd:')) base = fmName.slice(4);
|
||||
else if (fmName.startsWith('gsd-')) base = fmName.slice(4);
|
||||
if (!base || !/^[a-z][a-z0-9-]*$/.test(base)) continue;
|
||||
const flags = new Set();
|
||||
for (const m of fm['argument-hint'].matchAll(/--([a-z][a-z0-9-]*)/g)) {
|
||||
flags.add(m[1]);
|
||||
}
|
||||
if (flags.size) out.set(base, flags);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('Bug #2954: help.md ↔ commands/gsd/ bidirectional parity', () => {
|
||||
test('every /gsd-<name> referenced in help.md is a shipped command', () => {
|
||||
const helpContents = fs.readFileSync(HELP_MD, 'utf8');
|
||||
const referenced = extractSlashReferences(helpContents);
|
||||
const shipped = listShippedSlashBaseNames();
|
||||
const dangling = [...referenced].filter((n) => !shipped.has(n)).sort();
|
||||
assert.deepEqual(
|
||||
dangling,
|
||||
[],
|
||||
`help.md advertises /gsd-<name> commands that are not shipped: ${dangling.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every shipped /gsd-<name> command is documented in help.md', () => {
|
||||
const helpContents = fs.readFileSync(HELP_MD, 'utf8');
|
||||
const referenced = extractSlashReferences(helpContents);
|
||||
const shipped = listShippedSlashBaseNames();
|
||||
const undocumented = [...shipped].filter((n) => !referenced.has(n)).sort();
|
||||
assert.deepEqual(
|
||||
undocumented,
|
||||
[],
|
||||
`commands shipped under commands/gsd/ with no /gsd-<name> reference in help.md: ${undocumented.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every /gsd-<name> in do.md (live dispatcher) is a shipped command', () => {
|
||||
const doContents = fs.readFileSync(DO_MD, 'utf8');
|
||||
const referenced = extractSlashReferences(doContents);
|
||||
const shipped = listShippedSlashBaseNames();
|
||||
const dangling = [...referenced].filter((n) => !shipped.has(n)).sort();
|
||||
assert.deepEqual(
|
||||
dangling,
|
||||
[],
|
||||
`do.md routing table references /gsd-<name> that is not shipped: ${dangling.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every --flag in a command\'s argument-hint appears in help.md', () => {
|
||||
const helpContents = fs.readFileSync(HELP_MD, 'utf8');
|
||||
const flagsByCommand = listShippedFlagsByCommand();
|
||||
const gaps = [];
|
||||
for (const [command, flags] of flagsByCommand) {
|
||||
for (const flag of flags) {
|
||||
// Accept `/gsd-<command> --<flag>` (precise) OR a bare `--<flag>` token
|
||||
// anywhere in help.md (good enough for shared flags like `--force` that
|
||||
// appear under multiple commands' descriptions).
|
||||
const preciseToken = `/gsd-${command} --${flag}`;
|
||||
const flagToken = `--${flag}`;
|
||||
if (!helpContents.includes(preciseToken) && !helpContents.includes(flagToken)) {
|
||||
gaps.push(`/gsd-${command} --${flag}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
gaps.sort(),
|
||||
[],
|
||||
`commands ship --flag(s) in argument-hint that are absent from help.md: ${gaps.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
70
tests/bug-2957-claude-global-postinstall-message.test.cjs
Normal file
70
tests/bug-2957-claude-global-postinstall-message.test.cjs
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2957: post-install message for `--claude --global` must instruct
|
||||
* users to restart Claude Code and offer the skill-name fallback, since
|
||||
* the skills-only install layout (CC 2.1.88+) leaves nothing in
|
||||
* commands/gsd/ for the slash menu to read on older configurations.
|
||||
*
|
||||
* Captures the call to finishInstall(runtime='claude', isGlobal=true) and
|
||||
* asserts the printed message contains both invocation paths.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
function captureFinishInstallOutput(runtime, isGlobal) {
|
||||
const original = console.log;
|
||||
const lines = [];
|
||||
console.log = (...args) => { lines.push(args.join(' ')); };
|
||||
try {
|
||||
installModule.finishInstall(
|
||||
'/tmp/gsd-test-settings.json',
|
||||
{},
|
||||
null,
|
||||
false,
|
||||
runtime,
|
||||
isGlobal,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
// Strip ANSI color escapes so message-content assertions don't couple to colors.
|
||||
return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('Bug #2957: claude+global post-install message', () => {
|
||||
test('claude+global message tells the user to restart and offers skill-name fallback', () => {
|
||||
const output = captureFinishInstallOutput('claude', true);
|
||||
|
||||
assert.match(output, /restart claude code/i, 'should mention restart');
|
||||
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
|
||||
assert.match(output, /gsd-new-project skill/i, 'should mention the skill name fallback');
|
||||
assert.doesNotMatch(
|
||||
output,
|
||||
/open a blank directory/i,
|
||||
'global claude install should replace, not extend, the legacy generic instruction',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude+local message keeps the original /gsd-new-project instruction', () => {
|
||||
const output = captureFinishInstallOutput('claude', false);
|
||||
|
||||
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
|
||||
assert.doesNotMatch(output, /restart claude code/i, 'local install does not require the skills restart note');
|
||||
});
|
||||
|
||||
test('non-claude runtimes keep their original message format', () => {
|
||||
const output = captureFinishInstallOutput('opencode', true);
|
||||
|
||||
assert.match(output, /Open a blank directory/, 'opencode message should be unchanged');
|
||||
assert.doesNotMatch(output, /restart/i, 'opencode message should not have the claude-specific restart note');
|
||||
});
|
||||
});
|
||||
150
tests/bug-2962-windows-sdk-shim.test.cjs
Normal file
150
tests/bug-2962-windows-sdk-shim.test.cjs
Normal file
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2962: --sdk install flag on Windows leaves gsd-sdk un-shimmed.
|
||||
*
|
||||
* Tests are split into two layers, each at the right level of abstraction:
|
||||
*
|
||||
* 1. buildWindowsShimTriple — pure IR builder. Tests assert on TYPED
|
||||
* FIELDS of the returned record (interpreter, target, eol, fileNames).
|
||||
* No filesystem, no spawn, no text reads. This is the level where
|
||||
* structural correctness lives.
|
||||
*
|
||||
* 2. trySelfLinkGsdSdkWindows — fs/spawn driver that calls the IR builder
|
||||
* and writes the rendered shims to disk. Tests assert FILESYSTEM FACTS
|
||||
* (file exists, file is non-empty, file mtime advanced after replace,
|
||||
* function return value). No reads, no parsing, no substring matching.
|
||||
*
|
||||
* Per the repo's no-source-grep testing standard (CONTRIBUTING.md): the
|
||||
* test must NEVER read shim file contents and pattern-match against them.
|
||||
* The IR is the contract; the rendered text is an implementation detail of
|
||||
* the renderer.
|
||||
*/
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
describe('Bug #2962: buildWindowsShimTriple — pure IR builder', () => {
|
||||
test('resolves shimSrc to an absolute path on the invocation.target field', () => {
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const triple = installModule.buildWindowsShimTriple(shimSrc);
|
||||
assert.equal(triple.invocation.target, path.resolve(shimSrc));
|
||||
assert.equal(triple.invocation.interpreter, 'node');
|
||||
});
|
||||
|
||||
test('produces a structured IR with the documented shape', () => {
|
||||
const triple = installModule.buildWindowsShimTriple(path.join(ROOT, 'bin', 'gsd-sdk.js'));
|
||||
// Lock the public IR shape — adding/removing a key requires updating this assertion.
|
||||
assert.deepEqual(Object.keys(triple).sort(), ['eol', 'fileNames', 'invocation', 'render']);
|
||||
assert.deepEqual(Object.keys(triple.invocation).sort(), ['interpreter', 'target']);
|
||||
assert.deepEqual(Object.keys(triple.eol).sort(), ['cmd', 'ps1', 'sh']);
|
||||
assert.deepEqual(Object.keys(triple.fileNames).sort(), ['cmd', 'ps1', 'sh']);
|
||||
assert.deepEqual(Object.keys(triple.render).sort(), ['cmd', 'ps1', 'sh']);
|
||||
});
|
||||
|
||||
test('declares CRLF line endings on the .cmd file, LF on .ps1 and bash wrapper', () => {
|
||||
const triple = installModule.buildWindowsShimTriple(path.join(ROOT, 'bin', 'gsd-sdk.js'));
|
||||
assert.deepEqual(triple.eol, { cmd: '\r\n', ps1: '\n', sh: '\n' });
|
||||
});
|
||||
|
||||
test('declares the standard npm-style filenames for the shim triple', () => {
|
||||
const triple = installModule.buildWindowsShimTriple(path.join(ROOT, 'bin', 'gsd-sdk.js'));
|
||||
assert.deepEqual(triple.fileNames, { cmd: 'gsd-sdk.cmd', ps1: 'gsd-sdk.ps1', sh: 'gsd-sdk' });
|
||||
});
|
||||
|
||||
test('IR is purely a function of shimSrc — no fs / spawn side effects', () => {
|
||||
// If buildWindowsShimTriple touched the filesystem, calling it twice with
|
||||
// different shimSrc paths would leave two different artifacts. Asserting
|
||||
// pure-function behavior structurally: same input → identical IR.
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const a = installModule.buildWindowsShimTriple(shimSrc);
|
||||
const b = installModule.buildWindowsShimTriple(shimSrc);
|
||||
assert.deepEqual(a.invocation, b.invocation);
|
||||
assert.deepEqual(a.eol, b.eol);
|
||||
assert.deepEqual(a.fileNames, b.fileNames);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2962: trySelfLinkGsdSdkWindows — fs/spawn driver', () => {
|
||||
let tmpDir;
|
||||
let origExecSync;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2962-'));
|
||||
origExecSync = cp.execSync;
|
||||
cp.execSync = (cmd) => {
|
||||
if (typeof cmd === 'string' && cmd.trim() === 'npm prefix -g') {
|
||||
return tmpDir + '\n';
|
||||
}
|
||||
return origExecSync.call(cp, cmd);
|
||||
};
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cp.execSync = origExecSync;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns the .cmd path on success and writes all three shim files', () => {
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const triple = installModule.buildWindowsShimTriple(shimSrc);
|
||||
const result = installModule.trySelfLinkGsdSdkWindows(shimSrc);
|
||||
|
||||
assert.equal(result, path.join(tmpDir, triple.fileNames.cmd));
|
||||
for (const fileName of Object.values(triple.fileNames)) {
|
||||
const target = path.join(tmpDir, fileName);
|
||||
const stat = fs.statSync(target);
|
||||
assert.ok(stat.isFile(), `${fileName} must be a regular file`);
|
||||
assert.ok(stat.size > 0, `${fileName} must be non-empty`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the rendered file size matches the IR renderer\'s output length (renderer drives the writer)', () => {
|
||||
// Asserts the writer writes exactly what the renderer produces — no mutation,
|
||||
// no double-write, no truncation. We compare BYTE LENGTHS, not contents:
|
||||
// length is a structural property; content equality would re-introduce text matching.
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const triple = installModule.buildWindowsShimTriple(shimSrc);
|
||||
installModule.trySelfLinkGsdSdkWindows(shimSrc);
|
||||
for (const kind of ['cmd', 'ps1', 'sh']) {
|
||||
const target = path.join(tmpDir, triple.fileNames[kind]);
|
||||
const expected = Buffer.byteLength(triple.render[kind](), 'utf8');
|
||||
assert.equal(fs.statSync(target).size, expected, `${kind} byte length matches renderer`);
|
||||
}
|
||||
});
|
||||
|
||||
test('replaces stale shims atomically (mtime advances on rewrite)', () => {
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
installModule.trySelfLinkGsdSdkWindows(shimSrc);
|
||||
const cmdPath = path.join(tmpDir, 'gsd-sdk.cmd');
|
||||
const beforeMtime = fs.statSync(cmdPath).mtimeMs;
|
||||
|
||||
// Wait at least 10ms so mtime granularity (1ms on most fs, 1s on some) records the change.
|
||||
const wait = Date.now() + 20;
|
||||
while (Date.now() < wait) { /* busy-wait, intentional */ }
|
||||
|
||||
installModule.trySelfLinkGsdSdkWindows(shimSrc);
|
||||
const afterMtime = fs.statSync(cmdPath).mtimeMs;
|
||||
assert.ok(afterMtime > beforeMtime, `mtime must advance: before=${beforeMtime} after=${afterMtime}`);
|
||||
});
|
||||
|
||||
test('returns null when npm prefix -g fails', () => {
|
||||
const restore = cp.execSync;
|
||||
cp.execSync = () => { throw new Error('npm not on PATH'); };
|
||||
try {
|
||||
const result = installModule.trySelfLinkGsdSdkWindows(path.join(ROOT, 'bin', 'gsd-sdk.js'));
|
||||
assert.equal(result, null);
|
||||
} finally {
|
||||
cp.execSync = restore;
|
||||
}
|
||||
});
|
||||
});
|
||||
205
tests/bug-2969-verify-reapply-patches.test.cjs
Normal file
205
tests/bug-2969-verify-reapply-patches.test.cjs
Normal file
@@ -0,0 +1,205 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2969: /gsd-reapply-patches Step 5 hunk verification gate reports
|
||||
* success on lost content because the LLM-driven workflow fills in
|
||||
* "verified: yes" without actually checking content presence.
|
||||
*
|
||||
* Fix: deterministic verifier script (scripts/verify-reapply-patches.cjs)
|
||||
* that the workflow calls.
|
||||
*
|
||||
* Per the repo's no-source-grep testing standard (CONTRIBUTING.md):
|
||||
* tests must assert on TYPED structured fields — not regex/substring
|
||||
* matching against script output, formatter prose, or file content.
|
||||
*
|
||||
* The script's --json mode emits a structured report whose `reason`
|
||||
* field is a stable enum (exposed as REASON), and whose `missing` field
|
||||
* is an array of typed strings (exact set membership, not substring).
|
||||
* Every assertion below is a deepEqual / equal / Array.includes against
|
||||
* those typed fields. Zero regex, zero String#includes on text.
|
||||
*/
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SCRIPT = path.join(ROOT, 'scripts', 'verify-reapply-patches.cjs');
|
||||
const { REASON } = require(SCRIPT);
|
||||
|
||||
let tmpRoot;
|
||||
let patchesDir;
|
||||
let configDir;
|
||||
let pristineDir;
|
||||
|
||||
function writeFile(absPath, content) {
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, content);
|
||||
}
|
||||
|
||||
function resetFixture({ withPristine = true } = {}) {
|
||||
for (const dir of [patchesDir, configDir, pristineDir]) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(patchesDir);
|
||||
fs.mkdirSync(configDir);
|
||||
if (withPristine) fs.mkdirSync(pristineDir);
|
||||
}
|
||||
|
||||
/** Runs the verifier with --json. Returns parsed structured report. */
|
||||
function runVerifier({ includePristine = true } = {}) {
|
||||
const args = [
|
||||
SCRIPT,
|
||||
'--patches-dir', patchesDir,
|
||||
'--config-dir', configDir,
|
||||
...(includePristine ? ['--pristine-dir', pristineDir] : []),
|
||||
'--json',
|
||||
];
|
||||
const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8' });
|
||||
return {
|
||||
status: r.status,
|
||||
report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null,
|
||||
};
|
||||
}
|
||||
|
||||
before(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2969-'));
|
||||
patchesDir = path.join(tmpRoot, 'patches');
|
||||
configDir = path.join(tmpRoot, 'installed');
|
||||
pristineDir = path.join(tmpRoot, 'pristine');
|
||||
resetFixture();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Bug #2969: deterministic Step 5 verification gate', () => {
|
||||
test('REASON enum exposes the documented set of stable codes', () => {
|
||||
// Locks the public diagnostic surface — adding a code requires updating
|
||||
// this assertion, removing one breaks consumers that switch on the enum.
|
||||
assert.deepEqual(
|
||||
Object.keys(REASON).sort(),
|
||||
[
|
||||
'FAIL_INSTALLED_MISSING',
|
||||
'FAIL_INSTALLED_NOT_REGULAR_FILE',
|
||||
'FAIL_READ_ERROR',
|
||||
'FAIL_USER_LINES_MISSING',
|
||||
'OK_NO_SIGNIFICANT_BACKUP_LINES',
|
||||
'OK_NO_USER_LINES_VS_PRISTINE',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('exits 0 with status=ok when every user-added line is present in the merged file', () => {
|
||||
resetFixture();
|
||||
const pristine = 'line one of stock content here\nline two of stock content here\nline three of stock content here\n';
|
||||
const userAdded = 'a custom line the user added for behavior X\nanother substantial line that the user inserted\n';
|
||||
|
||||
writeFile(path.join(pristineDir, 'skills', 'foo', 'SKILL.md'), pristine);
|
||||
writeFile(path.join(patchesDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded);
|
||||
writeFile(path.join(configDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded);
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 0);
|
||||
assert.equal(report.failures, 0);
|
||||
assert.equal(report.checked, 1);
|
||||
assert.equal(report.results[0].status, 'ok');
|
||||
assert.deepEqual(report.results[0].missing, []);
|
||||
});
|
||||
|
||||
test('reason=FAIL_USER_LINES_MISSING with the exact dropped line in .missing[]', () => {
|
||||
resetFixture();
|
||||
const pristine = 'first stock line in the original file here\nsecond stock line in the original file here\n';
|
||||
const lostLine = 'this is the visual companion block that must survive';
|
||||
writeFile(path.join(pristineDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine);
|
||||
writeFile(path.join(patchesDir, 'skills', 'discuss-phase', 'SKILL.md'), `${pristine}${lostLine}\n`);
|
||||
writeFile(path.join(configDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine);
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 1);
|
||||
assert.equal(report.failures, 1);
|
||||
const r0 = report.results[0];
|
||||
assert.equal(r0.file, 'skills/discuss-phase/SKILL.md');
|
||||
assert.equal(r0.status, 'fail');
|
||||
assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING);
|
||||
assert.ok(
|
||||
r0.missing.includes(lostLine),
|
||||
`dropped line should be in .missing[]; got ${JSON.stringify(r0.missing)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('reason=FAIL_INSTALLED_NOT_REGULAR_FILE when installed path is a directory', () => {
|
||||
resetFixture();
|
||||
writeFile(path.join(pristineDir, 'a.md'), 'pristine line of substantial content here\n');
|
||||
writeFile(path.join(patchesDir, 'a.md'), 'pristine line of substantial content here\nuser added line that is substantial\n');
|
||||
fs.mkdirSync(path.join(configDir, 'a.md')); // EISDIR trap
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 1);
|
||||
assert.equal(report.results[0].status, 'fail');
|
||||
assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_NOT_REGULAR_FILE);
|
||||
});
|
||||
|
||||
test('reason=FAIL_INSTALLED_MISSING when the merged file has been deleted', () => {
|
||||
resetFixture();
|
||||
const pristine = 'stock line one with substantial content for the test\n';
|
||||
writeFile(path.join(pristineDir, 'workflow.md'), pristine);
|
||||
writeFile(path.join(patchesDir, 'workflow.md'), `${pristine}user line that should survive but does not\n`);
|
||||
// configDir intentionally missing the file.
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 1);
|
||||
assert.equal(report.results[0].status, 'fail');
|
||||
assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_MISSING);
|
||||
});
|
||||
|
||||
test('--json report has the documented shape: { checked, failures, results: [{ file, status, missing, reason }] }', () => {
|
||||
resetFixture();
|
||||
const pristine = 'pristine line that is sufficiently long to be significant\n';
|
||||
const userAdded = 'extra line the user wrote for their workflow customisation';
|
||||
writeFile(path.join(pristineDir, 'a.md'), pristine);
|
||||
writeFile(path.join(patchesDir, 'a.md'), `${pristine}${userAdded}\n`);
|
||||
writeFile(path.join(configDir, 'a.md'), pristine);
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 1);
|
||||
assert.deepEqual(Object.keys(report).sort(), ['checked', 'failures', 'results']);
|
||||
const r0 = report.results[0];
|
||||
assert.deepEqual(Object.keys(r0).sort(), ['file', 'missing', 'reason', 'status']);
|
||||
assert.equal(typeof r0.file, 'string');
|
||||
assert.equal(typeof r0.status, 'string');
|
||||
assert.equal(typeof r0.reason, 'string');
|
||||
assert.ok(Array.isArray(r0.missing));
|
||||
});
|
||||
|
||||
test('ignores backup-meta.json — it is metadata, not a patched file', () => {
|
||||
resetFixture();
|
||||
writeFile(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({ files: [] }));
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 0);
|
||||
assert.equal(report.checked, 0);
|
||||
assert.equal(report.failures, 0);
|
||||
assert.deepEqual(report.results, []);
|
||||
});
|
||||
|
||||
test('without --pristine-dir, treats every significant backup line as required (safe over-broad fallback)', () => {
|
||||
resetFixture({ withPristine: false });
|
||||
const presentLine = 'this is a substantial line of user content here';
|
||||
const droppedLine = 'another substantial line that should survive';
|
||||
writeFile(path.join(patchesDir, 'b.md'), `${presentLine}\n${droppedLine}\n`);
|
||||
writeFile(path.join(configDir, 'b.md'), `${presentLine}\n`);
|
||||
|
||||
const { status, report } = runVerifier({ includePristine: false });
|
||||
assert.equal(status, 1);
|
||||
assert.equal(report.results[0].reason, REASON.FAIL_USER_LINES_MISSING);
|
||||
assert.ok(report.results[0].missing.includes(droppedLine));
|
||||
assert.ok(!report.results[0].missing.includes(presentLine));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
/**
|
||||
* Tests for get-shit-done/bin/lib/graphify.cjs
|
||||
*
|
||||
|
||||
@@ -201,11 +201,15 @@ describe('gsd-settings-advanced — VALID_CONFIG_KEYS coverage', () => {
|
||||
// ─── /gsd-settings mentions /gsd-settings-advanced ────────────────────────────
|
||||
|
||||
describe('/gsd-settings advertises /gsd-settings-advanced', () => {
|
||||
test('settings workflow confirmation mentions gsd-settings-advanced', () => {
|
||||
test('settings workflow mentions canonical /gsd-config --advanced', () => {
|
||||
const text = fs.readFileSync(SETTINGS_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
text.includes('gsd-settings-advanced') || text.includes('gsd:settings-advanced'),
|
||||
'get-shit-done/workflows/settings.md must mention /gsd-settings-advanced or /gsd:settings-advanced'
|
||||
text.includes('gsd-config --advanced'),
|
||||
'get-shit-done/workflows/settings.md must mention /gsd-config --advanced'
|
||||
);
|
||||
assert.ok(
|
||||
!text.includes('gsd-settings-advanced') && !text.includes('gsd:settings-advanced'),
|
||||
'get-shit-done/workflows/settings.md must not mention legacy /gsd-settings-advanced variants'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
/**
|
||||
* GSD Tools Tests - Community Hooks (opt-in)
|
||||
*
|
||||
|
||||
337
tests/install-minimal-all-runtimes.test.cjs
Normal file
337
tests/install-minimal-all-runtimes.test.cjs
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Per-runtime regression test for `--minimal` install profile (#2923).
|
||||
*
|
||||
* Background: #2923 reported that `--opencode --local --minimal` silently
|
||||
* installed the full surface. While auditing the central gate
|
||||
* (`stageSkillsForMode` in get-shit-done/bin/lib/install-profiles.cjs),
|
||||
* we found that:
|
||||
* - Skills are correctly filtered for every runtime in both `--global`
|
||||
* and `--local` modes (the dispatch sites in install.js all call
|
||||
* stageSkillsForMode unconditionally).
|
||||
* - Agents are correctly suppressed under --minimal.
|
||||
* - HOWEVER, the install manifest only recorded `commands/gsd/` for
|
||||
* Gemini, leaving Claude Code local installs with an incomplete
|
||||
* manifest. saveLocalPatches() then couldn't detect user edits and
|
||||
* a minimal-mode reinstall couldn't be verified manifest-side.
|
||||
*
|
||||
* This test pins per-runtime behavior end-to-end: spawn the installer
|
||||
* with --minimal for each runtime in each scope, parse the resulting
|
||||
* manifest JSON, assert that mode === 'minimal', the recorded skill set
|
||||
* equals MINIMAL_SKILL_ALLOWLIST, and zero gsd-* agents are present.
|
||||
*
|
||||
* Cline is rules-based and embeds the workflow in `.clinerules` rather
|
||||
* than emitting per-skill files. Asserted separately: mode === 'minimal',
|
||||
* zero agents, .clinerules exists.
|
||||
*
|
||||
* No regex / `.includes()` against file contents — every assertion
|
||||
* either parses JSON or walks a directory tree.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const {
|
||||
MINIMAL_SKILL_ALLOWLIST,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
|
||||
// Per-runtime config dir name for local installs. Mirrors getDirName() in
|
||||
// bin/install.js; kept as a fixture to avoid coupling the test to that
|
||||
// internal helper.
|
||||
const LOCAL_DIR_NAME = {
|
||||
claude: '.claude',
|
||||
opencode: '.opencode',
|
||||
gemini: '.gemini',
|
||||
kilo: '.kilo',
|
||||
codex: '.codex',
|
||||
copilot: '.github',
|
||||
antigravity: '.agent',
|
||||
cursor: '.cursor',
|
||||
windsurf: '.windsurf',
|
||||
augment: '.augment',
|
||||
trae: '.trae',
|
||||
qwen: '.qwen',
|
||||
codebuddy: '.codebuddy',
|
||||
cline: '.', // Cline writes to project root
|
||||
};
|
||||
|
||||
// Skill-emitting runtimes (everything except Cline, which is rules-based).
|
||||
const SKILL_RUNTIMES = [
|
||||
'claude',
|
||||
'opencode',
|
||||
'gemini',
|
||||
'kilo',
|
||||
'codex',
|
||||
'copilot',
|
||||
'antigravity',
|
||||
'cursor',
|
||||
'windsurf',
|
||||
'augment',
|
||||
'trae',
|
||||
'qwen',
|
||||
'codebuddy',
|
||||
];
|
||||
|
||||
const ALL_RUNTIMES = [...SKILL_RUNTIMES, 'cline'];
|
||||
|
||||
/**
|
||||
* Run the installer in either global or local mode and return the parsed
|
||||
* manifest (or null if no manifest was written).
|
||||
*/
|
||||
function runInstall({ runtime, scope, extraArgs = [] }) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`));
|
||||
try {
|
||||
let configDir;
|
||||
let cwd = process.cwd();
|
||||
const args = [INSTALL_SCRIPT, `--${runtime}`];
|
||||
|
||||
if (scope === 'global') {
|
||||
args.push('--global', '--config-dir', root);
|
||||
configDir = root;
|
||||
} else {
|
||||
args.push('--local');
|
||||
cwd = root;
|
||||
configDir = runtime === 'cline'
|
||||
? root
|
||||
: path.join(root, LOCAL_DIR_NAME[runtime]);
|
||||
}
|
||||
args.push(...extraArgs);
|
||||
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
0,
|
||||
`installer exited with status ${result.status} for ${runtime} --${scope}` +
|
||||
`\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||
);
|
||||
|
||||
const manifestPath = path.join(configDir, MANIFEST_NAME);
|
||||
let manifest = null;
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
}
|
||||
return { manifest, configDir, root, stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (err) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the manifest's `files` keys and project them onto a per-runtime
|
||||
* "skill set". Each runtime emits skills under one of three keyspaces:
|
||||
* skills/<name>/... (Claude global, Codex, Copilot, Antigravity,
|
||||
* Cursor, Windsurf, Augment, Trae, Qwen,
|
||||
* CodeBuddy)
|
||||
* command/gsd-<name>.md (OpenCode, Kilo)
|
||||
* commands/gsd/<name>.md (Gemini, Claude local — fixed in #2923)
|
||||
*
|
||||
* Returns the unique set of skill basenames recorded in the manifest.
|
||||
*/
|
||||
function manifestSkillSet(manifest) {
|
||||
if (!manifest || !manifest.files) return new Set();
|
||||
const out = new Set();
|
||||
for (const key of Object.keys(manifest.files)) {
|
||||
if (key.startsWith('skills/')) {
|
||||
// Strip both the optional `gsd-` prefix (used by Claude/Codex/etc as
|
||||
// a per-skill subdir name) and any trailing `.md` (Codex flat layout).
|
||||
const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, '');
|
||||
out.add(seg);
|
||||
} else if (key.startsWith('command/')) {
|
||||
const file = key.split('/')[1];
|
||||
// Strip `gsd-` prefix and `.md` suffix. Subdirs flatten with `-`,
|
||||
// but our minimal allowlist is flat (top-level files only) so this
|
||||
// is safe here.
|
||||
const base = file.replace(/^gsd-/, '').replace(/\.md$/, '');
|
||||
out.add(base);
|
||||
} else if (key.startsWith('commands/gsd/')) {
|
||||
// Gemini transforms .md → .toml on emit; Claude local keeps .md.
|
||||
const file = key.split('/')[2];
|
||||
out.add(file.replace(/\.(md|toml)$/, ''));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function manifestAgentCount(manifest) {
|
||||
if (!manifest || !manifest.files) return 0;
|
||||
return Object.keys(manifest.files).filter((k) => k.startsWith('agents/')).length;
|
||||
}
|
||||
|
||||
function expectedSkillSet() {
|
||||
return new Set([...MINIMAL_SKILL_ALLOWLIST]);
|
||||
}
|
||||
|
||||
describe('install: --minimal honoured for every runtime in --global mode', () => {
|
||||
for (const runtime of SKILL_RUNTIMES) {
|
||||
test(`${runtime} --global --minimal emits exactly the core skill set, zero agents`, () => {
|
||||
const { manifest, root } = runInstall({
|
||||
runtime,
|
||||
scope: 'global',
|
||||
extraArgs: ['--minimal'],
|
||||
});
|
||||
try {
|
||||
assert.ok(manifest, `${runtime} global install must produce a manifest`);
|
||||
assert.strictEqual(manifest.mode, 'minimal',
|
||||
`${runtime} global manifest.mode should be "minimal"`);
|
||||
assert.deepStrictEqual(
|
||||
[...manifestSkillSet(manifest)].sort(),
|
||||
[...expectedSkillSet()].sort(),
|
||||
`${runtime} global should record exactly the MINIMAL allowlist in the manifest`,
|
||||
);
|
||||
assert.strictEqual(manifestAgentCount(manifest), 0,
|
||||
`${runtime} global --minimal should record zero gsd-* agents`);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('install: --minimal honoured for every runtime in --local mode', () => {
|
||||
for (const runtime of SKILL_RUNTIMES) {
|
||||
test(`${runtime} --local --minimal emits exactly the core skill set, zero agents`, () => {
|
||||
const { manifest, root } = runInstall({
|
||||
runtime,
|
||||
scope: 'local',
|
||||
extraArgs: ['--minimal'],
|
||||
});
|
||||
try {
|
||||
assert.ok(manifest, `${runtime} local install must produce a manifest`);
|
||||
assert.strictEqual(manifest.mode, 'minimal',
|
||||
`${runtime} local manifest.mode should be "minimal"`);
|
||||
assert.deepStrictEqual(
|
||||
[...manifestSkillSet(manifest)].sort(),
|
||||
[...expectedSkillSet()].sort(),
|
||||
`${runtime} local should record exactly the MINIMAL allowlist in the manifest (regression guard for #2923)`,
|
||||
);
|
||||
assert.strictEqual(manifestAgentCount(manifest), 0,
|
||||
`${runtime} local --minimal should record zero gsd-* agents`);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('install: Cline --minimal (rules-based runtime — no skills/ dir)', () => {
|
||||
for (const scope of ['global', 'local']) {
|
||||
test(`cline --${scope} --minimal records mode=minimal and zero agents`, () => {
|
||||
const { manifest, configDir, root } = runInstall({
|
||||
runtime: 'cline',
|
||||
scope,
|
||||
extraArgs: ['--minimal'],
|
||||
});
|
||||
try {
|
||||
assert.ok(manifest, `cline ${scope} install must produce a manifest`);
|
||||
assert.strictEqual(manifest.mode, 'minimal');
|
||||
assert.strictEqual(manifestAgentCount(manifest), 0,
|
||||
`cline ${scope} --minimal should record zero gsd-* agents`);
|
||||
|
||||
// .clinerules exists (Cline embeds the workflow there in lieu of
|
||||
// per-skill files).
|
||||
const clinerules = path.join(configDir, '.clinerules');
|
||||
assert.ok(fs.existsSync(clinerules),
|
||||
`cline install should emit .clinerules at ${clinerules}`);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('install: directory-on-disk matches manifest for --minimal', () => {
|
||||
// Cross-check that the manifest isn't lying — actually walk the install
|
||||
// dir and verify the gsd-* surface on disk equals what the manifest claims.
|
||||
// This catches the inverse of #2923: manifest says minimal, but disk has
|
||||
// full surface (or vice versa).
|
||||
for (const runtime of SKILL_RUNTIMES) {
|
||||
for (const scope of ['global', 'local']) {
|
||||
test(`${runtime} --${scope} --minimal: on-disk skill files match manifest`, () => {
|
||||
const { manifest, configDir, root } = runInstall({
|
||||
runtime,
|
||||
scope,
|
||||
extraArgs: ['--minimal'],
|
||||
});
|
||||
try {
|
||||
assert.ok(
|
||||
manifest,
|
||||
`${runtime} ${scope} --minimal: manifest must exist before parity check`,
|
||||
);
|
||||
const onDisk = collectSkillBasenamesOnDisk(configDir);
|
||||
const inManifest = manifestSkillSet(manifest);
|
||||
assert.deepStrictEqual(
|
||||
[...onDisk].sort(),
|
||||
[...inManifest].sort(),
|
||||
`${runtime} ${scope}: on-disk skills must match manifest record`,
|
||||
);
|
||||
// And no gsd-*.md agent file should exist on disk either:
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
if (fs.existsSync(agentsDir)) {
|
||||
const gsdAgents = fs.readdirSync(agentsDir).filter(
|
||||
(f) => f.startsWith('gsd-') && f.endsWith('.md'),
|
||||
);
|
||||
assert.deepStrictEqual(gsdAgents, [],
|
||||
`${runtime} ${scope} --minimal should not write gsd-*.md agents on disk`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Walk the per-runtime install destination and return the set of skill
|
||||
* basenames found on disk. Mirrors manifestSkillSet but reads the
|
||||
* filesystem, not the manifest — used to verify the two agree.
|
||||
*/
|
||||
function collectSkillBasenamesOnDisk(configDir) {
|
||||
const out = new Set();
|
||||
|
||||
// skills/<name>/SKILL.md (or SKILL.toml/.md depending on runtime)
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
||||
out.add(entry.name.replace(/^gsd-/, ''));
|
||||
} else if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
|
||||
// Codex flat skills/ layout: skills/gsd-<name>.md
|
||||
out.add(entry.name.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// command/gsd-<name>.md (OpenCode, Kilo)
|
||||
const commandDir = path.join(configDir, 'command');
|
||||
if (fs.existsSync(commandDir)) {
|
||||
for (const file of fs.readdirSync(commandDir)) {
|
||||
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
||||
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// commands/gsd/<name>.{md,toml} (Claude local emits .md; Gemini emits .toml)
|
||||
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
|
||||
if (fs.existsSync(commandsGsdDir)) {
|
||||
for (const file of fs.readdirSync(commandsGsdDir)) {
|
||||
if (file.endsWith('.md') || file.endsWith('.toml')) {
|
||||
out.add(file.replace(/\.(md|toml)$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
70
tests/no-unconditional-win32-skip.test.cjs
Normal file
70
tests/no-unconditional-win32-skip.test.cjs
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Behavior-based regression guard for #2962-class bugs.
|
||||
*
|
||||
* "Nothing for Windows should be deferred — if it wasn't in, it was missed
|
||||
* not deferred." (maintainer guidance, 2026-05-01.)
|
||||
*
|
||||
* Specifically guards against trySelfLinkGsdSdk silently no-op'ing on
|
||||
* Windows. Rather than regex-scanning bin/install.js source (which would
|
||||
* fail on harmless refactors and conflicts with the repo's no-source-grep
|
||||
* testing standard), this test exercises the function under a simulated
|
||||
* `process.platform === 'win32'` and asserts shim files actually land on
|
||||
* disk — i.e., the Windows branch dispatches, doesn't early-return null.
|
||||
*/
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
describe('Windows parity guard for trySelfLinkGsdSdk (#2962)', () => {
|
||||
let tmpDir;
|
||||
let origPlatform;
|
||||
let origExecSync;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-win32-guard-'));
|
||||
origPlatform = process.platform;
|
||||
origExecSync = cp.execSync;
|
||||
// Override process.platform to simulate Windows. process.platform is a
|
||||
// configurable property in Node — Object.defineProperty can swap it.
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
cp.execSync = (cmd) => {
|
||||
if (typeof cmd === 'string' && cmd.trim() === 'npm prefix -g') {
|
||||
return tmpDir + '\n';
|
||||
}
|
||||
throw new Error(`unexpected execSync: ${cmd}`);
|
||||
};
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true });
|
||||
cp.execSync = origExecSync;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('trySelfLinkGsdSdk dispatches to the Windows handler and writes shims (does NOT silently return null)', () => {
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const result = installModule.trySelfLinkGsdSdk(shimSrc);
|
||||
|
||||
assert.notEqual(
|
||||
result,
|
||||
null,
|
||||
'trySelfLinkGsdSdk must not silently return null on Windows; ' +
|
||||
'a no-op skip is a missed-parity regression (see #2962, #2775).',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, 'gsd-sdk.cmd')),
|
||||
'Windows dispatch must materialize gsd-sdk.cmd in the npm global bin',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,10 @@
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync, execSync } = require('child_process');
|
||||
|
||||
@@ -265,11 +265,15 @@ describe('#2529 config merge safety', () => {
|
||||
// ─── /gsd-settings mentions /gsd-settings-integrations ──────────────────────
|
||||
|
||||
describe('#2529 /gsd-settings mentions new command', () => {
|
||||
test('settings workflow mentions /gsd-settings-integrations in its confirmation output', () => {
|
||||
test('settings workflow mentions canonical /gsd-config --integrations', () => {
|
||||
const src = fs.readFileSync(SETTINGS_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
src.includes('/gsd-settings-integrations'),
|
||||
'settings.md must mention /gsd-settings-integrations as a follow-up'
|
||||
src.includes('gsd-config --integrations'),
|
||||
'settings.md must mention /gsd-config --integrations'
|
||||
);
|
||||
assert.ok(
|
||||
!src.includes('/gsd-settings-integrations'),
|
||||
'settings.md must not mention the legacy /gsd-settings-integrations variant'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,16 +199,17 @@ describe('config-get context_window (#1472)', () => {
|
||||
assert.strictEqual(output, 1000000);
|
||||
});
|
||||
|
||||
test('config-get context_window errors when key is absent', () => {
|
||||
test('config-get context_window returns schema default (200000) when key is absent', () => {
|
||||
// Bug #2943: context_window has a schema-level default of 200000.
|
||||
// config-get must return it (exit 0) rather than "Key not found" (exit 1).
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
|
||||
|
||||
const result = runGsdTools('config-get context_window', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(
|
||||
result.error.includes('Key not found'),
|
||||
`Expected "Key not found" in error: ${result.error}`
|
||||
);
|
||||
assert.ok(result.success, `Expected success but got: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output, 200000, 'schema default for context_window should be 200000');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -226,19 +226,20 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
|
||||
);
|
||||
});
|
||||
|
||||
// #2505 — installer does NOT wipe skills/ or command/; scanning them produces
|
||||
// false-positive "custom file" reports for every skill the user has installed
|
||||
// from other packages.
|
||||
test('does not scan skills/ directory (installer does not wipe it)', () => {
|
||||
// After v1.39.0 skill consolidation (#2790), the installer wipes skills/ on
|
||||
// update. skills/ is now a GSD-managed directory and must be scanned so that
|
||||
// user-added skill directories are backed up before the wipe (#2942).
|
||||
// GSD-owned skills (tracked in manifest) must NOT be flagged as custom.
|
||||
test('scans skills/ directory and detects user-added skills not in manifest (#2942)', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner\n',
|
||||
});
|
||||
|
||||
// Simulate user having third-party skills installed — none in manifest
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillsDir, 'my-custom-skill.md'), '# My Skill\n');
|
||||
fs.writeFileSync(path.join(skillsDir, 'another-plugin-skill.md'), '# Another\n');
|
||||
// Simulate user having a custom skill installed — NOT in manifest
|
||||
const customSkillDir = path.join(tmpDir, 'skills', 'my-custom-skill');
|
||||
fs.mkdirSync(customSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
@@ -248,10 +249,17 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
const skillFiles = json.custom_files.filter(f => f.startsWith('skills/'));
|
||||
assert.strictEqual(
|
||||
skillFiles.length, 0,
|
||||
`skills/ should not be scanned; got false positives: ${JSON.stringify(skillFiles)}`
|
||||
|
||||
// The user's custom skill should be detected
|
||||
assert.ok(
|
||||
json.custom_files.includes('skills/my-custom-skill/SKILL.md'),
|
||||
`custom skill should be detected; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
|
||||
// The GSD-owned skill (in manifest) should NOT be flagged as custom
|
||||
assert.ok(
|
||||
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
|
||||
`GSD-owned skill should not be flagged as custom; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user