Files
msd-core/tests/init-manager.test.cjs
Jeremy McSpadden 77c7b4fc9d fix(#1522): enforce canonical verification before phase transition (#1548)
* fix: require fresh phase verification before transition

* no-mistakes(review): Fix canonical verification closeout gates

* no-mistakes(review): Fix verify-work frontmatter promotion command

* no-mistakes(review): Fix stale verification gates

* no-mistakes(review): Fix canonical verification routing gates

* no-mistakes(review): Fix verification dependency and runtime routing gates

* no-mistakes(review): Block stale verification bypasses

* fix: handle large init manager outputs in verification workflows

* chore: update changeset pr number

* fix(verify-work): use fresh verification.status for stale gate

The stale check after UAT used phase_completion.verification_status from
session-start INIT while human_needed promotion already queried fresh
verification.status. Align the stale gate with the canonical query so
mid-session verification refresh is not ignored.

* fix(init): skip roadmap-checked phases when selecting next_phase

Roadmap-only phases without a disk directory were still promoted to
next_phase when their checkbox was already checked. Exclude
checkboxComplete phases so progress routing does not point at work the
roadmap already marks done.

* fix: gaps_found not overridden by stale, transition uses canonical verification

- verification.cts: check gaps_found before stale so gap-closure routing
  is not masked by a newer summary mtime
- phase.cts: remove redundant findStaleVerificationSummary — readVerificationStatus
  already handles stale detection
- transition.md: replace raw grep on file content with verification.status query
  to avoid false-positive blocks from body text matching

* ci: retrigger tests after rebase

* fix(transition): replace gsd_run advisory check with awk frontmatter extraction

The runtime launcher is not defined until the update_roadmap_and_state step
bash block (~line 165). The early verify_completion block used gsd_run to
query verification.status, which violated the runtime-launcher-parity test:
'preamble appears AFTER the first gsd_run reference'.

Replace the gsd_run call with an awk-based frontmatter extractor that reads
only the status: field between the two --- fences. This avoids both the
preamble-ordering constraint and the original false-positive grep bug where
body text like 'previous_status: gaps_found' would match a full-text regex.

The phase.complete gate at update_roadmap_and_state is the canonical
enforcement point; this early check is advisory only.

Also update workflow-size-baseline.json for the updated transition.md size.

Fixes: runtime-launcher-parity test (B)

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>

* fix: re-check verification under planning lock in phase complete

Move readVerificationStatus into withPlanningLock so stale verification
cannot slip through when a SUMMARY.md is written between the gate and
the roadmap/state mutation. Return the blocked status from the lock
callback and emit the error after release to avoid leaving .lock behind.

* fix(transition): gate on canonical verification.status including stale

Replace awk frontmatter read with verification.status query so transition
blocks when summaries are newer than VERIFICATION.md, matching phase.complete
and other workflows (autonomous, progress, verify-work).

* Fix workflow verification gates for yolo transition and stale routing

Require VERIFY_STATUS passed before yolo/interactive transition advance.
Route stale verification recovery to verify-work, matching canonical projection.

* fix(transition): use verification.status query for stale-aware advisory check

The awk-based check read raw frontmatter status: passed, which misses the
stale case where summaries are newer than the VERIFICATION.md file even
though the frontmatter still says passed. The stale status is computed from
file modification times, not stored in frontmatter.

Move the preamble to the verify_completion bash block (the first block with
a gsd_run call) so gsd_run query verification.status can be used for the
advisory check. This gives the full readVerificationStatus logic including
mtime-based staleness detection, matching the enforcement gate at phase.complete.

Capture full JSON (VERIFY_JSON) so next_action can be included in the
advisory output alongside the status.

Also update workflow-size-baseline.json for the updated transition.md size.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>

* ci: trigger test matrix for 525b946

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>

* fix(transition): restore awk frontmatter extraction for pre-shim verification check

The gsd_run launcher shim is not defined until line ~163 of transition.md,
so the verification debt check at line ~80 cannot use gsd_run. Restore the
awk-based frontmatter extraction that correctly reads status without needing
the runtime, and restore the shim at its proper location before
phase.complete.

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

* fix(#1522): clarify transition verification gate wording

* fix(#1522): update transition workflow size baseline

* fix(#1522): update workflow-size-baseline after rebase onto next

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>

* fix(#1522): guard findStaleVerificationSummary FS calls + thread opts.fs seam (review)

Address review blocker B1 on #1548: findStaleVerificationSummary ran fs.readdirSync
and two fs.statSync calls unguarded between readVerificationStatus's try/catch sections,
so a TOCTOU race (a SUMMARY listed by scanPhasePlans then removed before statSync) or any
FS error threw uncaught into callers NOT under the planning lock (init.manager /
init.progress / uat-predicate). Wrap the body in try/catch degrading to 'not stale', and
thread the injectable opts.fs seam (add statSync to FsLike, pass fsImpl from the caller)
for parity with readVerificationStatus's no-throw contract and testability. Also adds the
Verification Module glossary entry to CONTEXT.md (review B3).

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-06-24 13:19:10 -04:00

808 lines
32 KiB
JavaScript

/**
* GSD Tools Tests - Init Manager
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// Helper: write a minimal ROADMAP.md with phases
function writeRoadmap(tmpDir, phases) {
const sections = phases.map(p => {
let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** ${p.goal || 'Do the thing'}\n`;
if (p.depends_on) section += `**Depends on:** ${p.depends_on}\n`;
return section;
}).join('\n');
const checklist = phases.map(p => {
const mark = p.complete ? 'x' : ' ';
return `- [${mark}] **Phase ${p.number}: ${p.name}**`;
}).join('\n');
const content = `# Roadmap\n\n## Progress\n\n${checklist}\n\n${sections}`;
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
}
// Helper: write a minimal STATE.md
function writeState(tmpDir) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n');
}
// Helper: scaffold a phase directory with specific artifacts
function scaffoldPhase(tmpDir, num, opts = {}) {
const padded = String(num).padStart(2, '0');
const slug = opts.slug || 'test-phase';
const dir = path.join(tmpDir, '.planning', 'phases', `${padded}-${slug}`);
fs.mkdirSync(dir, { recursive: true });
if (opts.context) fs.writeFileSync(path.join(dir, `${padded}-CONTEXT.md`), '# Context');
if (opts.research) fs.writeFileSync(path.join(dir, `${padded}-RESEARCH.md`), '# Research');
if (opts.plans) {
for (let i = 1; i <= opts.plans; i++) {
const planPad = String(i).padStart(2, '0');
fs.writeFileSync(path.join(dir, `${padded}-${planPad}-PLAN.md`), `# Plan ${i}`);
}
}
if (opts.summaries) {
for (let i = 1; i <= opts.summaries; i++) {
const sumPad = String(i).padStart(2, '0');
fs.writeFileSync(path.join(dir, `${padded}-${sumPad}-SUMMARY.md`), `# Summary ${i}`);
}
}
return dir;
}
function writePassedVerification(phaseDir, padded) {
fs.writeFileSync(
path.join(phaseDir, `${padded}-VERIFICATION.md`),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n')
);
}
describe('init manager', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('fails without ROADMAP.md', () => {
writeState(tmpDir);
const result = runGsdTools('init manager', tmpDir);
assert.ok(!result.success);
assert.ok(result.error.includes('ROADMAP.md'));
});
test('fails without STATE.md', () => {
writeRoadmap(tmpDir, [{ number: '1', name: 'Setup' }]);
const result = runGsdTools('init manager', tmpDir);
assert.ok(!result.success);
assert.ok(result.error.includes('STATE.md'));
});
test('returns basic structure with phases', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'API Layer' },
{ number: '3', name: 'UI' },
]);
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 3);
assert.strictEqual(output.completed_count, 0);
assert.strictEqual(output.roadmap_exists, true);
assert.strictEqual(output.state_exists, true);
assert.ok(Array.isArray(output.phases));
assert.ok(Array.isArray(output.recommended_actions));
});
test('detects disk status correctly for each phase state', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Complete Phase', complete: true },
{ number: '2', name: 'Planned Phase' },
{ number: '3', name: 'Discussed Phase' },
{ number: '4', name: 'Empty Phase' },
{ number: '5', name: 'Not Started' },
]);
// Phase 1: complete (plans + matching summaries + passed verification)
writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'complete-phase', context: true, plans: 2, summaries: 2 }), '01');
// Phase 2: planned (plans, no summaries)
scaffoldPhase(tmpDir, 2, { slug: 'planned-phase', context: true, plans: 3 });
// Phase 3: discussed (context only)
scaffoldPhase(tmpDir, 3, { slug: 'discussed-phase', context: true });
// Phase 4: empty directory
scaffoldPhase(tmpDir, 4, { slug: 'empty-phase' });
// Phase 5: no directory at all
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].disk_status, 'complete');
assert.strictEqual(output.phases[1].disk_status, 'planned');
assert.strictEqual(output.phases[2].disk_status, 'discussed');
assert.strictEqual(output.phases[3].disk_status, 'empty');
assert.strictEqual(output.phases[4].disk_status, 'no_directory');
});
test('treats plans/PLAN-NN.md layout as planned/complete counts (#3053)', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Nested Plans' },
]);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-nested-plans');
fs.mkdirSync(path.join(phaseDir, 'plans'), { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'plans', 'PLAN-01.md'), '# Plan 1');
fs.writeFileSync(path.join(phaseDir, 'plans', 'PLAN-02.md'), '# Plan 2');
fs.writeFileSync(path.join(phaseDir, 'plans', 'SUMMARY-01.md'), '# Summary 1');
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].plan_count, 2);
assert.strictEqual(output.phases[0].summary_count, 1);
assert.strictEqual(output.phases[0].disk_status, 'partial');
});
test('dependency satisfaction: deps on complete phases = satisfied', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation', complete: true },
{ number: '2', name: 'Depends on 1', depends_on: 'Phase 1' },
]);
writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }), '01');
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].deps_satisfied, true);
assert.strictEqual(output.phases[1].deps_satisfied, true);
});
test('dependency satisfaction: deps on incomplete phases = not satisfied', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'Depends on 1', depends_on: 'Phase 1' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].deps_satisfied, true); // no deps
assert.strictEqual(output.phases[1].deps_satisfied, false); // phase 1 not complete
});
test('parallel discuss: all undiscussed phases are marked is_next_to_discuss', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'API Layer' },
{ number: '3', name: 'UI' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
// All three phases are undiscussed — all should be discussable
assert.strictEqual(output.phases[0].is_next_to_discuss, true);
assert.strictEqual(output.phases[1].is_next_to_discuss, true);
assert.strictEqual(output.phases[2].is_next_to_discuss, true);
// All three should have discuss recommendations
const discussActions = output.recommended_actions.filter(a => a.action === 'discuss');
assert.strictEqual(discussActions.length, 3);
});
test('discussed phase is not discussable; undiscussed siblings are', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'API Layer' },
{ number: '3', name: 'UI' },
]);
// Phase 1 discussed
scaffoldPhase(tmpDir, 1, { slug: 'foundation', context: true });
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
// Phase 1 is discussed; phases 2 and 3 are both undiscussed and discussable
assert.strictEqual(output.phases[0].is_next_to_discuss, false);
assert.strictEqual(output.phases[1].is_next_to_discuss, true);
assert.strictEqual(output.phases[2].is_next_to_discuss, true);
// Should recommend plan phase 1 AND discuss phases 2 and 3
const phase1Rec = output.recommended_actions.find(r => r.phase === '1');
const phase2Rec = output.recommended_actions.find(r => r.phase === '2');
const phase3Rec = output.recommended_actions.find(r => r.phase === '3');
assert.strictEqual(phase1Rec.action, 'plan');
assert.strictEqual(phase2Rec.action, 'discuss');
assert.strictEqual(phase3Rec.action, 'discuss');
});
test('sliding window: full pipeline — execute N, plan N+1, discuss N+2', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation', complete: true },
{ number: '2', name: 'API Layer' },
{ number: '3', name: 'Auth' },
{ number: '4', name: 'UI' },
{ number: '5', name: 'Polish' },
]);
writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }), '01');
scaffoldPhase(tmpDir, 2, { slug: 'api-layer', context: true, plans: 2 }); // planned
scaffoldPhase(tmpDir, 3, { slug: 'auth', context: true }); // discussed
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
// Phases 4 and 5 are both undiscussed — both discussable
assert.strictEqual(output.phases[3].is_next_to_discuss, true);
assert.strictEqual(output.phases[4].is_next_to_discuss, true);
// Recommendations: execute 2, plan 3, discuss 4, discuss 5
assert.strictEqual(output.recommended_actions[0].action, 'execute');
assert.strictEqual(output.recommended_actions[0].phase, '2');
assert.strictEqual(output.recommended_actions[1].action, 'plan');
assert.strictEqual(output.recommended_actions[1].phase, '3');
const discussRecs = output.recommended_actions.filter(a => a.action === 'discuss').map(a => a.phase).sort();
assert.deepStrictEqual(discussRecs, ['4', '5']);
});
test('recommendation ordering: execute > plan > discuss', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Complete', complete: true },
{ number: '2', name: 'Ready to Execute' },
{ number: '3', name: 'Ready to Plan' },
{ number: '4', name: 'Ready to Discuss' },
]);
writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'complete', plans: 1, summaries: 1 }), '01');
scaffoldPhase(tmpDir, 2, { slug: 'ready-to-execute', context: true, plans: 2 });
scaffoldPhase(tmpDir, 3, { slug: 'ready-to-plan', context: true });
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.ok(output.recommended_actions.length >= 3);
assert.strictEqual(output.recommended_actions[0].action, 'execute');
assert.strictEqual(output.recommended_actions[0].phase, '2');
assert.strictEqual(output.recommended_actions[1].action, 'plan');
assert.strictEqual(output.recommended_actions[1].phase, '3');
assert.strictEqual(output.recommended_actions[2].action, 'discuss');
assert.strictEqual(output.recommended_actions[2].phase, '4');
});
test('blocked phases not recommended', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'In Progress' },
{ number: '2', name: 'Blocked', depends_on: 'Phase 1' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
// Phase 2 should not appear in recommendations (blocked by phase 1)
const phase2Rec = output.recommended_actions.find(r => r.phase === '2');
assert.strictEqual(phase2Rec, undefined);
});
test('all phases complete sets all_complete flag', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Done', complete: true },
{ number: '2', name: 'Also Done', complete: true },
]);
writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'done', plans: 1, summaries: 1 }), '01');
writePassedVerification(scaffoldPhase(tmpDir, 2, { slug: 'also-done', plans: 1, summaries: 1 }), '02');
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_complete, true);
assert.strictEqual(output.recommended_actions.length, 0);
});
test('implementation-complete phase without passed verification is not all_complete', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Implemented', complete: true },
]);
scaffoldPhase(tmpDir, 1, { slug: 'implemented', plans: 1, summaries: 1 });
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_complete, false);
assert.strictEqual(output.completed_count, 0);
assert.strictEqual(output.phases[0].disk_status, 'executed');
assert.strictEqual(output.phases[0].implementation_complete, true);
assert.strictEqual(output.phases[0].verification_status, 'missing');
assert.strictEqual(output.phases[0].verification_passed, false);
assert.strictEqual(output.recommended_actions[0].action, 'verify');
assert.match(output.recommended_actions[0].command, /execute-phase 1/);
});
test('stale passed verification is not projected as complete', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Implemented', complete: true },
]);
const phaseDir = scaffoldPhase(tmpDir, 1, { slug: 'implemented', plans: 1, summaries: 1 });
writePassedVerification(phaseDir, '01');
const verificationPath = path.join(phaseDir, '01-VERIFICATION.md');
const summaryPath = path.join(phaseDir, '01-01-SUMMARY.md');
const older = new Date('2025-01-01T00:00:00.000Z');
const newer = new Date('2025-01-01T00:01:00.000Z');
fs.utimesSync(verificationPath, older, older);
fs.utimesSync(summaryPath, newer, newer);
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_complete, false);
assert.strictEqual(output.completed_count, 0);
assert.strictEqual(output.phases[0].disk_status, 'executed');
assert.strictEqual(output.phases[0].implementation_complete, true);
assert.strictEqual(output.phases[0].verification_status, 'stale');
assert.strictEqual(output.phases[0].verification_passed, false);
assert.strictEqual(output.phases[0].phase_complete, false);
assert.strictEqual(output.recommended_actions[0].action, 'verify');
assert.match(output.recommended_actions[0].reason, /verification stale/);
assert.match(output.recommended_actions[0].command, /verify-work 1/);
});
test('checked unpadded roadmap token does not satisfy padded unverified dependency', () => {
writeState(tmpDir);
const roadmap = [
'# Roadmap',
'',
'## Progress',
'',
'- [x] **Phase 1: Foundation**',
'- [ ] **Phase 02: Followup**',
'',
'### Phase 01: Foundation',
'',
'**Goal:** Build foundation',
'',
'### Phase 02: Followup',
'',
'**Goal:** Build followup',
'**Depends on:** Phase 1',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 });
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const phase1 = output.phases.find(p => p.number === '01');
const phase2 = output.phases.find(p => p.number === '02');
assert.ok(phase1, 'Phase 01 should be in the output');
assert.strictEqual(phase1.phase_complete, false, 'Phase 01 is unverified and must not be complete');
assert.ok(phase2, 'Phase 02 should be in the output');
assert.strictEqual(phase2.deps_satisfied, false, 'Phase 02 dependency must wait for canonical Phase 01 verification');
});
test('WAITING.json detected when present', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Test' }]);
const waiting = { type: 'decision', phase: '1', question: 'Pick one' };
fs.writeFileSync(
path.join(tmpDir, '.planning', 'WAITING.json'),
JSON.stringify(waiting)
);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.waiting_signal, waiting);
});
test('phase fields include goal and depends_on from roadmap', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation', goal: 'Set up the base' },
{ number: '2', name: 'API', goal: 'Build endpoints', depends_on: 'Phase 1' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].goal, 'Set up the base');
assert.strictEqual(output.phases[0].depends_on, null);
assert.strictEqual(output.phases[1].goal, 'Build endpoints');
assert.strictEqual(output.phases[1].depends_on, 'Phase 1');
});
test('display_name truncates long phase names', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Short' },
{ number: '2', name: 'Exactly Twenty Chars' },
{ number: '3', name: 'This Name Is Way Too Long For The Table' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].display_name, 'Short');
assert.strictEqual(output.phases[1].display_name, 'Exactly Twenty Chars');
assert.strictEqual(output.phases[2].display_name, 'This Name Is Way To…');
assert.strictEqual(output.phases[2].display_name.length, 20);
// Full name is preserved
assert.strictEqual(output.phases[2].name, 'This Name Is Way Too Long For The Table');
});
test('activity detection: recent file = active', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Active Phase' }]);
// Scaffold with a file — it will have current mtime (within 5 min)
scaffoldPhase(tmpDir, 1, { slug: 'active-phase', context: true });
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].is_active, true);
assert.ok(output.phases[0].last_activity !== null);
});
test('conflict filter: blocks dependent phase execute when dep is active', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation', complete: true },
{ number: '2', name: 'API Layer', depends_on: 'Phase 1' },
{ number: '3', name: 'Auth', depends_on: 'Phase 2' },
]);
// Phase 2: partial (actively executing — has 2 plans, 1 summary)
scaffoldPhase(tmpDir, 2, { slug: 'api-layer', context: true, plans: 2, summaries: 1 });
// Phase 3: planned and deps would be met if Phase 2 were complete, but it's not
scaffoldPhase(tmpDir, 3, { slug: 'auth', context: true, plans: 1 });
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
// Phase 2 is partial — should NOT appear as execute recommendation (already running)
// Phase 3 deps_satisfied is false (Phase 2 not complete) — also no recommendation
const execRecs = output.recommended_actions.filter(r => r.action === 'execute');
assert.strictEqual(execRecs.length, 0);
});
test('conflict filter: allows independent phase execute in parallel', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation', complete: true },
{ number: '2', name: 'API Layer', depends_on: 'Phase 1' },
{ number: '3', name: 'Notifications' }, // no deps — independent
]);
// Phase 2: partial (actively executing)
scaffoldPhase(tmpDir, 2, { slug: 'api-layer', context: true, plans: 2, summaries: 1 });
// Phase 3: planned, no deps — independent of Phase 2
scaffoldPhase(tmpDir, 3, { slug: 'notifications', context: true, plans: 1 });
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
// Phase 3 is independent of Phase 2 — should be recommended for execution
const execRecs = output.recommended_actions.filter(r => r.action === 'execute');
assert.strictEqual(execRecs.length, 1);
assert.strictEqual(execRecs[0].phase, '3');
});
test('output includes project_root field', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Test' }]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
// macOS resolves /var → /private/var; normalize both sides
assert.strictEqual(fs.realpathSync(output.project_root), fs.realpathSync(tmpDir));
});
test('output includes manager_flags defaults when not configured', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Test' }]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.ok(output.manager_flags, 'should include manager_flags');
assert.strictEqual(output.manager_flags.discuss, '');
assert.strictEqual(output.manager_flags.plan, '');
assert.strictEqual(output.manager_flags.execute, '');
});
test('output includes manager_flags from config when set', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Test' }]);
// Write config with manager flags
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
manager: {
flags: {
discuss: '--auto --analyze',
plan: '--skip-research',
execute: '--interactive',
}
}
})
);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.ok(output.manager_flags, 'should include manager_flags');
assert.strictEqual(output.manager_flags.discuss, '--auto --analyze');
assert.strictEqual(output.manager_flags.plan, '--skip-research');
assert.strictEqual(output.manager_flags.execute, '--interactive');
});
test('sanitizes invalid manager_flags to prevent injection (#1410)', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Test' }]);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
manager: {
flags: {
discuss: '; rm -rf /',
plan: '--valid-flag',
execute: '$(whoami)',
}
}
})
);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
// Invalid flags should be sanitized to empty string
assert.strictEqual(output.manager_flags.discuss, '', 'injection attempt should be sanitized');
assert.strictEqual(output.manager_flags.plan, '--valid-flag', 'valid flag should pass through');
assert.strictEqual(output.manager_flags.execute, '', 'command substitution should be sanitized');
});
test('does not recommend BACKLOG phases (999.x) as next actions', () => {
writeState(tmpDir);
// Regular phase (planned, deps met) plus a backlog phase (999.1) also planned
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '999.1', name: 'Nice to have feature (BACKLOG)' },
]);
// Phase 1: planned (has plan, no summary)
scaffoldPhase(tmpDir, 1, { plans: 1 });
// Phase 999.1: planned (has plan, no summary)
const backlogDir = path.join(tmpDir, '.planning', 'phases', '999.1-backlog');
fs.mkdirSync(backlogDir, { recursive: true });
fs.writeFileSync(path.join(backlogDir, '999.1-01-PLAN.md'), '# Backlog Plan');
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const recommended = output.recommended_actions || [];
const backlogRecs = recommended.filter(r => /^999/.test(r.phase));
assert.strictEqual(backlogRecs.length, 0, 'no 999.x phases should appear in recommended_actions');
// Phase 1 (non-backlog) should still be recommended
const activeRecs = recommended.filter(r => r.phase === '1');
assert.strictEqual(activeRecs.length, 1, 'phase 1 should still be recommended');
});
test('output includes response_language when configured', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Test' }]);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ response_language: 'Japanese' })
);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.response_language, 'Japanese');
});
test('output omits response_language when not configured', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Test' }]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.response_language, undefined);
});
test('all_complete is true when non-backlog phases are complete and 999.x exists (#2129)', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Setup', complete: true },
{ number: '2', name: 'Core', complete: true },
{ number: '3', name: 'Polish', complete: true },
{ number: '999.1', name: 'Backlog idea' },
]);
// Scaffold completed phases on disk
writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 2, summaries: 2 }), '01');
writePassedVerification(scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 }), '02');
writePassedVerification(scaffoldPhase(tmpDir, 3, { slug: 'polish', plans: 1, summaries: 1 }), '03');
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_complete, true, 'all_complete should be true when only 999.x phases remain incomplete');
});
test('all_complete false with incomplete non-backlog phase still produces recommended_actions (#2129)', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Setup', complete: true },
{ number: '2', name: 'Core', complete: true },
{ number: '3', name: 'Polish' },
{ number: '999.1', name: 'Backlog idea' },
]);
writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 1, summaries: 1 }), '01');
writePassedVerification(scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 }), '02');
// Phase 3 has no directory — should trigger discuss recommendation
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_complete, false, 'all_complete should be false with phase 3 incomplete');
assert.ok(output.recommended_actions.length > 0, 'recommended_actions should not be empty when non-backlog phases remain');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bug #2267: deps_satisfied should include phases from shipped milestones.
//
// Root cause: completedNums was built only from the current milestone's phases,
// so a dependency on a phase from a previously shipped milestone was never
// satisfied — even though all prior-milestone phases are complete by definition.
// ─────────────────────────────────────────────────────────────────────────────
describe('init manager — cross-milestone dependency satisfaction (#2267)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
/**
* Write a ROADMAP.md that has:
* - A shipped previous milestone (v1.0) inside a <details> block containing
* Phase 5 marked [x] complete.
* - A current active milestone (v2.0) containing Phase 6 that depends on
* Phase 5.
*/
function writeRoadmapWithShippedMilestone(dir) {
const content = [
'# Roadmap',
'',
'<details>',
'<summary>v1.0 — Initial Release (Shipped)</summary>',
'',
'## Roadmap v1.0: Initial Release',
'',
'- [x] **Phase 5: Auth**',
'',
'### Phase 5: Auth',
'**Goal:** Add authentication',
'',
'</details>',
'',
'## Roadmap v2.0: Dashboard',
'',
'- [ ] **Phase 6: Dashboard**',
'',
'### Phase 6: Dashboard',
'**Goal:** Build dashboard',
'**Depends on:** Phase 5',
'',
].join('\n');
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), content);
}
function writeStateWithMilestone(dir, version) {
fs.writeFileSync(
path.join(dir, '.planning', 'STATE.md'),
`---\nmilestone: ${version}\n---\n# State\n`
);
}
test('phase depending on a shipped-milestone phase has deps_satisfied: true (#2267)', () => {
writeRoadmapWithShippedMilestone(tmpDir);
writeStateWithMilestone(tmpDir, 'v2.0');
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Only the current milestone's phases should appear in the phases array
assert.strictEqual(output.phases.length, 1, 'Should have exactly one phase from the current milestone');
const phase6 = output.phases[0];
assert.strictEqual(phase6.number, '6', 'Should be Phase 6');
// Phase 6 depends on Phase 5 from the prior milestone — must be satisfied
assert.strictEqual(
phase6.deps_satisfied,
true,
'Phase 6 dep on shipped Phase 5 should be satisfied'
);
});
test('phase depending on a non-existent phase has deps_satisfied: false (#2267)', () => {
writeRoadmapWithShippedMilestone(tmpDir);
writeStateWithMilestone(tmpDir, 'v2.0');
// Add a second phase in the current milestone that depends on a phantom phase
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
const existing = fs.readFileSync(roadmapPath, 'utf-8');
const withExtra = existing + [
'### Phase 7: Extra',
'**Goal:** Extra work',
'**Depends on:** Phase 99',
'',
].join('\n');
fs.writeFileSync(roadmapPath, withExtra);
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const phase7 = output.phases.find(p => p.number === '7');
assert.ok(phase7, 'Phase 7 should be in the output');
assert.strictEqual(
phase7.deps_satisfied,
false,
'Phase 7 dep on non-existent Phase 99 should not be satisfied'
);
});
});