merge: sync main into PR #3114 and keep canonical next/profile commands

This commit is contained in:
Tom Boucher
2026-05-04 23:32:42 -04:00
179 changed files with 5796 additions and 1860 deletions

View File

@@ -26,7 +26,8 @@ const CONFIG_MUTATION_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'co
// #2653 — allowlist moved to shared schema module.
const CONFIG_SCHEMA_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-schema.ts');
const CONFIG_GATES_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-gates.ts');
const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'index.ts');
const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'registry-assembly.ts');
const QUERY_ASSEMBLY_DESCRIPTOR_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'registry-assembly-descriptor.ts');
describe('plan-phase decision-coverage gate (#2492)', () => {
const md = fs.readFileSync(PLAN_PHASE, 'utf-8');
@@ -168,10 +169,11 @@ describe('SDK wiring for #2492 gates', () => {
);
});
test('query index.ts registers the new handlers', () => {
test('query registry assembly registers the new handlers', () => {
const c = fs.readFileSync(QUERY_INDEX_TS, 'utf-8');
assert.ok(c.includes('check.decision-coverage-plan'), 'check.decision-coverage-plan handler must be registered');
assert.ok(c.includes('check.decision-coverage-verify'), 'check.decision-coverage-verify handler must be registered');
assert.ok(c.includes('decisions.parse'), 'decisions.parse handler must be registered');
const d = fs.readFileSync(QUERY_ASSEMBLY_DESCRIPTOR_TS, 'utf-8');
assert.ok(c.includes('REGISTRY_ASSEMBLY_PLAN'), 'registry assembly must be driven by declarative plan');
assert.ok(d.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decision-coverage handlers must be sourced from verify-decision catalog');
assert.ok(d.includes("{ kind: 'static', key: 'VERIFY_DECISION_STATIC_CATALOG' }"), 'verify-decision catalog must be present in assembly plan');
});
});

View File

@@ -6,8 +6,10 @@
/**
* Bug #2524: gsd-sdk query --ws <name> silently ignores the workstream flag.
* Tests that --ws is forwarded through the call chain:
* cli.ts -> registry.dispatch() -> planningPaths()
*
* This file is structural/static coverage only (source-file assertions).
* Runtime forwarding coverage for the query adapter path lives in:
* sdk/src/query/query-cli-adapter.test.ts
*
* Uses static source-file text assertions (no sdk/dist/ build required in CI).
*/
@@ -88,12 +90,6 @@ describe('QueryRegistry.dispatch() workstream threading', () => {
// ─── Layer 1: CLI forwards args.ws to registry.dispatch() ─────────────────
describe('CLI forwards --ws to registry.dispatch()', () => {
test('cli.ts passes args.ws as the workstream argument to registry.dispatch()', () => {
assert.ok(
cliTs.includes('registry.dispatch(matched.cmd, matched.args, args.projectDir, args.ws)'),
'cli.ts must forward args.ws to registry.dispatch() as the workstream argument',
);
});
test('cli.ts defines a ws field in ParsedCliArgs', () => {
assert.ok(
@@ -108,4 +104,5 @@ describe('CLI forwards --ws to registry.dispatch()', () => {
'cli.ts query permissive parser must handle the --ws flag',
);
});
});

View File

@@ -302,6 +302,28 @@ describe('bug #3044: localized doc sets also scrubbed', () => {
// ─── Replacement commands are documented ────────────────────────────────────
describe('bug #3094: progress routing does not reference removed /gsd-list-phase-assumptions', () => {
test('get-shit-done/workflows/progress.md has no /gsd-list-phase-assumptions token', () => {
const content = read('get-shit-done/workflows/progress.md');
const tokens = extractSlashCommandTokens(content);
assert.equal(
tokens.has('/gsd-list-phase-assumptions'),
false,
'progress.md must not recommend removed /gsd-list-phase-assumptions'
);
});
test('progress.md pre-planning guidance uses /gsd-discuss-phase instead', () => {
const content = read('get-shit-done/workflows/progress.md');
const tokens = extractSlashCommandTokens(content);
assert.equal(
tokens.has('/gsd-discuss-phase'),
true,
'progress.md should route pre-planning assumption checks via /gsd-discuss-phase'
);
});
});
describe('replacement commands appear where the deleted ones used to live', () => {
test('docs/issue-driven-orchestration.md uses /gsd-workspace --new (not /gsd-new-workspace)', () => {
const content = read('docs/issue-driven-orchestration.md');

View File

@@ -0,0 +1,57 @@
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('bug #3043: milestone complete respects explicit version scope', () => {
test('milestone.complete v3.6 uses v3.6 phases even when STATE milestone is v3.5', () => {
const tmpDir = createTempProject('gsd-bug-3043-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v3.5\n---\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## 🚧 v3.5 Paused\n### Phase 103: old\n### Phase 104: old2\n\n## 🚧 v3.6 Current\n### Phase 108: new\n',
);
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
const oldDirA = path.join(tmpDir, '.planning', 'phases', '103.old');
const oldDirB = path.join(tmpDir, '.planning', 'phases', '104.old');
const newDir = path.join(tmpDir, '.planning', 'phases', '108.new');
fs.mkdirSync(oldDirA, { recursive: true });
fs.mkdirSync(oldDirB, { recursive: true });
fs.mkdirSync(newDir, { recursive: true });
fs.writeFileSync(path.join(oldDirA, 'SUMMARY.md'), 'one-liner: old milestone A\n\n## Summary\nold\n');
fs.writeFileSync(path.join(oldDirB, 'SUMMARY.md'), 'one-liner: old milestone B\n\n## Summary\nold\n');
fs.writeFileSync(path.join(newDir, 'SUMMARY.md'), 'one-liner: new milestone\n\n## Summary\nnew\n');
const result = runGsdTools(['milestone', 'complete', 'v3.6', '--raw'], tmpDir);
assert.equal(result.success, true, result.error || result.output);
const payload = JSON.parse(result.output);
assert.equal(payload.version, 'v3.6');
assert.equal(payload.phases, 1, `expected v3.6 to scope to one phase, got ${payload.phases}`);
} finally {
cleanup(tmpDir);
}
});
test('milestone.complete fails when explicit milestone version resolves no phases', () => {
const tmpDir = createTempProject('gsd-bug-3043-empty-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v1.0\n---\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## 🚧 v1.0\n### Phase 1: foundation\n',
);
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const result = runGsdTools(['milestone', 'complete', 'v9.9', '--raw'], tmpDir);
assert.equal(result.success, false, 'expected command to fail when no phases match explicit version');
assert.match(result.error || '', /no phases|phase/i);
} finally {
cleanup(tmpDir);
}
});
});

View File

@@ -0,0 +1,62 @@
'use strict';
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, '..', 'get-shit-done', 'workflows');
function read(rel) {
return fs.readFileSync(path.join(ROOT, rel), 'utf8');
}
function extractFindingsProbesFromBashBlocks(markdown) {
const probes = [];
const fenceRe = /```bash\n([\s\S]*?)```/g;
let fenceMatch;
while ((fenceMatch = fenceRe.exec(markdown)) !== null) {
const block = fenceMatch[1];
const baseLine = markdown.slice(0, fenceMatch.index).split('\n').length;
const lines = block.split('\n');
lines.forEach((line, idx) => {
if (!line.includes('.claude/skills/')) return;
const kind = line.includes('sketch-findings-*/SKILL.md')
? 'sketch'
: line.includes('spike-findings-*/SKILL.md')
? 'spike'
: null;
if (!kind) return;
probes.push({
lineNumber: baseLine + idx,
commandText: line.trim(),
kind,
hasNonFatalGuard: /\|\|\s*true/.test(line),
});
});
}
return probes;
}
describe('bug #3072: optional sketch/spike findings probes are non-fatal', () => {
test('all sketch/spike findings SKILL.md ls probes include || true', () => {
const files = ['ui-phase.md', 'plan-phase.md', 'discuss-phase.md', 'new-project.md'];
const offenders = [];
for (const file of files) {
const content = read(file);
const probes = extractFindingsProbesFromBashBlocks(content);
for (const probe of probes) {
if (!probe.hasNonFatalGuard) {
offenders.push(`${file}:${probe.lineNumber} ${probe.commandText}`);
}
}
}
assert.deepStrictEqual(offenders, [], `missing non-fatal guard on optional findings probe:\n${offenders.join('\n')}`);
});
});

View File

@@ -1,6 +1,6 @@
/**
* Drift guard: every `gsd-sdk query <cmd>` reference in the repo must
* resolve to a handler registered in sdk/src/query/index.ts.
* resolve to a handler registered in sdk/src/query/registry-assembly.ts.
*
* The set of commands workflows/agents/commands call must equal the set
* the SDK registry exposes. New references with no handler — or handlers
@@ -13,7 +13,7 @@ const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..');
const REGISTRY_FILE = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'index.ts');
const REGISTRY_FILE = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'registry-assembly.ts');
const COMMAND_ALIASES_FILE = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs');
// Prose tokens that repeatedly appear after `gsd-sdk query` in English
@@ -43,11 +43,36 @@ function collectRegisteredNames() {
const src = fs.readFileSync(REGISTRY_FILE, 'utf8');
const names = new Set();
// Static registrations in index.ts
// Static registrations in index.ts (legacy style, may still exist)
const re = /registry\.register\(\s*['"]([^'"]+)['"]/g;
let m;
while ((m = re.exec(src)) !== null) names.add(m[1]);
// Catalog-based registrations: parse known static catalogs directly.
const catalogFileByVar = {
FOUNDATION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
STATE_SUPPORT_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
MUTATION_SURFACES_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
VERIFY_DECISION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
DECISION_ROUTING_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
DOMAIN_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-domain.ts'),
};
for (const [catalogVarName, cf] of Object.entries(catalogFileByVar)) {
try {
const catSrc = fs.readFileSync(cf, 'utf8');
const exportRe = new RegExp(`export const ${catalogVarName}:`, 'm');
if (!exportRe.test(catSrc)) continue;
const entryRe = /\[\s*['"]([^'"]+)['"]/g;
let em;
while ((em = entryRe.exec(catSrc)) !== null) {
names.add(em[1]);
}
} catch {
// File not found, skip.
}
}
// Manifest-generated family aliases registered via loop in index.ts.
// Keep this in sync with command-manifest-driven routing seams.
try {
@@ -159,7 +184,7 @@ describe('gsd-sdk query registry integration', () => {
assert.strictEqual(
offenders.length, 0,
'Referenced `gsd-sdk query <cmd>` tokens with no handler in ' +
'sdk/src/query/index.ts. Either register the handler or remove ' +
'sdk/src/query/registry-assembly.ts. Either register the handler or remove ' +
'the reference.\n\n' + offenders.join('\n')
);
});

View File

@@ -227,6 +227,43 @@ describe('milestone complete command', () => {
);
});
test('normalizes stale STATE.md narrative tails after milestone complete (#3088)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n\n## Current Position\n\nPhase: 03 — EXECUTING\nPlan: 03-02\nStatus: Executing\nLast activity: 2025-01-01 — Running phase\n\n## Operator Next Steps\n\n- Re-run /gsd-complete-milestone v1.0\n`
);
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('Phase: Milestone v1.0 complete'));
assert.ok(state.includes('Status: Awaiting next milestone'));
assert.ok(!state.includes('Re-run /gsd-complete-milestone'));
assert.ok(state.includes('/gsd-new-milestone'));
});
test('appends canonical narrative sections when STATE.md headings are missing (#3088)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n`);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('## Current Position'));
assert.ok(state.includes('Phase: Milestone v1.0 complete'));
assert.ok(state.includes('## Operator Next Steps'));
assert.ok(state.includes('/gsd-new-milestone'));
});
test('handles missing ROADMAP.md gracefully', () => {
// Only STATE.md — no ROADMAP.md, no REQUIREMENTS.md
fs.writeFileSync(

View File

@@ -21,6 +21,24 @@ function getPruneOrphanedWorktrees() {
}
// Create a minimal git repo with an initial commit on main.
function canonicalPath(p) {
try {
return fs.realpathSync.native(path.resolve(p));
} catch {
return path.resolve(p);
}
}
function listedWorktreePaths(repoDir) {
const out = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf8' });
return new Set(
out
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => canonicalPath(line.slice('worktree '.length).trim()))
);
}
function createGitRepo(dir) {
fs.mkdirSync(dir, { recursive: true });
execSync('git init', { cwd: dir, stdio: 'pipe' });
@@ -49,8 +67,8 @@ describe('pruneOrphanedWorktrees', () => {
cleanup(tmpBase);
});
// Test 1: removes a worktree whose branch is merged into main
test('removes a worktree whose branch is merged into main', () => {
// Test 1: keeps a merged worktree (destructive removal disabled by default)
test('keeps a worktree whose branch is merged into main', () => {
const repoDir = path.join(tmpBase, 'repo');
const worktreeDir = path.join(tmpBase, 'wt-merged');
@@ -72,17 +90,17 @@ describe('pruneOrphanedWorktrees', () => {
const pruneOrphanedWorktrees = getPruneOrphanedWorktrees();
pruneOrphanedWorktrees(repoDir);
// Assert: worktree directory no longer exists
// Assert: worktree directory still exists
assert.ok(
!fs.existsSync(worktreeDir),
'worktree directory should have been removed but still exists: ' + worktreeDir
fs.existsSync(worktreeDir),
'merged worktree should not be removed by default: ' + worktreeDir
);
// Assert: git worktree list no longer shows it
const listOut = execSync('git worktree list', { cwd: repoDir, encoding: 'utf8' });
// Assert: git worktree list still shows it
const listed = listedWorktreePaths(repoDir);
assert.ok(
!listOut.includes(worktreeDir),
'git worktree list still references removed worktree:\n' + listOut
listed.has(canonicalPath(worktreeDir)),
'git worktree list should still reference merged worktree'
);
});
@@ -132,11 +150,8 @@ describe('pruneOrphanedWorktrees', () => {
const pruneOrphanedWorktrees = getPruneOrphanedWorktrees();
const pruned = pruneOrphanedWorktrees(repoDir);
// process.cwd() must not appear in pruned paths
assert.ok(
!pruned.includes(process.cwd()),
'process.cwd() should never be pruned, but found in: ' + JSON.stringify(pruned)
);
// No destructive removals are performed by default
assert.deepStrictEqual(pruned, []);
// The main worktree (repoDir) itself must still exist
assert.ok(