merge: sync Kilo runtime branch with latest main

This commit is contained in:
Alex Alecu
2026-04-03 12:47:24 +03:00
43 changed files with 3868 additions and 407 deletions

View File

@@ -0,0 +1,171 @@
/**
* Augment conversion regression tests.
*
* Ensures Augment frontmatter names are emitted as plain identifiers
* (without surrounding quotes), so Augment does not treat quotes as
* literal parts of skill/subagent names.
*/
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert');
const {
convertClaudeCommandToAugmentSkill,
convertClaudeAgentToAugmentAgent,
convertClaudeToAugmentMarkdown,
} = require('../bin/install.js');
describe('convertClaudeCommandToAugmentSkill', () => {
test('writes unquoted Augment skill name in frontmatter', () => {
const input = `---
name: quick
description: Execute a quick task
---
<objective>
Test body
</objective>
`;
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-quick');
const nameMatch = result.match(/^name:\s*(.+)$/m);
assert.ok(nameMatch, 'frontmatter contains name field');
assert.strictEqual(nameMatch[1], 'gsd-quick', 'skill name is plain scalar');
assert.ok(!result.includes('name: "gsd-quick"'), 'quoted skill name is not emitted');
});
test('preserves slash for slash commands in markdown body', () => {
const input = `---
name: gsd:plan-phase
description: Plan a phase
---
Next:
/gsd:execute-phase 17
/gsd-help
gsd:progress
`;
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-plan-phase');
// Slash commands: /gsd:execute-phase -> /gsd-execute-phase
assert.ok(result.includes('/gsd-execute-phase 17'), 'slash command gsd: -> gsd-');
assert.ok(result.includes('/gsd-help'), '/gsd-help preserved');
assert.ok(result.includes('gsd-progress'), 'bare gsd: -> gsd-');
});
test('includes augment_skill_adapter block', () => {
const input = `---
name: test
description: A test skill
---
Body content.
`;
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-test');
assert.ok(result.includes('<augment_skill_adapter>'), 'adapter header present');
assert.ok(result.includes('</augment_skill_adapter>'), 'adapter footer present');
assert.ok(result.includes('launch-process'), 'launch-process tool mentioned');
assert.ok(result.includes('str-replace-editor'), 'str-replace-editor tool mentioned');
});
test('converts tool names to Augment format', () => {
const input = `---
name: test
description: Test
---
Use Bash() to run commands.
Use Edit() to modify files.
Use Read() to view files.
`;
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-test');
assert.ok(result.includes('launch-process('), 'Bash converted to launch-process');
assert.ok(result.includes('str-replace-editor('), 'Edit converted to str-replace-editor');
assert.ok(result.includes('view('), 'Read converted to view');
});
});
describe('convertClaudeAgentToAugmentAgent', () => {
test('converts agent frontmatter with unquoted name', () => {
const input = `---
name: gsd-bugfix
description: "Fix bugs automatically"
tools: Read, Write, Edit, Bash
color: green
---
<role>
You are a bug fixer.
</role>
`;
const result = convertClaudeAgentToAugmentAgent(input);
const nameMatch = result.match(/^name:\s*(.+)$/m);
assert.ok(nameMatch, 'frontmatter contains name field');
assert.strictEqual(nameMatch[1], 'gsd-bugfix', 'agent name is plain scalar');
assert.ok(!result.includes('name: "gsd-bugfix"'), 'quoted agent name is not emitted');
});
test('removes color and skills from frontmatter', () => {
const input = `---
name: gsd-test
description: Test agent
color: blue
skills:
- some-skill
tools: Read, Write
---
<role>
Test role
</role>
`;
const result = convertClaudeAgentToAugmentAgent(input);
assert.ok(result.includes('name: gsd-test'), 'name preserved');
assert.ok(result.includes('description:'), 'description preserved');
assert.ok(!result.includes('color:'), 'color removed');
assert.ok(!result.includes('skills:'), 'skills removed');
});
test('replaces CLAUDE.md with .augment/rules/', () => {
const input = `---
name: gsd-test
description: Test
---
See CLAUDE.md for details.
`;
const result = convertClaudeAgentToAugmentAgent(input);
assert.ok(result.includes('.augment/rules/'), 'CLAUDE.md replaced with .augment/rules/');
assert.ok(!result.includes('CLAUDE.md'), 'CLAUDE.md reference removed');
});
});
describe('convertClaudeToAugmentMarkdown', () => {
test('replaces Claude Code with Augment', () => {
const input = 'Use Claude Code tools for this task.';
const result = convertClaudeToAugmentMarkdown(input);
assert.strictEqual(result, 'Use Augment tools for this task.');
});
test('replaces .claude/skills/ with .augment/skills/', () => {
const input = 'Check .claude/skills/ for more info.';
const result = convertClaudeToAugmentMarkdown(input);
assert.strictEqual(result, 'Check .augment/skills/ for more info.');
});
test('normalizes gsd: to gsd-', () => {
const input = 'Run /gsd:new-project for new projects.';
const result = convertClaudeToAugmentMarkdown(input);
assert.strictEqual(result, 'Run /gsd-new-project for new projects.');
});
});

View File

@@ -0,0 +1,84 @@
/**
* GSD Tools Tests - autonomous --interactive flag
*
* Validates that the autonomous workflow and command definition
* correctly document and support the --interactive flag.
*
* Closes: #1413
*/
const { test, describe } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
describe('autonomous --interactive flag (#1413)', () => {
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'autonomous.md');
const commandPath = path.join(__dirname, '..', 'commands', 'gsd', 'autonomous.md');
test('command definition includes --interactive in argument-hint', () => {
const content = fs.readFileSync(commandPath, 'utf8');
assert.ok(content.includes('--interactive'), 'command should document --interactive flag');
assert.ok(content.includes('argument-hint:') && content.includes('--interactive'),
'argument-hint should include --interactive');
});
test('command definition describes interactive mode behavior', () => {
const content = fs.readFileSync(commandPath, 'utf8');
assert.ok(content.includes('discuss') && content.includes('inline'),
'command should describe discuss running inline');
assert.ok(content.includes('background'),
'command should mention background agents for plan+execute');
});
test('workflow parses --interactive flag', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes("--interactive") && content.includes('INTERACTIVE'),
'workflow should parse --interactive into INTERACTIVE variable');
});
test('workflow uses discuss-phase skill in interactive mode', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('gsd:discuss-phase') && content.includes('INTERACTIVE'),
'workflow should invoke gsd:discuss-phase when INTERACTIVE is set'
);
});
test('workflow dispatches plan as background agent in interactive mode', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
// Should have Agent() with run_in_background for plan
assert.ok(
content.includes('run_in_background') && content.includes('plan-phase'),
'workflow should dispatch plan-phase as background agent in interactive mode'
);
});
test('workflow dispatches execute as background agent in interactive mode', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('run_in_background') && content.includes('execute-phase'),
'workflow should dispatch execute-phase as background agent in interactive mode'
);
});
test('workflow describes pipeline parallelism in interactive mode', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('pipeline parallelism') || content.includes('Phase N+1'),
'workflow should describe overlapping discuss/execute between phases'
);
});
test('success criteria include --interactive requirements', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
const criteriaMatch = content.match(/<success_criteria>([\s\S]*?)<\/success_criteria>/);
const criteria = criteriaMatch ? criteriaMatch[1] : '';
assert.ok(criteria.includes('--interactive'),
'success criteria should include --interactive requirements');
assert.ok(criteria.includes('discuss inline'),
'success criteria should mention discuss inline');
assert.ok(criteria.includes('background agents'),
'success criteria should mention background agents');
});
});

View File

