feat(#2762): --minimal install profile (≥94% cold-start token reduction) (#2764)

* feat(#2762): add --minimal install profile to cut cold-start token cost

Eager system-prompt load from 86 gsd-* skill descriptions plus 33
subagent descriptions costs ~12k tokens per turn even in directories
with no .planning/. Frontier models (Sonnet 4.6 / Opus 4.7) with 200K-1M
context don't feel it; local LLMs with 32K-128K do.

--minimal (alias --core-only) installs only the main GSD loop:
new-project, discuss-phase, plan-phase, execute-phase, plus help/update.
Zero gsd-* subagents are written. Re-running gsd update without
--minimal expands to the full surface. Default install behavior is
unchanged.

DRY: a single stageSkillsForMode() helper filters the source dir; all
13 runtime-specific copy fns are unchanged because they recurse the
staged dir. Allowlist + helpers live in get-shit-done/bin/lib/install-
profiles.cjs as the single source of truth.

Manifest now records mode: 'minimal' | 'full' so future commands can
detect install profile.

Tested end-to-end: --minimal yields 6 skill folders + 0 agents; default
yields 86 + 33 (unchanged).

* docs(#2762): document --minimal install in README

Adds a collapsible 'Minimal Install' section under Getting Started
covering: who it's for (local LLMs, token-billed APIs), what you get
(6 skills, 0 subagents, ~700 token floor vs ~12k), and the critical
caveat that re-installing without --minimal restores the full surface
and erases the savings. Includes a comparison table, the manifest
inspection one-liner, and the use-case decision matrix.

* fix(#2762): address CodeRabbit review + CI failures

CodeRabbit findings:
1. Temp dir leak (Minor): stageSkillsForMode created tmp dirs that were
   never cleaned up. Added a module-level Set tracking every staged dir
   plus a process.on('exit') handler that rm -rf's them. Also wrap the
   copy loop in try/catch to remove a partially-populated tmp dir on
   mid-flight failure. Verified end-to-end: 0 leaked dirs in /tmp after
   a real install.

2. Codex full -> minimal stale state (Major): a previous full Codex
   install left agents/gsd-*.toml files plus [agents.gsd-*] sections in
   config.toml. The original cleanup only removed .md files, so a switch
   to --minimal would leave Codex still advertising the full agent
   surface. Cleanup now also handles .toml under isCodex, and minimal
   mode strips GSD sections from config.toml via the existing
   stripGsdFromCodexConfig helper (same path used by --uninstall).

3. Nitpick — Codex downgrade regression test: added a spawnSync-based
   end-to-end test that fakes a previous full install (stale gsd-*.md +
   gsd-*.toml + GSD-marked config.toml + a user-owned agent/setting),
   runs install.js --codex --minimal, and asserts stale GSD files +
   sections are gone while user content is preserved.

CI failures (inventory parity):
- docs/INVENTORY.md CLI Modules table now lists install-profiles.cjs
  with the correct headline count (30 -> 31).
- docs/INVENTORY-MANIFEST.json regenerated via gen-inventory-manifest.cjs.

Test count: 149 pass (was 116 in last commit; +14 new install-minimal +
all previously-failing inventory tests now green).

* test(#2762): expand install-minimal test coverage for future-proofing

Each new test pins a specific guarantee that closes off a future
regression class — turning every CodeRabbit finding (including the
nitpicky one) into a permanent guard.

cleanupStagedSkills suite (+3 tests):
- 'full mode does not register a staged dir' — catches a future
  regression where someone forgets the early-return in stageSkillsForMode
  and starts polluting STAGED_DIRS in default installs.
- 'exit handler registers exactly once across many calls' — catches
  removal of the exitHandlerRegistered guard. install.js has 13
  dispatch sites, so a missing guard would attach 13 listeners.
- 'mid-copy failure removes partial staged dir and re-throws' —
  intercepts fs.copyFileSync to throw mid-loop and asserts the staged
  dir count in /tmp is unchanged after the throw. Pins the exact
  CodeRabbit-flagged leak.

Claude full -> minimal downgrade (+1 test):
- Mirrors the Codex downgrade test for the .md-only path that the
  other 12 runtimes share. Asserts user-owned agents are preserved.

Manifest mode round-trip (+3 tests):
- Default install -> mode: 'full' with >6 skills and >0 agents
- --minimal -> mode: 'minimal' with exactly 6 skills and 0 agents
- --core-only alias produces identical manifest to --minimal

Allowlist scope guards (+3 tests):
- Every main-loop command IS in allowlist (positive)
- Off-loop commands (autonomous, ship, do, progress, next, fast,
  quick, debug, code-review, verify-work) are NOT (guards against
  silent scope creep — future contributor adds 'autonomous' to core
  and the floor erodes)
- Unknown mode strings fall through to full behavior — pre-emptive
  guard for future 'compact'/'tier2' modes that might forget to
  update the predicate.

Total: 25 tests in this file (was 15), 159/159 passing across the
install + inventory suites.

* fix(#2762): clean up staged tmp dirs on SIGINT/SIGTERM/SIGHUP

CodeRabbit follow-up review on c727bf5f flagged that process.on('exit')
does not fire on signal-driven termination. An installer is exactly
the kind of process users abort mid-run with Ctrl+C, so without
explicit signal handlers the staged tmp dirs in STAGED_DIRS would be
left behind until the OS reaps tmpdir.

Fix: ensureExitCleanup now also registers process.once handlers for
SIGINT, SIGTERM, SIGHUP. Each handler runs cleanupStagedSkills then
re-raises the same signal via process.kill(pid, sig) so the OS-default
handler takes over and the parent shell sees the correct exit code
(130 for SIGINT, etc.) — CI scripts and interactive users see the
abort the way they expect.

Test: spawns a child that stages a tmp dir then blocks; parent
captures the staged path from stdout, sends SIGINT, asserts (a) the
staged dir is gone after child exit, (b) child exits via the signal
not via code 0. Skipped on Windows (signal semantics differ; the
natural-exit cleanup test covers the Windows CI matrix).

Total: 26 tests in install-minimal.test.cjs (was 25).
This commit is contained in:
Tom Boucher
2026-04-27 00:13:20 -04:00
committed by GitHub
parent ab5ad6c8bc
commit 9472f343db
7 changed files with 844 additions and 30 deletions

View File

@@ -6,6 +6,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased](https://github.com/gsd-build/get-shit-done/compare/v1.38.5...HEAD)
### Added
- `--minimal` install flag (alias `--core-only`) writes only the main-loop core skills
(`new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, `help`, `update`) and
zero `gsd-*` subagents. Cuts cold-start system-prompt overhead from ~12k tokens to
~700, useful for local LLMs with 32K–128K context (Sonnet 4.6 / Opus 4.7 don't need
it). Re-run `gsd update` without `--minimal` to expand to the full surface. The
install manifest now records `mode: "minimal" | "full"`. (#2762)
## [1.38.5] - 2026-04-25
### Fixed

View File

@@ -197,6 +197,57 @@ The GSD SDK CLI (`gsd-sdk`) is installed automatically (required by `/gsd-*` com
</details>
<details>
<summary><strong>Minimal Install (local LLMs and token-billed APIs)</strong></summary>
GSD ships 86 skills and 33 subagents. Every runtime (Claude Code, OpenCode, etc.) eagerly enumerates skill descriptions and subagent descriptions into the system prompt on **every turn** — about **~12k tokens** of fixed overhead before you've typed anything. Frontier models with large context (Sonnet 4.6, Opus 4.7 — 200K to 1M ctx) absorb that without a noticeable hit. **Local LLMs with 32K–128K context, and any model where you're paying per token, will feel it.**
Pass `--minimal` (alias `--core-only`) to install only the **main GSD loop**:
```bash
npx get-shit-done-cc --claude --global --minimal
# or any other runtime — works the same
npx get-shit-done-cc --opencode --global --minimal
```
What you get:
| Surface | Default install | `--minimal` install |
|---|---|---|
| Skills | 86 (`new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, …82 more) | **6** (`new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, `help`, `update`) |
| Subagents | 33 `gsd-*` agents | **0** |
| Cold-start system-prompt overhead | ~12k tokens | **~700 tokens** (≥94% reduction) |
| Manifest mode field | `"full"` | `"minimal"` |
The 6 core skills are exactly the ones you need to drive a project from zero: `new-project` to bootstrap, then the `discuss → plan → execute` loop, plus `help` for discovery and `update` to upgrade later.
**This is a hard floor, not a ceiling.** Each `/gsd-*` command you start using and each subagent it dispatches loads its body content into the conversation for that turn — that's normal token use, not eager overhead. But:
> [!IMPORTANT]
> **The savings disappear the moment you re-install without `--minimal`.** Running `npx get-shit-done-cc@latest` (or `gsd update` from inside a session) without the flag puts the full 86-skill / 33-agent surface back on disk, and every subsequent session pays the full ~12k-token floor again. If you want to stay minimal, **always pass `--minimal` when updating**:
>
> ```bash
> npx get-shit-done-cc@latest --claude --global --minimal
> ```
>
> Need a specific skill that isn't in the core set (e.g., `gsd-autonomous`, `gsd-ship`, `gsd-debug`)? You have two options:
> 1. **Permanent expand:** re-install without `--minimal` to get the full surface (and the full token floor).
> 2. **One-shot:** run the slash command's underlying logic by reading the source from `commands/gsd/<name>.md` in the GSD package and executing it manually — no install change needed.
>
> Tip: `cat ~/.claude/get-shit-done/.gsd-manifest.json | jq .mode` (or `gsd-file-manifest.json` depending on layout) confirms which mode you're in.
When to use `--minimal`:
- Local model with 32K–128K context (Qwen3, Llama, Mistral, etc.)
- Token-metered API where every turn matters
- Throwaway directory or non-GSD project where you want `/gsd-new-project` available without paying for the rest
- CI runners or ephemeral containers where install footprint matters
When **not** to use `--minimal`:
- Active GSD project where you regularly invoke the broader command set (`autonomous`, `ship`, `code-review`, `debug`, etc.) — re-installing each time is friction without payoff.
- Frontier models with 200K–1M context — the savings are noise.
</details>
<details>
<summary><strong>Development Installation</strong></summary>

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-04-23",
"generated": "2026-04-27",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -278,6 +278,7 @@
"graphify.cjs",
"gsd2-import.cjs",
"init.cjs",
"install-profiles.cjs",
"intel.cjs",
"learnings.cjs",
"milestone.cjs",

View File

@@ -361,7 +361,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (30 shipped)
## CLI Modules (31 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -381,6 +381,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` |
| `gsd2-import.cjs` | External-plan ingest for `/gsd-from-gsd2` |
| `init.cjs` | Compound context loading for each workflow type |
| `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs |
| `intel.cjs` | Codebase intel store backing `/gsd-intel` and `gsd-intel-updater` |
| `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` |
| `milestone.cjs` | Milestone archival, requirements marking |

View File

@@ -0,0 +1,132 @@
/**
* Install profiles — single source of truth for which skills/agents
* are written to the runtime config dirs.
*
* Background: every installed `gsd-*` skill costs eager system-prompt
* tokens because runtimes (Claude Code, opencode, etc.) enumerate
* skill descriptions in `<available_skills>` on every turn. With 86
* skills + 33 agents the floor is ~12k tokens per turn, which is a
* meaningful tax for local LLMs with 32K–128K context. Frontier
* models (Sonnet 4.6 / Opus 4.7 with 200K–1M ctx) don't feel it.
*
* The `minimal` profile installs the main GSD loop only:
* new-project → discuss-phase → plan-phase → execute-phase
* plus `help` (discoverability) and `update` (upgrade path).
*
* Users opt into minimal via `--minimal` on the install CLI.
* Default install (`full`) is unchanged — back-compat preserved.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const MINIMAL_SKILL_ALLOWLIST = Object.freeze([
'new-project',
'discuss-phase',
'plan-phase',
'execute-phase',
'help',
'update',
]);
const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST);
function isMinimalMode(mode) {
return mode === 'minimal';
}
function shouldInstallSkill(skillBaseName, mode) {
if (!isMinimalMode(mode)) return true;
return MINIMAL_ALLOWLIST_SET.has(skillBaseName);
}
// Stage dirs created during this process — cleaned up on exit.
// 13 runtime dispatch sites in install.js can each call stageSkillsForMode,
// so accumulating them in a single set avoids leaks without forcing each
// site to track its own cleanup handle.
const STAGED_DIRS = new Set();
let exitHandlerRegistered = false;
function cleanupStagedSkills() {
for (const dir of STAGED_DIRS) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Best-effort: missing dir or permission error shouldn't crash a
// successful install. The OS reaps tmpdir eventually.
}
}
STAGED_DIRS.clear();
}
// Signals we register a cleanup handler for in addition to the natural
// 'exit' event. `process.on('exit')` does NOT fire on these — an installer
// is exactly the kind of process users abort mid-run, so without explicit
// signal handling Ctrl+C would leave staged tmp dirs behind.
const CLEANUP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
function ensureExitCleanup() {
if (exitHandlerRegistered) return;
exitHandlerRegistered = true;
process.on('exit', cleanupStagedSkills);
for (const sig of CLEANUP_SIGNALS) {
// `once` so re-raising the signal below isn't intercepted by us a second
// time — the OS-default handler should take over and exit with the right
// status code (so CI sees the abort, scripts see 130 for SIGINT, etc.).
process.once(sig, () => {
cleanupStagedSkills();
process.kill(process.pid, sig);
});
}
}
/**
* Stage a filtered copy of the source commands/gsd directory when in
* minimal mode. All runtime-specific copy fns recurse a source dir,
* so filtering at the source point lets every copy fn stay unchanged
* (DRY: one filter, not 12).
*
* In full mode this is a no-op — the original srcDir is returned.
*
* Cleanup: the staged dir is automatically removed on process exit.
* If the copy loop throws mid-flight, the partially-populated dir is
* removed and the error re-raised, so callers never see an orphan.
*
* @param {string} srcDir absolute path to commands/gsd
* @param {string} mode 'full' | 'minimal'
* @returns {string} path to use (original or staged tmp)
*/
function stageSkillsForMode(srcDir, mode) {
if (!isMinimalMode(mode)) return srcDir;
if (!fs.existsSync(srcDir)) return srcDir;
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-minimal-skills-'));
try {
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const baseName = entry.name.replace(/\.md$/, '');
if (!shouldInstallSkill(baseName, mode)) continue;
fs.copyFileSync(
path.join(srcDir, entry.name),
path.join(stageDir, entry.name),
);
}
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
throw err;
}
STAGED_DIRS.add(stageDir);
ensureExitCleanup();
return stageDir;
}
module.exports = {
MINIMAL_SKILL_ALLOWLIST,
isMinimalMode,
shouldInstallSkill,
stageSkillsForMode,
cleanupStagedSkills,
};

View File

@@ -0,0 +1,579 @@
/**
* Tests for `--minimal` install profile (#2762).
*
* Verifies:
* 1. The install-profiles allowlist contains exactly the documented core
* main-loop skills.
* 2. stageSkillsForMode() filters source dir entries to the allowlist when
* mode === 'minimal' and is a no-op for mode === 'full'.
* 3. Filtering is by basename (mirrors how copyCommandsAs*Skills derives
* skill names).
* 4. shouldInstallSkill() agrees with stageSkillsForMode().
*
* Note: end-to-end install tests (spawning bin/install.js with --minimal) are
* intentionally out of scope here — they require a fully-mocked runtime config
* dir which would duplicate antigravity-install.test.cjs scaffolding. The unit
* tests below pin the allowlist contract; the dispatch sites in install.js
* call stageSkillsForMode unconditionally so any breakage there shows up as
* a stage_dir/source_dir mismatch covered by these tests.
*/
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 {
MINIMAL_SKILL_ALLOWLIST,
isMinimalMode,
shouldInstallSkill,
stageSkillsForMode,
cleanupStagedSkills,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => {
test('contains exactly the main-loop core (no drift without test update)', () => {
assert.deepStrictEqual(
[...MINIMAL_SKILL_ALLOWLIST].sort(),
[
'discuss-phase',
'execute-phase',
'help',
'new-project',
'plan-phase',
'update',
],
);
});
test('is frozen (mutations throw in strict mode)', () => {
assert.ok(Object.isFrozen(MINIMAL_SKILL_ALLOWLIST));
});
test('every allowlisted skill exists in commands/gsd/', () => {
const commandsDir = path.join(__dirname, '..', 'commands', 'gsd');
for (const name of MINIMAL_SKILL_ALLOWLIST) {
const file = path.join(commandsDir, `${name}.md`);
assert.ok(
fs.existsSync(file),
`core skill ${name} is allowlisted but ${file} does not exist`,
);
}
});
});
describe('install-profiles: isMinimalMode', () => {
test('returns true only for the literal string "minimal"', () => {
assert.strictEqual(isMinimalMode('minimal'), true);
assert.strictEqual(isMinimalMode('full'), false);
assert.strictEqual(isMinimalMode(''), false);
assert.strictEqual(isMinimalMode(undefined), false);
assert.strictEqual(isMinimalMode(null), false);
assert.strictEqual(isMinimalMode('MINIMAL'), false);
});
});
describe('install-profiles: shouldInstallSkill', () => {
test('full mode admits every skill', () => {
assert.strictEqual(shouldInstallSkill('plan-phase', 'full'), true);
assert.strictEqual(shouldInstallSkill('autonomous', 'full'), true);
assert.strictEqual(shouldInstallSkill('arbitrary-future-name', 'full'), true);
});
test('minimal mode admits only allowlisted skills', () => {
for (const name of MINIMAL_SKILL_ALLOWLIST) {
assert.strictEqual(shouldInstallSkill(name, 'minimal'), true, name);
}
for (const denied of ['autonomous', 'do', 'progress', 'next', 'fast', 'quick']) {
assert.strictEqual(shouldInstallSkill(denied, 'minimal'), false, denied);
}
});
test('minimal mode rejects allowlist names with .md suffix (callers must strip)', () => {
assert.strictEqual(shouldInstallSkill('plan-phase.md', 'minimal'), false);
});
});
describe('install-profiles: stageSkillsForMode', () => {
function createFixtureSkillsDir() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fixture-'));
fs.writeFileSync(path.join(tmp, 'plan-phase.md'), '# plan-phase\n');
fs.writeFileSync(path.join(tmp, 'execute-phase.md'), '# execute-phase\n');
fs.writeFileSync(path.join(tmp, 'autonomous.md'), '# autonomous\n');
fs.writeFileSync(path.join(tmp, 'do.md'), '# do\n');
fs.writeFileSync(path.join(tmp, 'help.md'), '# help\n');
fs.writeFileSync(path.join(tmp, 'new-project.md'), '# new-project\n');
fs.writeFileSync(path.join(tmp, 'discuss-phase.md'), '# discuss-phase\n');
fs.writeFileSync(path.join(tmp, 'update.md'), '# update\n');
fs.writeFileSync(path.join(tmp, 'progress.md'), '# progress\n');
return tmp;
}
test('full mode returns the original src dir unchanged', () => {
const src = createFixtureSkillsDir();
try {
const result = stageSkillsForMode(src, 'full');
assert.strictEqual(result, src);
} finally {
fs.rmSync(src, { recursive: true, force: true });
}
});
test('minimal mode returns a new dir containing only allowlisted skills', () => {
const src = createFixtureSkillsDir();
let staged;
try {
staged = stageSkillsForMode(src, 'minimal');
assert.notStrictEqual(staged, src);
const stagedFiles = fs.readdirSync(staged).sort();
assert.deepStrictEqual(stagedFiles, [
'discuss-phase.md',
'execute-phase.md',
'help.md',
'new-project.md',
'plan-phase.md',
'update.md',
]);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) fs.rmSync(staged, { recursive: true, force: true });
}
});
test('minimal mode preserves file content byte-for-byte', () => {
const src = createFixtureSkillsDir();
let staged;
try {
staged = stageSkillsForMode(src, 'minimal');
const original = fs.readFileSync(path.join(src, 'plan-phase.md'), 'utf8');
const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8');
assert.strictEqual(copied, original);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) fs.rmSync(staged, { recursive: true, force: true });
}
});
test('minimal mode against non-existent source returns the source path (caller handles missing)', () => {
const ghost = path.join(os.tmpdir(), 'gsd-stage-does-not-exist-' + Date.now());
const result = stageSkillsForMode(ghost, 'minimal');
assert.strictEqual(result, ghost);
});
test('minimal mode skips non-md files and subdirectories', () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-mixed-'));
let staged;
try {
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
fs.writeFileSync(path.join(src, 'README.txt'), 'not a skill\n');
fs.mkdirSync(path.join(src, 'nested-dir'));
fs.writeFileSync(path.join(src, 'nested-dir', 'plan-phase.md'), '# nested\n');
staged = stageSkillsForMode(src, 'minimal');
const stagedFiles = fs.readdirSync(staged);
assert.deepStrictEqual(stagedFiles, ['plan-phase.md']);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) fs.rmSync(staged, { recursive: true, force: true });
}
});
});
describe('install-profiles: cleanupStagedSkills', () => {
test('removes every staged dir created during this process', () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-cleanup-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
try {
const a = stageSkillsForMode(src, 'minimal');
const b = stageSkillsForMode(src, 'minimal');
assert.notStrictEqual(a, b, 'each call should mkdtemp a fresh dir');
assert.ok(fs.existsSync(a));
assert.ok(fs.existsSync(b));
cleanupStagedSkills();
assert.ok(!fs.existsSync(a), 'first staged dir should be removed');
assert.ok(!fs.existsSync(b), 'second staged dir should be removed');
} finally {
fs.rmSync(src, { recursive: true, force: true });
}
});
test('is idempotent — calling twice does not throw', () => {
cleanupStagedSkills();
cleanupStagedSkills();
});
test('full mode does not register a staged dir (no leak source for default install)', () => {
cleanupStagedSkills();
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fullmode-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
try {
const before = listTmpStageDirs();
const result = stageSkillsForMode(src, 'full');
assert.strictEqual(result, src, 'full mode returns original src unchanged');
cleanupStagedSkills();
const after = listTmpStageDirs();
// No new gsd-minimal-skills- dirs should have been created.
assert.deepStrictEqual(after, before);
} finally {
fs.rmSync(src, { recursive: true, force: true });
}
});
test('exit handler registers exactly once across many stageSkillsForMode calls', () => {
cleanupStagedSkills();
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-exit-handler-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
try {
const before = process.listenerCount('exit');
// Call 5x — install.js has 13 dispatch sites, so this matters.
for (let i = 0; i < 5; i++) stageSkillsForMode(src, 'minimal');
const after = process.listenerCount('exit');
// Either 0 (handler was already registered by an earlier test) or +1.
// Never +5.
assert.ok(after - before <= 1, `expected <=1 new exit listener, got ${after - before}`);
} finally {
fs.rmSync(src, { recursive: true, force: true });
cleanupStagedSkills();
}
});
test('SIGINT triggers cleanup and re-raises the signal (Ctrl+C path)', () => {
// Run a child process that calls stageSkillsForMode then sleeps; send it
// SIGINT and assert (a) the child exits with the SIGINT-induced status
// (signal: 'SIGINT' OR exit code 130 depending on platform), and (b) the
// staged tmp dir is gone afterwards. Skipping on Windows where signal
// semantics differ — the unit test for natural `exit` covers Linux/macOS
// CI matrix, and signal handling is a Unix concern in practice.
if (process.platform === 'win32') return;
const { spawnSync } = require('child_process');
const probe = `
const { stageSkillsForMode } = require(${JSON.stringify(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'),
)});
const fs = require('fs');
const path = require('path');
const os = require('os');
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-sig-src-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\\n');
const staged = stageSkillsForMode(src, 'minimal');
// Print the staged path so the parent knows what to look for, then
// signal readiness and block until SIGINT.
process.stdout.write(staged + '\\n');
setInterval(() => {}, 1000);
`;
// Spawn detached so we control the signal cleanly.
const child = require('child_process').spawn(process.execPath, ['-e', probe], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let staged = '';
child.stdout.on('data', (chunk) => {
staged += chunk.toString();
if (!staged.includes('\n')) return;
// Once we have the staged path, send SIGINT and check on exit.
child.kill('SIGINT');
});
return new Promise((resolve, reject) => {
child.on('exit', (code, signal) => {
try {
const stagedPath = staged.split('\n')[0];
assert.ok(
stagedPath && stagedPath.startsWith(os.tmpdir()),
`child should have printed a staged path under tmpdir, got: ${JSON.stringify(stagedPath)}`,
);
assert.ok(
!fs.existsSync(stagedPath),
`staged dir should have been cleaned up on SIGINT, but ${stagedPath} still exists`,
);
// The child should have exited *because* of the signal, not 0.
assert.ok(
signal === 'SIGINT' || code === 130 || code === null,
`child should exit via SIGINT, got code=${code} signal=${signal}`,
);
resolve();
} catch (err) {
reject(err);
}
});
child.on('error', reject);
});
});
test('mid-copy failure removes the partial staged dir and re-throws', () => {
cleanupStagedSkills();
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fail-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
try {
// Force a failure mid-loop by making fs.copyFileSync throw on the
// second allowlisted file. Capture the staged dir from the first
// successful call (we can't see it directly, so we count tmp dirs).
const before = listTmpStageDirs();
const realCopy = fs.copyFileSync;
let copyCount = 0;
fs.copyFileSync = (s, d) => {
copyCount++;
if (copyCount === 2) throw new Error('synthetic disk full');
return realCopy(s, d);
};
// Need at least 2 allowlisted files in src for the second copy to fire.
fs.writeFileSync(path.join(src, 'execute-phase.md'), '# x\n');
try {
assert.throws(() => stageSkillsForMode(src, 'minimal'), /synthetic disk full/);
} finally {
fs.copyFileSync = realCopy;
}
const after = listTmpStageDirs();
// Partial dir must have been cleaned up by stageSkillsForMode itself
// before re-throwing — so the count is unchanged.
assert.deepStrictEqual(after, before, 'partial staged dir should be removed on throw');
} finally {
fs.rmSync(src, { recursive: true, force: true });
cleanupStagedSkills();
}
});
});
// Helper for the cleanup tests above. Listed as a sibling so the describe
// block stays focused on the contract assertions.
function listTmpStageDirs() {
try {
return fs
.readdirSync(os.tmpdir())
.filter((n) => n.startsWith('gsd-minimal-skills-'))
.sort();
} catch {
return [];
}
}
// ─── End-to-end install regression: full → minimal Codex downgrade ─────────
//
// CodeRabbit (#2764) flagged that switching from full to minimal on Codex
// would leave stale `agents/gsd-*.toml` files plus `[agents.gsd-*]`
// sections in `config.toml`. This test simulates a previous full Codex
// install (a few stale agent files + an existing GSD-marked config.toml)
// and confirms that `--minimal` cleans them up.
describe('install: Codex full → minimal downgrade cleans stale agent state', () => {
const { spawnSync } = require('child_process');
const installScript = path.join(__dirname, '..', 'bin', 'install.js');
function makeStaleCodexInstall(targetDir) {
const agentsDir = path.join(targetDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
// Pretend a previous full install left these behind:
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), 'stale\n');
fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), 'stale\n');
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.toml'), 'name = "gsd-executor"\n');
fs.writeFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'name = "gsd-planner"\n');
// Also drop an unrelated user agent to confirm we don't touch it:
fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n');
// A previously-written codex config.toml with both GSD and user content,
// matching the marker format produced by installCodexConfig.
const codexConfig = [
'# user-owned setting',
'model = "gpt-5"',
'',
'# GSD Agent Configuration — managed by get-shit-done installer',
'[agents.gsd-executor]',
'cmd = "stale"',
'',
'[agents.gsd-planner]',
'cmd = "stale"',
'',
].join('\n');
fs.writeFileSync(path.join(targetDir, 'config.toml'), codexConfig);
}
test('--minimal removes stale .toml agents and strips [agents.gsd-*] from config.toml', () => {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-downgrade-'));
try {
makeStaleCodexInstall(targetDir);
const result = spawnSync(
process.execPath,
[installScript, '--codex', '--global', '--config-dir', targetDir, '--minimal'],
{ encoding: 'utf8' },
);
// Install may print the SDK-not-found warning at the end (the worktree
// doesn't always have sdk/dist built). That's a non-fatal post-step;
// skill/agent staging happens before it. We assert state, not exit code.
assert.ok(result.stdout || result.stderr, 'install should produce some output');
const agentsDir = path.join(targetDir, 'agents');
const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : [];
// Stale gsd-* files (.md AND .toml) must be gone:
assert.ok(!remaining.includes('gsd-executor.md'), 'stale gsd-executor.md should be removed');
assert.ok(!remaining.includes('gsd-planner.md'), 'stale gsd-planner.md should be removed');
assert.ok(!remaining.includes('gsd-executor.toml'), 'stale gsd-executor.toml should be removed');
assert.ok(!remaining.includes('gsd-planner.toml'), 'stale gsd-planner.toml should be removed');
// User-owned agent must survive:
assert.ok(remaining.includes('my-custom-agent.md'), 'user agent should be preserved');
// config.toml: GSD section gone, user content preserved
const configPath = path.join(targetDir, 'config.toml');
if (fs.existsSync(configPath)) {
const config = fs.readFileSync(configPath, 'utf8');
assert.ok(!config.includes('[agents.gsd-executor]'), 'gsd-executor section stripped');
assert.ok(!config.includes('[agents.gsd-planner]'), 'gsd-planner section stripped');
assert.ok(config.includes('model = "gpt-5"'), 'user setting preserved');
}
// (If config.toml was GSD-only it'd be removed entirely, which is also acceptable —
// in this fixture there's user content so the file should still exist.)
assert.ok(fs.existsSync(configPath), 'config.toml with user content should remain');
} finally {
fs.rmSync(targetDir, { recursive: true, force: true });
}
});
});
// ─── Claude full → minimal downgrade ────────────────────────────────────────
//
// Mirrors the Codex test for the most common runtime. The Codex test pins
// the .toml + config.toml cleanup; this one pins the .md-only path that
// every non-Codex runtime shares.
describe('install: Claude full → minimal downgrade removes stale agents', () => {
const { spawnSync } = require('child_process');
const installScript = path.join(__dirname, '..', 'bin', 'install.js');
test('--minimal removes stale gsd-*.md agents but preserves user-owned agents', () => {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-claude-downgrade-'));
try {
const agentsDir = path.join(targetDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
// Fake a previous full install + a user-owned agent:
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), 'stale\n');
fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), 'stale\n');
fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n');
spawnSync(
process.execPath,
[installScript, '--claude', '--global', '--config-dir', targetDir, '--minimal'],
{ encoding: 'utf8' },
);
const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : [];
assert.ok(!remaining.includes('gsd-executor.md'), 'stale gsd-executor.md removed');
assert.ok(!remaining.includes('gsd-planner.md'), 'stale gsd-planner.md removed');
assert.ok(remaining.includes('my-custom-agent.md'), 'user agent preserved');
// No `gsd-*` files at all should remain:
const stragglers = remaining.filter((f) => f.startsWith('gsd-'));
assert.deepStrictEqual(stragglers, [], 'no gsd-* files should remain in agents/');
} finally {
fs.rmSync(targetDir, { recursive: true, force: true });
}
});
});
// ─── Manifest mode field round-trip ─────────────────────────────────────────
//
// Locks in the contract that downstream tooling (uninstaller, drift detector,
// future profile-aware commands) can rely on the `mode` field being present
// and accurate after every install. Catches regressions in writeManifest's
// options threading.
describe('install: manifest records mode for both profiles', () => {
const { spawnSync } = require('child_process');
const installScript = path.join(__dirname, '..', 'bin', 'install.js');
function manifestModeAfterInstall(extraArgs) {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-mode-'));
try {
spawnSync(
process.execPath,
[installScript, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
{ encoding: 'utf8' },
);
const manifestPath = path.join(targetDir, 'gsd-file-manifest.json');
if (!fs.existsSync(manifestPath)) {
return { mode: '<no manifest>', skillCount: 0, agentCount: 0 };
}
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const skillCount = new Set(
Object.keys(m.files || {})
.filter((k) => k.startsWith('skills/'))
.map((k) => k.split('/')[1]),
).size;
const agentCount = Object.keys(m.files || {}).filter((k) => k.startsWith('agents/')).length;
return { mode: m.mode, skillCount, agentCount };
} finally {
fs.rmSync(targetDir, { recursive: true, force: true });
}
}
test('default install records mode: "full" with the full skill+agent count', () => {
const r = manifestModeAfterInstall([]);
assert.strictEqual(r.mode, 'full');
assert.ok(r.skillCount > 6, `full install should have >6 skills, got ${r.skillCount}`);
assert.ok(r.agentCount > 0, `full install should have agents, got ${r.agentCount}`);
});
test('--minimal records mode: "minimal" with exactly 6 skills and 0 agents', () => {
const r = manifestModeAfterInstall(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 6);
assert.strictEqual(r.agentCount, 0);
});
test('--core-only is an alias for --minimal', () => {
const r = manifestModeAfterInstall(['--core-only']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 6);
assert.strictEqual(r.agentCount, 0);
});
});
// ─── Allowlist scope guard ─────────────────────────────────────────────────
//
// Catches drift in the opposite direction: someone adds an off-loop command
// to the allowlist, or removes a main-loop command. The first test in this
// file asserts the exact set; these add semantic guard rails so the failure
// mode is clear ("autonomous shouldn't be in core") rather than just a diff.
describe('install-profiles: allowlist scope guards', () => {
test('every main-loop command is in the allowlist', () => {
for (const required of ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase']) {
assert.ok(
shouldInstallSkill(required, 'minimal'),
`main-loop command "${required}" must be in MINIMAL_SKILL_ALLOWLIST`,
);
}
});
test('off-loop convenience commands are NOT in the allowlist', () => {
// These exist in commands/gsd/ and are valid skills, but they're not part
// of the core main loop. If any of these slip into the allowlist the
// floor erodes.
for (const offLoop of [
'autonomous',
'ship',
'do',
'progress',
'next',
'fast',
'quick',
'debug',
'code-review',
'verify-work',
]) {
assert.ok(
!shouldInstallSkill(offLoop, 'minimal'),
`off-loop command "${offLoop}" must NOT be in MINIMAL_SKILL_ALLOWLIST`,
);
}
});
test('mode is required to be a known string — defensive against typos', () => {
// Any non-'minimal' mode should admit everything (full-mode behavior).
// This catches a future bug where someone adds a 'compact' or 'tier2'
// mode and forgets to wire up the predicate.
for (const unknownMode of ['compact', 'tier2', 'CORE', 'Minimal', 'mini']) {
assert.ok(
shouldInstallSkill('autonomous', unknownMode),
`unknown mode "${unknownMode}" should fall through to full behavior`,
);
}
});
});