@@ -1694,3 +1694,60 @@ describe('stats command', () => {
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// check-commit command (#1395)
// ─────────────────────────────────────────────────────────────────────────────
describe('check-commit command', () => {
const { createTempGitProject } = require('./helpers.cjs');
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('allows commit when commit_docs is true', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: true })
);
const result = runGsdTools('check-commit', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.allowed, true);
});
test('allows commit when no .planning/ files staged and commit_docs is false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
// Stage a non-planning file
fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")');
execSync('git add src.js', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.allowed, true);
});
test('blocks commit when .planning/ files staged and commit_docs is false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(!result.success, 'should block commit');
assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files');
assert.ok(result.error.includes('unstage'), 'error should suggest unstage command');
});
});

View File

@@ -0,0 +1,819 @@
/**
* GSD Tools Tests - Concurrency Safety
*
* Tests for fix/concurrency-safety-1473a:
* - Planning lock integration (withPlanningLock in phase/roadmap operations)
* - readModifyWriteStateMd (atomic state updates)
* - normalizeMd behavioral equivalence (O(n) insideFence rewrite)
* - Warnings (frontmatter parse warning, stateReplaceFieldWithFallback)
* - Performance benchmarks (normalizeMd O(n) verification)
* - Snapshot tests for normalizeMd (regression detection)
* - Multi-process concurrent write tests
* - Stress tests at scale (50+ phases)
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, exec } = require('child_process');
const { promisify } = require('util');
const { performance } = require('perf_hooks');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const {
normalizeMd,
} = require('../get-shit-done/bin/lib/core.cjs');
const execAsync = promisify(exec);
// ─── Helpers ────────────────────────────────────────────────────────────────
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n${lines}\n`
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
function writeMinimalProjectMd(tmpDir) {
const sections = ['## What This Is', '## Core Value', '## Requirements'];
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
`# Project\n\n${content}`
);
}
function writeValidConfigJson(tmpDir, overrides = {}) {
const base = { model_profile: 'balanced', commit_docs: true };
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ ...base, ...overrides }, null, 2)
);
}
/**
* Generate a 50-phase project structure for stress testing.
*/
function create50PhaseProject(tmpDir, completedCount = 25) {
let roadmapContent = '# Roadmap v1.0\n\n';
for (let i = 1; i <= 50; i++) {
roadmapContent += `- [${i <= completedCount ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
}
roadmapContent += '\n';
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
roadmapContent += `### Phase ${i}: Feature ${i}\n\n`;
roadmapContent += `**Goal:** Build feature ${i}\n`;
roadmapContent += `**Requirements:** REQ-${pad}\n`;
roadmapContent += `**Plans:** 1 plans\n\n`;
roadmapContent += `Plans:\n- [${i <= completedCount ? 'x' : ' '}] ${pad}-01-PLAN.md\n\n`;
}
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
roadmapContent
);
const phasesDir = path.join(tmpDir, '.planning', 'phases');
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
const dirName = `${pad}-feature-${i}`;
const phaseDir = path.join(phasesDir, dirName);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, `${pad}-01-PLAN.md`),
`# Phase ${i} Plan 1\n\nBuild feature ${i}.\n`
);
if (i <= completedCount) {
fs.writeFileSync(
path.join(phaseDir, `${pad}-01-SUMMARY.md`),
`# Phase ${i} Plan 1 Summary\n\nFeature ${i} completed.\n`
);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Planning lock integration
// ─────────────────────────────────────────────────────────────────────────────
describe('planning lock integration', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('phase add creates and releases .planning/.lock during ROADMAP write', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
);
const result = runGsdTools('phase add Testing', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase add');
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 2, 'should be phase 2');
});
test('phase complete creates and releases .planning/.lock', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [ ] Phase 1: Foundation\n\n### Phase 1: Foundation\n**Goal:** Setup\n**Plans:** 1 plans\n\n### Phase 2: API\n**Goal:** Build\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase complete');
const output = JSON.parse(result.output);
assert.strictEqual(output.completed_phase, '1', 'phase should be completed');
});
test('roadmap update-plan-progress creates and releases .planning/.lock', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n| Phase | Plans | Status | Updated |\n|-------|-------|--------|---------|\n| 1 | 0/0 | Not started | - |\n\n### Phase 1: Foundation\n**Goal:** Setup\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after roadmap update');
});
test('lock file does NOT persist after successful phase operations', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
runGsdTools('phase add First Phase', tmpDir);
runGsdTools('phase add Second Phase', tmpDir);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should not persist after multiple operations');
});
test('phase add still works correctly with lock (behavioral regression)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n\n---\n`
);
const result = runGsdTools('phase add User Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 3, 'should be phase 3');
assert.strictEqual(output.slug, 'user-dashboard');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')),
'directory should be created'
);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. readModifyWriteStateMd (tested via CLI commands that use it)
// ─────────────────────────────────────────────────────────────────────────────
describe('readModifyWriteStateMd (via state patch)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('transforms content atomically (read + modify + write under lock)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 03\n**Status:** Planning\n**Current Plan:** 03-01\n`
);
const result = runGsdTools('state patch --Status "In progress" --"Current Plan" 03-02', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(content.includes('**Status:** In progress'), 'Status should be updated');
assert.ok(content.includes('03-02'), 'Current Plan should be updated');
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should be released after patch');
});
test('lock file cleaned up after state patch operation', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Status:** Ready\n`
);
runGsdTools('state patch --Status "In progress"', tmpDir);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after operation');
});
test('state patch still works correctly via readModifyWriteStateMd path (behavioral regression)', () => {
const stateMd = [
'# Project State',
'',
'**Current Phase:** 03',
'**Status:** Planning',
'**Current Plan:** 03-01',
'**Last Activity:** 2024-01-15',
].join('\n') + '\n';
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state patch --Status Complete --"Current Phase" 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('**Status:** Complete'), 'Status should be updated to Complete');
assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged');
});
test('two sequential state patches both persist (patch A then patch B)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n**Last Activity:** 2024-01-01\n`
);
const resultA = runGsdTools('state patch --Status "In progress"', tmpDir);
assert.ok(resultA.success, `Patch A failed: ${resultA.error}`);
const resultB = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
assert.ok(resultB.success, `Patch B failed: ${resultB.error}`);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(content.includes('**Status:** In progress'), 'Patch A (Status) should persist');
assert.ok(content.includes('01-02'), 'Patch B (Current Plan) should persist');
assert.ok(content.includes('**Last Activity:** 2024-01-01'), 'Untouched field should be preserved');
});
test('lock file does not persist after rapid sequential patches', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n`
);
runGsdTools('state patch --Status "In progress"', tmpDir);
runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
runGsdTools('state patch --Status Complete', tmpDir);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after rapid sequential patches');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Multi-process concurrent write tests
// ─────────────────────────────────────────────────────────────────────────────
describe('multi-process concurrent write tests', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('two concurrent state patches to DIFFERENT fields both persist', async () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** In progress',
'**Current Plan:** 01-01',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** Working',
'',
].join('\n')
);
const toolsPath = TOOLS_PATH;
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
const [resultA, resultB] = await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(e => e),
execAsync(cmdB, { encoding: 'utf-8' }).catch(e => e),
]);
const aOk = !(resultA instanceof Error);
const bOk = !(resultB instanceof Error);
assert.ok(aOk || bOk, 'At least one concurrent patch should succeed');
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
content.includes('Complete') || content.includes('01-02'),
`At least one concurrent patch should persist in STATE.md. Content:\n${content}`
);
if (content.includes('Complete') && content.includes('01-02')) {
assert.ok(true, 'Both concurrent patches persisted (lock serialization)');
}
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field Current Phase should survive');
assert.ok(content.includes('2025-01-01'), 'Untouched field Last Activity should survive');
});
test('lock file does not persist after concurrent operations', async () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** Planning',
'**Current Plan:** 01-01',
'',
].join('\n')
);
const toolsPath = TOOLS_PATH;
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(
!fs.existsSync(lockPath),
'STATE.md.lock should not persist after concurrent operations complete'
);
});
test('three rapid sequential patches all persist', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** Planning',
'**Current Plan:** 01-01',
'**Last Activity:** 2025-01-01',
'',
].join('\n')
);
const r1 = runGsdTools('state patch --Status "In progress"', tmpDir);
assert.ok(r1.success, `Patch 1 failed: ${r1.error}`);
const r2 = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
assert.ok(r2.success, `Patch 2 failed: ${r2.error}`);
const r3 = runGsdTools('state patch --"Last Activity" 2025-06-15', tmpDir);
assert.ok(r3.success, `Patch 3 failed: ${r3.error}`);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(content.includes('In progress'), 'Patch 1 (Status) should persist');
assert.ok(content.includes('01-02'), 'Patch 2 (Current Plan) should persist');
assert.ok(content.includes('2025-06-15'), 'Patch 3 (Last Activity) should persist');
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field should be preserved');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. normalizeMd behavioral equivalence (O(n) insideFence rewrite)
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeMd behavioral equivalence', () => {
test('simple markdown with headings and paragraphs', () => {
const input = '# Title\nSome text.\n## Section\nMore text.\n';
const result = normalizeMd(input);
assert.ok(result.includes('# Title\n\nSome text.'), 'title heading should have blank line after');
assert.ok(result.includes('\n\n## Section\n\nMore text.'), 'section heading should have blank lines around it');
assert.ok(result.endsWith('\n'), 'should end with newline');
assert.ok(!result.endsWith('\n\n'), 'should not end with double newline');
});
test('single fenced code block gets blank lines before/after', () => {
const input = 'Some text\n```js\nconst x = 1;\n```\nMore text\n';
const result = normalizeMd(input);
assert.ok(result.includes('Some text\n\n```js'), 'code block should have blank line before');
assert.ok(result.includes('```\n\nMore text'), 'code block should have blank line after');
assert.ok(result.includes('const x = 1;'), 'code content should be preserved');
});
test('multiple fenced code blocks', () => {
const input = 'Intro\n```js\nfoo();\n```\nMiddle\n```py\nbar()\n```\nEnd\n';
const result = normalizeMd(input);
assert.ok(result.includes('Intro\n\n```js'), 'first code block should have blank line before');
assert.ok(result.includes('```\n\nMiddle'), 'first code block should have blank line after');
assert.ok(result.includes('Middle\n\n```py'), 'second code block should have blank line before');
assert.ok(result.includes('```\n\nEnd'), 'second code block should have blank line after');
});
test('unclosed fence at end of file (edge case)', () => {
const input = 'Some text\n```js\nconst x = 1;\n';
const result = normalizeMd(input);
assert.ok(typeof result === 'string', 'should return a string');
assert.ok(result.includes('```js'), 'fence opener should be preserved');
assert.ok(result.includes('const x = 1;'), 'content after unclosed fence should be preserved');
assert.ok(result.endsWith('\n'), 'should end with newline');
});
test('empty string input', () => {
assert.strictEqual(normalizeMd(''), '', 'empty string should return empty string');
});
test('mixed headings + lists + fences (complex case)', () => {
const input = [
'# Title',
'## Section One',
'Paragraph text.',
'- item 1',
'- item 2',
'## Section Two',
'```bash',
'echo hello',
'```',
'After code.',
'## Section Three',
'1. First',
'2. Second',
'Done.',
].join('\n') + '\n';
const result = normalizeMd(input);
assert.ok(result.includes('\n\n## Section One\n\n'), 'Section One heading needs blank lines');
assert.ok(result.includes('\n\n## Section Two\n\n'), 'Section Two heading needs blank lines');
assert.ok(result.includes('\n\n## Section Three\n\n'), 'Section Three heading needs blank lines');
assert.ok(result.includes('Paragraph text.\n\n- item 1'), 'list should have blank line before');
assert.ok(result.includes('\n\n```bash'), 'code block should have blank line before');
assert.ok(result.includes('```\n\nAfter code.'), 'code block should have blank line after');
assert.ok(result.includes('echo hello'), 'code content should be preserved');
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. normalizeMd performance benchmark
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeMd performance benchmark', () => {
test('processes a 100-line markdown file in under 50ms', () => {
const lines = [];
for (let i = 0; i < 100; i++) {
if (i % 20 === 0) {
lines.push(`## Section ${i / 20 + 1}`);
} else if (i % 30 === 0) {
lines.push('```js');
lines.push(`const x${i} = ${i};`);
lines.push('```');
} else if (i % 5 === 0) {
lines.push(`- List item ${i}`);
} else {
lines.push(`Paragraph text line ${i} with some content to process.`);
}
}
const input = lines.join('\n') + '\n';
const start = performance.now();
const result = normalizeMd(input);
const elapsed = performance.now() - start;
assert.ok(typeof result === 'string', 'should return a string');
assert.ok(result.length > 0, 'result should not be empty');
assert.ok(result.endsWith('\n'), 'result should end with newline');
assert.ok(elapsed < 50, `100-line file should process in under 50ms, took ${elapsed.toFixed(2)}ms`);
});
test('processes a 1000-line markdown file with 20 code blocks in under 200ms', () => {
const lines = [];
let codeBlockCount = 0;
for (let i = 0; i < 1000; i++) {
if (i % 50 === 0 && codeBlockCount < 20) {
lines.push(`## Section ${codeBlockCount + 1}`);
lines.push('');
lines.push('Some introductory text for this section.');
lines.push('');
lines.push('```python');
for (let j = 0; j < 5; j++) {
lines.push(` result_${codeBlockCount}_${j} = compute(${j})`);
}
lines.push('```');
lines.push('');
lines.push('Explanation of the code above.');
codeBlockCount++;
} else if (i % 10 === 0) {
lines.push(`### Subsection at line ${i}`);
} else if (i % 7 === 0) {
lines.push(`- Item ${i}: description of this list item`);
} else if (i % 13 === 0) {
lines.push(`1. Ordered item ${i}`);
} else {
lines.push(`Line ${i}: Regular paragraph content with various markdown elements.`);
}
}
const input = lines.join('\n') + '\n';
normalizeMd(input); // warm up JIT
const start = performance.now();
const result = normalizeMd(input);
const elapsed = performance.now() - start;
assert.ok(typeof result === 'string', 'should return a string');
assert.ok(result.length > 0, 'result should not be empty');
assert.ok(result.endsWith('\n'), 'result should end with newline');
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
assert.ok(elapsed < 200, `1000-line file with 20 code blocks should process in under 200ms, took ${elapsed.toFixed(2)}ms`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. normalizeMd snapshot tests
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeMd snapshot tests', () => {
test('snapshot - heading spacing', () => {
const input = '# Title\nParagraph\n## Section\nMore text';
const expected = '# Title\n\nParagraph\n\n## Section\n\nMore text\n';
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`Heading spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
test('snapshot - code block spacing', () => {
const input = 'Text before\n```js\nconst x = 1;\n```\nText after\n';
const expected = 'Text before\n\n```js\nconst x = 1;\n```\n\nText after\n';
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`Code block spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
test('snapshot - list spacing', () => {
const input = 'Paragraph\n- item 1\n- item 2\nAnother paragraph';
const expected = 'Paragraph\n\n- item 1\n- item 2\n\nAnother paragraph\n';
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`List spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
test('snapshot - complex mixed document', () => {
const input = [
'# Main Title',
'Intro paragraph.',
'## Section One',
'Some text here.',
'```js',
'const a = 1;',
'```',
'- first item',
'- second item',
'## Section Two',
'Final text.',
].join('\n');
const expected = [
'# Main Title',
'',
'Intro paragraph.',
'',
'## Section One',
'',
'Some text here.',
'',
'```js',
'const a = 1;',
'```',
'',
'- first item',
'- second item',
'',
'## Section Two',
'',
'Final text.',
'',
].join('\n');
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`Complex mixed document snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Warnings (frontmatter parse, state field miss)
// ─────────────────────────────────────────────────────────────────────────────
describe('warnings', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('must_haves parse warning fires for block with content but 0 items', () => {
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(planDir, { recursive: true });
fs.writeFileSync(
path.join(planDir, '01-01-PLAN.md'),
`---
phase: "01"
plan: "01"
must_haves:
acceptance:
bare content without dash prefix
another line without dash prefix
---
# Plan 01-01
`
);
const result = runGsdTools(
['frontmatter', 'get', path.join(planDir, '01-01-PLAN.md'), 'must_haves'],
tmpDir
);
const stderr = result.error || '';
assert.ok(
stderr.includes('WARNING') && stderr.includes('must_haves') ||
result.output.includes('acceptance'),
`Expected WARNING about must_haves parse or valid parse result. stderr: ${stderr}, stdout: ${result.output}`
);
});
test('stateReplaceFieldWithFallback logs warning on miss', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 8. Malformed input resilience
// ─────────────────────────────────────────────────────────────────────────────
describe('malformed input resilience', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE.md with invalid bold format -- state patch returns gracefully', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Current Phase: 01\n**Status:** Planning\n'
);
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
const didNotCrash = result.success || (result.output !== undefined);
assert.ok(didNotCrash, `state patch should not crash on malformed bold format: ${result.error}`);
if (result.success) {
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
content.includes('In progress'),
'Status field (with valid bold format) should be updated'
);
}
});
test('STATE.md with only frontmatter, no body -- state patch handles gracefully', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nphase: "01"\n---\n'
);
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
const didNotCrash = result.success || (result.output !== undefined);
assert.ok(didNotCrash, `state patch should not crash on frontmatter-only STATE.md: ${result.error}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 9. Stress tests with 50+ phases
// ─────────────────────────────────────────────────────────────────────────────
describe('stress tests with 50+ phases', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('roadmap analyze on 50-phase ROADMAP completes in under 2000ms', () => {
create50PhaseProject(tmpDir, 25);
const start = performance.now();
const result = runGsdTools('roadmap analyze', tmpDir);
const elapsed = performance.now() - start;
assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`);
assert.ok(elapsed < 2000, `Should complete in under 2000ms, took ${elapsed.toFixed(0)}ms`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.phases), 'Output should contain a phases array');
assert.strictEqual(output.phases.length, 50, `Should have 50 phases, got ${output.phases.length}`);
const completedPhases = output.phases.filter(p => p.disk_status === 'complete');
assert.strictEqual(completedPhases.length, 25, `Should have 25 complete phases, got ${completedPhases.length}`);
});
test('phase complete on phase 26 of 50-phase project works correctly', () => {
create50PhaseProject(tmpDir, 25);
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** In progress\n');
const phase26Dir = path.join(tmpDir, '.planning', 'phases', '26-feature-26');
fs.writeFileSync(
path.join(phase26Dir, '26-01-SUMMARY.md'),
'# Phase 26 Plan 1 Summary\n\nFeature 26 completed.\n'
);
const result = runGsdTools('phase complete 26', tmpDir);
assert.ok(result.success, `phase complete 26 should succeed: ${result.error}`);
const roadmapContent = fs.readFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'utf-8'
);
const phase26Checkbox = roadmapContent.match(/-\s*\[(x| )\]\s*.*Phase\s+26/i);
assert.ok(phase26Checkbox, 'Should find Phase 26 checkbox in ROADMAP');
assert.strictEqual(phase26Checkbox[1], 'x', 'Phase 26 should now be marked as complete [x]');
});
});

View File

@@ -242,6 +242,16 @@ describe('config-set command', () => {
assert.strictEqual(config.workflow.use_worktrees, false);
});
test('sets git.base_branch for non-main default branches', () => {
writeConfig(tmpDir, {});
const result = runGsdTools('config-set git.base_branch master', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.git.base_branch, 'master');
});
test('errors when no key path provided', () => {
const result = runGsdTools('config-set', tmpDir);
assert.strictEqual(result.success, false);
@@ -329,6 +339,27 @@ describe('config-get command', () => {
});
});
test('gets git.base_branch after it is set', () => {
runGsdTools('config-set git.base_branch master', tmpDir);
const result = runGsdTools('config-get git.base_branch', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output, 'master');
});
test('errors for git.base_branch when not explicitly set', () => {
// Default config from config-ensure-section does not include git.base_branch,
// so config-get should return "Key not found" — this triggers auto-detect
// fallback in the workflow (origin/HEAD detection).
const result = runGsdTools('config-get git.base_branch', tmpDir);
assert.strictEqual(result.success, false);
assert.ok(
result.error.includes('Key not found'),
`Expected "Key not found" in error: ${result.error}`
);
});
test('errors when no key path provided', () => {
const result = runGsdTools('config-get', tmpDir);
assert.strictEqual(result.success, false);

View File

@@ -0,0 +1,145 @@
/**
* GSD Tools Tests - Adaptive Context Enrichment for 1M Models
*
* Tests for feat/1m-context-enrichment-1473b:
* - Workflow template syntax validation (CONTEXT_WINDOW conditionals)
* - execute-phase.md enrichment blocks (executor + verifier)
* - plan-phase.md cross-phase context gating
*/
const { test, describe } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
// ─────────────────────────────────────────────────────────────────────────────
// Workflow template syntax validation
// ─────────────────────────────────────────────────────────────────────────────
describe('execute-phase.md context enrichment', () => {
const EXECUTE_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'execute-phase.md');
test('contains CONTEXT_WINDOW config-get command', () => {
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('CONTEXT_WINDOW'),
'execute-phase.md should reference CONTEXT_WINDOW variable'
);
assert.ok(
content.includes('config-get context_window'),
'execute-phase.md should read context_window via config-get'
);
assert.ok(
content.includes('|| echo "200000"'),
'execute-phase.md should default CONTEXT_WINDOW to 200000'
);
});
test('contains conditional prior_wave_summaries in executor prompt', () => {
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('CONTEXT_WINDOW >= 500000'),
'execute-phase.md should gate enrichment on CONTEXT_WINDOW >= 500000'
);
assert.ok(
content.includes('prior_wave_summaries'),
'execute-phase.md should include prior_wave_summaries in enrichment block'
);
assert.ok(
content.includes('CONTEXT.md'),
'execute-phase.md should reference CONTEXT.md in conditional enrichment'
);
assert.ok(
content.includes('RESEARCH.md'),
'execute-phase.md should reference RESEARCH.md in conditional enrichment'
);
});
test('verifier prompt includes files_to_read block', () => {
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('<files_to_read>'),
'execute-phase.md should contain <files_to_read> opening tag'
);
assert.ok(
content.includes('</files_to_read>'),
'execute-phase.md should contain </files_to_read> closing tag'
);
const verifierSection = content.substring(content.lastIndexOf('<files_to_read>'));
assert.ok(
verifierSection.includes('PLAN.md'),
'verifier files_to_read should reference PLAN.md'
);
assert.ok(
verifierSection.includes('SUMMARY.md'),
'verifier files_to_read should reference SUMMARY.md'
);
assert.ok(
verifierSection.includes('REQUIREMENTS.md'),
'verifier files_to_read should reference REQUIREMENTS.md'
);
});
test('executor enrichment block includes CONTEXT.md and RESEARCH.md for 1M models', () => {
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
// Find the executor section's enrichment block
const executorIdx = content.indexOf('CONTEXT_WINDOW >= 500000');
assert.ok(executorIdx > -1, 'Should find CONTEXT_WINDOW >= 500000 conditional');
// Extract ~500 chars after the conditional to check what's included
const enrichmentBlock = content.substring(executorIdx, executorIdx + 500);
assert.ok(
enrichmentBlock.includes('CONTEXT.md'),
'executor enrichment should include CONTEXT.md'
);
assert.ok(
enrichmentBlock.includes('RESEARCH.md'),
'executor enrichment should include RESEARCH.md'
);
});
});
describe('plan-phase.md context enrichment', () => {
const PLAN_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'plan-phase.md');
test('contains CONTEXT_WINDOW conditional for prior CONTEXT.md', () => {
const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('CONTEXT_WINDOW'),
'plan-phase.md should reference CONTEXT_WINDOW variable'
);
assert.ok(
content.includes('config-get context_window'),
'plan-phase.md should read context_window via config-get'
);
assert.ok(
content.includes('CONTEXT_WINDOW >= 500000'),
'plan-phase.md should gate cross-phase context on CONTEXT_WINDOW >= 500000'
);
assert.ok(
content.includes('CONTEXT.md'),
'plan-phase.md should reference CONTEXT.md in cross-phase enrichment'
);
});
test('enrichment block mentions cross-phase decision consistency', () => {
const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8');
// The enrichment should explain why prior context matters
assert.ok(
content.includes('cross-phase') || content.includes('Cross-phase'),
'plan-phase.md should mention cross-phase context'
);
assert.ok(
content.includes('SUMMARY.md'),
'plan-phase.md should reference prior SUMMARY.md files'
);
});
test('default CONTEXT_WINDOW fallback is 200000', () => {
const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('|| echo "200000"'),
'plan-phase.md should default CONTEXT_WINDOW to 200000'
);
});
});

View File

@@ -659,7 +659,7 @@ describe('copyCommandsAsCopilotSkills', () => {
assert.ok(skillContent.includes('description: Run all remaining phases autonomously'),
'description preserved');
// argument-hint present and double-quoted
assert.ok(skillContent.includes('argument-hint: "[--from N]"'), 'argument-hint present and quoted');
assert.ok(skillContent.includes('argument-hint: "[--from N] [--only N] [--interactive]"'), 'argument-hint present and quoted');
// allowed-tools comma-separated
assert.ok(skillContent.includes('allowed-tools: Read, Write, Bash, Glob, Grep, AskUserQuestion, Task'),
'allowed-tools is comma-separated');
@@ -1349,3 +1349,89 @@ describe('E2E: Copilot uninstall verification', () => {
});
});
});
// ─── Claude uninstall: user file preservation (#1423) ─────────────────────────
function runClaudeInstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
function runClaudeUninstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--uninstall'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
describe('Claude uninstall preserves user-generated files (#1423)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-preserve-'));
runClaudeInstall(tmpDir);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('preserves USER-PROFILE.md across uninstall', () => {
const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md');
const content = '# Developer Profile\n\nAutonomy: High\nGenerated: 2026-03-29\n';
fs.writeFileSync(profilePath, content);
runClaudeUninstall(tmpDir);
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive uninstall');
assert.strictEqual(fs.readFileSync(profilePath, 'utf-8'), content, 'content should be identical');
});
test('preserves dev-preferences.md across uninstall', () => {
const prefsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
fs.mkdirSync(prefsDir, { recursive: true });
const prefsPath = path.join(prefsDir, 'dev-preferences.md');
const content = '---\nname: dev-preferences\n---\n# Preferences\nUse TypeScript strict.\n';
fs.writeFileSync(prefsPath, content);
runClaudeUninstall(tmpDir);
assert.ok(fs.existsSync(prefsPath), 'dev-preferences.md should survive uninstall');
assert.strictEqual(fs.readFileSync(prefsPath, 'utf-8'), content, 'content should be identical');
});
test('still removes GSD engine files during uninstall', () => {
const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md');
fs.writeFileSync(profilePath, '# Profile\n');
// Verify engine files exist before uninstall
const binDir = path.join(tmpDir, '.claude', 'get-shit-done', 'bin');
assert.ok(fs.existsSync(binDir), 'bin/ should exist before uninstall');
runClaudeUninstall(tmpDir);
// Engine files gone, user file preserved
assert.ok(!fs.existsSync(binDir), 'bin/ should be removed after uninstall');
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive');
});
test('clean uninstall when no user files exist', () => {
runClaudeUninstall(tmpDir);
const gsdDir = path.join(tmpDir, '.claude', 'get-shit-done');
const cmdDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
// Directories should be fully removed when no user files to preserve
assert.ok(!fs.existsSync(gsdDir), 'get-shit-done/ should not exist after clean uninstall');
assert.ok(!fs.existsSync(cmdDir), 'commands/gsd/ should not exist after clean uninstall');
});
});

View File

@@ -30,6 +30,7 @@ const {
findPhaseInternal,
findProjectRoot,
detectSubRepos,
planningDir,
} = require('../get-shit-done/bin/lib/core.cjs');
// ─── loadConfig ────────────────────────────────────────────────────────────────
@@ -1611,3 +1612,78 @@ describe('reapStaleTempFiles', () => {
});
});
});
// ─── planningDir ──────────────────────────────────────────────────────────────
describe('planningDir', () => {
const cwd = '/fake/repo';
let savedProject, savedWorkstream;
beforeEach(() => {
savedProject = process.env.GSD_PROJECT;
savedWorkstream = process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
delete process.env.GSD_WORKSTREAM;
});
afterEach(() => {
if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject;
else delete process.env.GSD_PROJECT;
if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream;
else delete process.env.GSD_WORKSTREAM;
});
test('returns .planning/ when neither project nor workstream is set', () => {
const result = planningDir(cwd, null, null);
assert.strictEqual(result, path.join(cwd, '.planning'));
});
test('returns .planning/{project}/ when project is set', () => {
const result = planningDir(cwd, null, 'my-app');
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app'));
});
test('returns .planning/workstreams/{ws}/ when workstream is set', () => {
const result = planningDir(cwd, 'feature-x', null);
assert.strictEqual(result, path.join(cwd, '.planning', 'workstreams', 'feature-x'));
});
test('returns .planning/{project}/workstreams/{ws}/ when both are set', () => {
const result = planningDir(cwd, 'feature-x', 'my-app');
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x'));
});
test('reads GSD_PROJECT from env when project param is undefined', () => {
process.env.GSD_PROJECT = 'env-project';
const result = planningDir(cwd);
assert.strictEqual(result, path.join(cwd, '.planning', 'env-project'));
});
test('rejects path traversal in project name', () => {
assert.throws(
() => planningDir(cwd, null, '../../etc'),
/invalid path characters/
);
});
test('rejects forward slash in project name', () => {
assert.throws(
() => planningDir(cwd, null, 'foo/bar'),
/invalid path characters/
);
});
test('rejects backslash in project name', () => {
assert.throws(
() => planningDir(cwd, null, 'foo\\bar'),
/invalid path characters/
);
});
test('rejects path traversal in workstream name', () => {
assert.throws(
() => planningDir(cwd, '../../../tmp', null),
/invalid path characters/
);
});
});

View File

@@ -0,0 +1,388 @@
/**
* GSD Tools Tests - Health Validation
*
* Tests for fix/health-validation-1473c:
* - W011: STATE/ROADMAP cross-validation (phase divergence detection)
* - W012: branching_strategy validation
* - W013: context_window validation
* - W014: phase_branch_template placeholder validation
* - W015: milestone_branch_template placeholder validation
* - stateReplaceFieldWithFallback field-miss warning
* - Boundary conditions and edge cases
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Helpers ────────────────────────────────────────────────────────────────
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n${lines}\n`
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
function writeMinimalProjectMd(tmpDir) {
const sections = ['## What This Is', '## Core Value', '## Requirements'];
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
`# Project\n\n${content}`
);
}
function writeValidConfigJson(tmpDir, overrides = {}) {
const base = { model_profile: 'balanced', commit_docs: true };
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ ...base, ...overrides }, null, 2)
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. W011: STATE/ROADMAP cross-validation
// ─────────────────────────────────────────────────────────────────────────────
describe('W011: STATE/ROADMAP cross-validation', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE says current phase but ROADMAP shows it as complete -> warning', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Session State\n\n**Current Phase:** 03\n**Current Phase Name:** Database Layer\n**Status:** In progress\n`
);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W011'),
`Expected W011 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('STATE and ROADMAP agree (phase not checked off) -> no W011 warning', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [ ] Phase 2: API Layer\n\n### Phase 2: API Layer\n**Goal:** Build API\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Session State\n\n**Current Phase:** 2\n**Status:** In progress\n`
);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api-layer'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W011'),
`Should not have W011: ${JSON.stringify(output.warnings)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. W012-W015: Config field validation
// ─────────────────────────────────────────────────────────────────────────────
describe('config field validation', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('W012: invalid branching_strategy triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { branching_strategy: 'banana' });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W012'),
`Expected W012 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('W013: negative context_window triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { context_window: -500 });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W013'),
`Expected W013 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('W014: phase_branch_template missing {phase} triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { phase_branch_template: 'gsd/no-placeholder-{slug}' });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W014'),
`Expected W014 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('W015: milestone_branch_template missing {milestone} triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { milestone_branch_template: 'release/no-placeholder' });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W015'),
`Expected W015 in warnings: ${JSON.stringify(output.warnings)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Boundary conditions
// ─────────────────────────────────────────────────────────────────────────────
describe('boundary conditions', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('context_window config accepts 500000 (boundary value)', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { context_window: 500000 });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W013'),
`Should not have W013 for context_window=500000: ${JSON.stringify(output.warnings)}`
);
});
test('context_window config accepts 200000 (default value)', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { context_window: 200000 });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W013'),
`Should not have W013 for context_window=200000: ${JSON.stringify(output.warnings)}`
);
});
test('W013 does NOT fire when context_window is absent from config', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W013'),
`Should not have W013 when context_window is absent: ${JSON.stringify(output.warnings)}`
);
});
test('health check handles STATE.md with no Current Phase field (no W011 crash)', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nSome content but no phase reference.\n');
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command should not crash: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(typeof output.status === 'string', 'should return a status string');
assert.ok(Array.isArray(output.errors), 'should return errors array');
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
});
test('health check handles empty ROADMAP.md (no crash)', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '');
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command should not crash on empty ROADMAP.md: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(typeof output.status === 'string', 'should return a status string');
assert.ok(Array.isArray(output.errors), 'should return errors array');
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
});
test('config.json with trailing comma -- validate health reports parse error', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{"model_profile": "balanced",}'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Test Phase\n'
);
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `validate health should not crash on invalid JSON: ${result.error}`);
const output = JSON.parse(result.output);
const hasE005 = output.errors.some(e => e.code === 'E005');
assert.ok(hasE005, `Should report E005 for invalid config.json: ${JSON.stringify(output.errors)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. stateReplaceFieldWithFallback warning
// ─────────────────────────────────────────────────────────────────────────────
describe('stateReplaceFieldWithFallback field-miss warning', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('advance-plan completes even when fields are missing (non-fatal)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
});
test('validate health on 50-phase project completes in under 3000ms', () => {
// Stress test for the new health checks at scale
let roadmapContent = '# Roadmap v1.0\n\n';
for (let i = 1; i <= 50; i++) {
roadmapContent += `- [${i <= 25 ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
}
roadmapContent += '\n';
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
roadmapContent += `### Phase ${i}: Feature ${i}\n\n**Goal:** Build feature ${i}\n**Plans:** 1 plans\n\n`;
}
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent);
writeMinimalProjectMd(tmpDir);
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** Planning\n');
writeValidConfigJson(tmpDir);
const phasesDir = path.join(tmpDir, '.planning', 'phases');
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
const phaseDir = path.join(phasesDir, `${pad}-feature-${i}`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${pad}-01-PLAN.md`), `# Plan ${i}\n`);
if (i <= 25) {
fs.writeFileSync(path.join(phaseDir, `${pad}-01-SUMMARY.md`), `# Summary ${i}\n`);
}
}
const { performance } = require('perf_hooks');
const start = performance.now();
const result = runGsdTools('validate health', tmpDir);
const elapsed = performance.now() - start;
assert.ok(result.success, `validate health should succeed: ${result.error}`);
assert.ok(elapsed < 3000, `Should complete in under 3000ms, took ${elapsed.toFixed(0)}ms`);
const output = JSON.parse(result.output);
assert.ok(typeof output.status === 'string', 'Should return a status string');
});
});

507
tests/hooks-opt-in.test.cjs Normal file
View File

@@ -0,0 +1,507 @@
/**
* GSD Tools Tests - Community Hooks (opt-in)
*
* Tests for feat/hooks-opt-in-1473d:
* - Hook file existence and permissions
* - Installer hook registration in install.js
* - Hook execution with opt-in enabled and disabled
* - Negative security tests for hooks
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const isWindows = process.platform === 'win32';
// ─── Helpers ────────────────────────────────────────────────────────────────
function createTempProject(prefix = 'gsd-hook-test-') {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
return tmpDir;
}
function cleanup(tmpDir) {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
}
function writeConfigWithHooks(tmpDir, enabled) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'balanced',
hooks: { community: enabled }
}, null, 2)
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || '# Session State\n\n**Current Phase:** 01\n**Status:** Active\n';
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Hook file existence and permissions
// ─────────────────────────────────────────────────────────────────────────────
describe('hook file validation', () => {
test('gsd-session-state.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-session-state.sh should exist');
});
test('gsd-validate-commit.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-validate-commit.sh should exist');
});
test('gsd-phase-boundary.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-phase-boundary.sh should exist');
});
test('gsd-session-state.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-session-state.sh should be executable');
});
test('gsd-validate-commit.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-validate-commit.sh should be executable');
});
test('gsd-phase-boundary.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-phase-boundary.sh should be executable');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Installer hook registration
// ─────────────────────────────────────────────────────────────────────────────
describe('installer hook registration', () => {
const installJsPath = path.join(__dirname, '..', 'bin', 'install.js');
let installSource;
beforeEach(() => {
installSource = fs.readFileSync(installJsPath, 'utf-8');
});
test('install.js contains gsd-validate-commit registration block', () => {
assert.ok(
installSource.includes('gsd-validate-commit'),
'install.js should contain gsd-validate-commit hook registration'
);
assert.ok(
installSource.includes('validateCommitCommand'),
'install.js should define validateCommitCommand variable'
);
assert.ok(
installSource.includes('hasValidateCommitHook'),
'install.js should check for existing validate-commit hook'
);
});
test('install.js contains gsd-session-state registration block', () => {
assert.ok(
installSource.includes('gsd-session-state'),
'install.js should contain gsd-session-state hook registration'
);
assert.ok(
installSource.includes('sessionStateCommand'),
'install.js should define sessionStateCommand variable'
);
assert.ok(
installSource.includes('hasSessionStateHook'),
'install.js should check for existing session-state hook'
);
});
test('install.js contains gsd-phase-boundary registration block', () => {
assert.ok(
installSource.includes('gsd-phase-boundary'),
'install.js should contain gsd-phase-boundary hook registration'
);
assert.ok(
installSource.includes('phaseBoundaryCommand'),
'install.js should define phaseBoundaryCommand variable'
);
assert.ok(
installSource.includes('hasPhaseBoundaryHook'),
'install.js should check for existing phase-boundary hook'
);
});
test('install.js registers validate-commit with PreToolUse event and Bash matcher', () => {
assert.ok(
installSource.includes("settings.hooks[preToolEvent].push"),
'validate-commit should be pushed to preToolEvent hooks array'
);
const validateCommitBlock = installSource.substring(
installSource.indexOf('// Configure commit validation hook'),
installSource.indexOf('// Configure session state orientation hook')
);
assert.ok(
validateCommitBlock.includes("matcher: 'Bash'"),
'validate-commit hook should use Bash matcher'
);
assert.ok(
validateCommitBlock.includes('preToolEvent'),
'validate-commit hook should register on preToolEvent (PreToolUse)'
);
});
test('install.js adds all 3 new hooks to the uninstall cleanup list', () => {
const gsdHooksMatch = installSource.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
assert.ok(gsdHooksMatch, 'install.js should define gsdHooks array for uninstall cleanup');
const gsdHooksContent = gsdHooksMatch[1];
assert.ok(
gsdHooksContent.includes('gsd-session-state.sh'),
'gsdHooks should include gsd-session-state.sh'
);
assert.ok(
gsdHooksContent.includes('gsd-validate-commit.sh'),
'gsdHooks should include gsd-validate-commit.sh'
);
assert.ok(
gsdHooksContent.includes('gsd-phase-boundary.sh'),
'gsdHooks should include gsd-phase-boundary.sh'
);
});
test('install.js log messages indicate opt-in behavior', () => {
assert.ok(
installSource.includes('opt-in via config'),
'install.js should mention opt-in in log messages'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Opt-in gating behavior
// ─────────────────────────────────────────────────────────────────────────────
describe('opt-in gating behavior', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
// Should exit 0 (no-op) even with a bad commit message
assert.strictEqual(result.status, 0, `Should be no-op when disabled, got ${result.status}`);
});
test('validate-commit is a no-op when config.json is absent', () => {
// No config.json at all
const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-bare-'));
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
try {
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: bareDir,
});
assert.strictEqual(result.status, 0, `Should be no-op without config.json, got ${result.status}`);
} finally {
fs.rmSync(bareDir, { recursive: true, force: true });
}
});
test('session-state is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
writeMinimalStateMd(tmpDir);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const result = spawnSync('bash', [hookPath], {
input: '',
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
// Should NOT output state info when disabled
assert.ok(
!result.stdout.includes('Project State Reminder'),
`Should not output state reminder when disabled: ${result.stdout}`
);
});
test('phase-boundary is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_input: { file_path: '.planning/STATE.md' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
!result.stdout.includes('.planning/ file modified'),
`Should not output warning when disabled: ${result.stdout}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Hook execution when enabled
// ─────────────────────────────────────────────────────────────────────────────
describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
writeConfigWithHooks(tmpDir, true);
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit allows valid conventional commit', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "fix(core): add locking mechanism"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Valid commit should exit 0, got ${result.status}. stderr: ${result.stderr}`);
});
test('validate-commit blocks non-conventional commit', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Non-conventional commit should exit 2, got ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
assert.ok(result.stdout.includes('Conventional Commits'), `stdout should mention "Conventional Commits": ${result.stdout}`);
});
test('validate-commit allows non-commit commands', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git push origin main' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Non-commit command should exit 0, got ${result.status}`);
});
test('session-state outputs state info when enabled', () => {
writeMinimalStateMd(tmpDir);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const result = spawnSync('bash', [hookPath], {
input: '',
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('STATE.md exists'),
`stdout should contain "STATE.md exists": ${result.stdout}`
);
});
test('session-state exits 0 without .planning/ (in enabled project)', () => {
// Create a dir with config but no STATE.md
const noStateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-nostate-'));
fs.mkdirSync(path.join(noStateDir, '.planning'), { recursive: true });
writeConfigWithHooks(noStateDir, true);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
try {
const result = spawnSync('bash', [hookPath], {
input: '',
encoding: 'utf-8',
cwd: noStateDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('No .planning/ found') || result.stdout.includes('Project State'),
`Should handle missing STATE.md gracefully: ${result.stdout}`
);
} finally {
fs.rmSync(noStateDir, { recursive: true, force: true });
}
});
test('phase-boundary detects .planning/ writes when enabled', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_input: { file_path: '.planning/STATE.md' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('.planning/ file modified'),
`stdout should contain ".planning/ file modified": ${result.stdout}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Negative security tests for hooks
// ─────────────────────────────────────────────────────────────────────────────
describe('hook security tests', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
writeConfigWithHooks(tmpDir, true);
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit blocks message with shell metacharacters', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "$(rm -rf /)"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Shell metacharacter message should be blocked: ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
});
test('validate-commit blocks message with backtick injection', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "`whoami`"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Backtick injection should be blocked: ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
});
test('validate-commit allows commit with scope containing special chars', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "fix(api/v2): handle edge case"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Valid commit with / in scope should be allowed: ${result.status}`);
});
test('phase-boundary handles malformed JSON input gracefully', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = 'not json at all';
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should not crash on malformed JSON: ${result.stderr}`);
});
test('hooks handle config.json with broken JSON gracefully', () => {
// Write malformed JSON config
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{ broken json'
);
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
// Should exit 0 (treat malformed config as disabled)
assert.strictEqual(result.status, 0, `Malformed config should be treated as disabled: ${result.status}`);
});
});

View File

@@ -26,9 +26,10 @@ const runtimeMap = {
'6': 'copilot',
'7': 'antigravity',
'8': 'cursor',
'9': 'windsurf'
'9': 'windsurf',
'10': 'augment'
};
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf'];
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
/**
* Simulate the parsing logic from promptRuntime without requiring readline.
@@ -37,7 +38,7 @@ const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot',
function parseRuntimeInput(input) {
input = input.trim() || '1';
if (input === '10') {
if (input === '11') {
return allRuntimes;
}
@@ -57,6 +58,7 @@ describe('multi-runtime selection parsing', () => {
test('single choice returns single runtime', () => {
assert.deepStrictEqual(parseRuntimeInput('1'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('2'), ['kilo']);
assert.deepStrictEqual(parseRuntimeInput('3'), ['opencode']);
assert.deepStrictEqual(parseRuntimeInput('4'), ['gemini']);
assert.deepStrictEqual(parseRuntimeInput('5'), ['codex']);
assert.deepStrictEqual(parseRuntimeInput('8'), ['cursor']);
@@ -82,8 +84,12 @@ describe('multi-runtime selection parsing', () => {
assert.deepStrictEqual(parseRuntimeInput('9'), ['windsurf']);
});
test('choice 10 returns all runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('10'), allRuntimes);
test('single choice for augment', () => {
assert.deepStrictEqual(parseRuntimeInput('10'), ['augment']);
});
test('choice 11 returns all runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('11'), allRuntimes);
});
test('empty input defaults to claude', () => {
@@ -92,7 +98,7 @@ describe('multi-runtime selection parsing', () => {
});
test('invalid choices are ignored, falls back to claude if all invalid', () => {
assert.deepStrictEqual(parseRuntimeInput('11'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('12'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']);
});
@@ -114,7 +120,7 @@ describe('multi-runtime selection parsing', () => {
});
describe('install.js source contains multi-select support', () => {
test('runtimeMap is defined with all 9 runtimes', () => {
test('runtimeMap is defined with all 10 runtimes', () => {
for (const [key, name] of Object.entries(runtimeMap)) {
assert.ok(
installSrc.includes(`'${key}': '${name}'`),
@@ -131,6 +137,24 @@ describe('install.js source contains multi-select support', () => {
}
});
test('all shortcut uses option 11', () => {
assert.ok(
installSrc.includes("if (input === '11')"),
'all shortcut uses option 11'
);
});
test('prompt lists Augment as option 10 and All as option 11', () => {
assert.ok(
installSrc.includes('10${reset}) Augment'),
'prompt lists Augment as option 10'
);
assert.ok(
installSrc.includes('11${reset}) All'),
'prompt lists All as option 11'
);
});
test('prompt text shows multi-select hint', () => {
assert.ok(
installSrc.includes('Select multiple'),

View File

@@ -1,8 +1,9 @@
/**
* GSD Tools Tests - OpenCode permission config
* Regression tests for OpenCode permission config handling.
*
* Regression tests for OpenCode permission handling, including the valid
* top-level string form: "permission": "allow".
* Ensures the installer does not crash when opencode.json uses the valid
* top-level string form: "permission": "allow", and that path-specific
* permissions are written against the actual resolved install directory.
*/
process.env.GSD_TEST_MODE = '1';
@@ -17,6 +18,19 @@ const { configureOpencodePermissions } = require('../bin/install.js');
const installSrc = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
const envKeys = ['OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'XDG_CONFIG_HOME'];
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
function restoreEnv(snapshot) {
for (const key of envKeys) {
if (snapshot[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = snapshot[key];
}
}
}
describe('configureOpencodePermissions', () => {
let configDir;
@@ -26,9 +40,10 @@ describe('configureOpencodePermissions', () => {
afterEach(() => {
cleanup(configDir);
restoreEnv(originalEnv);
});
test('does not rewrite top-level string permissions', () => {
test('does not crash or rewrite top-level string permissions', () => {
const configPath = path.join(configDir, 'opencode.json');
const original = JSON.stringify({
$schema: 'https://opencode.ai/config.json',
@@ -37,6 +52,7 @@ describe('configureOpencodePermissions', () => {
}, null, 2) + '\n';
fs.writeFileSync(configPath, original);
process.env.OPENCODE_CONFIG_DIR = configDir;
assert.doesNotThrow(() => configureOpencodePermissions(true, configDir));
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
@@ -45,6 +61,7 @@ describe('configureOpencodePermissions', () => {
test('adds path-specific read and external_directory permissions for object configs', () => {
const configPath = path.join(configDir, 'opencode.json');
fs.writeFileSync(configPath, JSON.stringify({ permission: {} }, null, 2) + '\n');
process.env.OPENCODE_CONFIG_DIR = configDir;
configureOpencodePermissions(true, configDir);
@@ -56,6 +73,9 @@ describe('configureOpencodePermissions', () => {
});
test('finishInstall passes the actual config dir to OpenCode permissions', () => {
assert.ok(installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'), 'OpenCode permission config uses actual install dir');
assert.ok(
installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'),
'OpenCode permission config uses actual install dir'
);
});
});

View File

@@ -0,0 +1,254 @@
/**
* GSD Tools Tests - subagent timeout configuration
*
* Validates that workflow.subagent_timeout is properly registered,
* loaded from config, and emitted in init context.
*
* Closes: #1472
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── config key registration ─────────────────────────────────────────────────
describe('workflow.subagent_timeout config key (#1472)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('subagent_timeout has correct default value (300000ms)', () => {
// Write a minimal config.json
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ model_profile: 'balanced' }, null, 2));
// Load config via init and check the value propagates
// Use config-get to verify the field is recognized
const result = runGsdTools(['config-set', 'workflow.subagent_timeout', '600000'], tmpDir);
assert.ok(result.success, `config-set should accept workflow.subagent_timeout: ${result.error}`);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.strictEqual(config.workflow.subagent_timeout, 600000);
});
test('config-set rejects invalid config keys but accepts subagent_timeout', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
// Valid key should succeed
const valid = runGsdTools(['config-set', 'workflow.subagent_timeout', '900000'], tmpDir);
assert.ok(valid.success, `workflow.subagent_timeout should be a valid key: ${valid.error}`);
// Invalid key should fail
const invalid = runGsdTools(['config-set', 'workflow.nonexistent_key', 'true'], tmpDir);
assert.ok(!invalid.success, 'nonexistent key should be rejected');
});
test('subagent_timeout appears in map-codebase init context', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
workflow: { subagent_timeout: 600000 }
}, null, 2));
const result = runGsdTools('init map-codebase', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init map-codebase should succeed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.subagent_timeout, 600000, 'init context should include configured timeout');
});
test('subagent_timeout defaults to 300000 when not configured', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
const result = runGsdTools('init map-codebase', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init map-codebase should succeed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.subagent_timeout, 300000, 'default should be 300000ms (5 minutes)');
});
});
describe('map-codebase workflow references configurable timeout (#1472)', () => {
test('workflow file references subagent_timeout from init context', () => {
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md');
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('subagent_timeout'),
'map-codebase.md should reference subagent_timeout from init context'
);
assert.ok(
content.includes('workflow.subagent_timeout'),
'map-codebase.md should document the config key'
);
});
test('workflow file no longer has hardcoded 300000 timeout', () => {
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md');
const content = fs.readFileSync(workflowPath, 'utf8');
// The timeout line should reference the config variable, not a hardcoded value
const timeoutLines = content.split('\n').filter(l => l.includes('timeout:'));
for (const line of timeoutLines) {
assert.ok(
!line.match(/timeout:\s*300000\s*$/),
`found hardcoded timeout: "${line.trim()}". Should reference subagent_timeout from init context.`
);
}
});
});
describe('planning-config.md documents subagent_timeout (#1472)', () => {
test('reference doc includes subagent_timeout entry', () => {
const refPath = path.join(__dirname, '..', 'get-shit-done', 'references', 'planning-config.md');
const content = fs.readFileSync(refPath, 'utf8');
assert.ok(
content.includes('workflow.subagent_timeout'),
'planning-config.md should document workflow.subagent_timeout'
);
assert.ok(
content.includes('300000'),
'planning-config.md should document the default value (300000)'
);
});
});
// ─── init execute-phase includes context_window ─────────────────────────────
describe('init execute-phase context_window (#1472)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('init execute-phase output includes context_window from config', () => {
// Write config with a custom context_window value (1M for Opus/Sonnet 4.6)
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
context_window: 1000000,
}, null, 2));
// Create a phase directory with a plan so init execute-phase succeeds
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('init execute-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.context_window, 1000000, 'context_window should reflect configured value');
});
test('init execute-phase uses default context_window when not configured', () => {
// Write minimal config without context_window
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('init execute-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.context_window, 200000, 'default context_window should be 200000');
});
});
// ─── config-get context_window ──────────────────────────────────────────────
describe('config-get context_window (#1472)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('config-get context_window returns the configured value', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
context_window: 1000000,
}, null, 2));
const result = runGsdTools('config-get context_window', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output, 1000000);
});
test('config-get context_window errors when key is absent', () => {
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}`
);
});
});
// ─── config-set workflow.subagent_timeout numeric coercion ──────────────────
describe('config-set workflow.subagent_timeout numeric values (#1472)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
});
afterEach(() => {
cleanup(tmpDir);
});
test('config-set workflow.subagent_timeout coerces string to number', () => {
const result = runGsdTools(['config-set', 'workflow.subagent_timeout', '900000'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true);
assert.strictEqual(output.key, 'workflow.subagent_timeout');
assert.strictEqual(output.value, 900000);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.strictEqual(config.workflow.subagent_timeout, 900000);
assert.strictEqual(typeof config.workflow.subagent_timeout, 'number');
});
test('config-set workflow.subagent_timeout round-trips through config-get', () => {
runGsdTools(['config-set', 'workflow.subagent_timeout', '1200000'], tmpDir);
const result = runGsdTools('config-get workflow.subagent_timeout', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output, 1200000);
});
});