* fix: PROJECT.md is shared across workstreams, not workstream-scoped (#4455 follow-up)
Self-discovered regression, found while diagnosing #4456: #4455 (PR #4542,
merged as c6df4e1e46) resolved `project_path` in cmdInitCompleteMilestone
via the workstream-scoped planningDir(cwd), reasoning by analogy from
MILESTONES.md (which cmdMilestoneComplete genuinely does write
workstream-scoped, per an explicit #1911 comment) without checking an
actual PROJECT.md write call site.
PROJECT.md is documented as SHARED across workstreams, not cloned per
workstream:
- gsd-core/references/workstream-flag.md's directory diagram marks it
`# Shared`.
- new-milestone.md states it outright: "PROJECT.md is shared across
workstreams" — and explicitly SKIPS writing its `## Current Milestone`
heading under an active workstream specifically to avoid clobbering the
one shared file (#2308): "whichever workstream runs new-milestone last
would silently win the shared heading."
- cmdWorkstreamCreate (src/workstream.cts) never creates a PROJECT.md
under a new workstream directory — only STATE.md and phases/.
Under an active workstream, complete-milestone.md's safety commit was
therefore silently missing the real PROJECT.md from its --files list
(staging a path that never exists instead).
Also fixed, in the same change: withProjectRoot (src/init.cts) — a
helper every cmdInit* function calls to enrich its JSON output — read
PROJECT.md via the workstream-aware planningDir(cwd) to extract
project_title. This predates #4455 entirely (unrelated diff, no prior
test either direction) but is the identical defect class, one line,
directly adjacent to what this fix already touches: under an active
workstream, every init.* command's project_title field silently
vanished, since no PROJECT.md ever exists at the workstream path.
Deliberately NOT fixed here: cmdInitNewMilestone (src/init.cts, the
function backing new-milestone.md's own init.new-milestone call) has
the identical bug for project_path/project_exists/config_path
(config.json is ALSO marked `# Shared` in the same diagram). That
function is exactly what #4456 (new-milestone.md's own missing --ws
forwarding) already needs to modify — its field-by-field scoping
belongs in that follow-up, not here.
Also deliberately NOT touched: getLatestCompletedMilestone reads
MILESTONES.md via the ROOT-ONLY planningRoot(cwd), contradicting
cmdMilestoneComplete's workstream-scoped WRITE. Resolving that
disagreement requires a genuine product-intent call (is "latest
completed milestone" scoped to the current workstream or pooled
project-wide?) that isn't derivable from the code alone — left alone
rather than guessed.
Verified: direct CLI invocation confirms project_path/project_title
now resolve to the root PROJECT.md under GSD_WORKSTREAM=alpha instead
of a workstream-scoped path that no writer ever populates. New
regression tests cover both the real cmdInitCompleteMilestone function
(tests/init-manager.test.cjs) and withProjectRoot's project_title
(same file); the existing fence-level test in
tests/workstream-scoped-paths.test.cjs (which enshrined the wrong
behavior) is corrected to reflect reality.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: extend PROJECT.md root-scoping to 6 more cmdInit* functions found via grep
A code-review pass on this fix's first draft (which only touched
cmdInitCompleteMilestone and withProjectRoot) flagged that cmdInitNewProject
had the identical bug and asked whether other call sites were missed.
Grepping the exact literal patterns found SEVEN total occurrences across
cmdInitNewProject, cmdInitNewMilestone, cmdInitIngestDocs, cmdInitResume,
cmdInitMilestoneOp, cmdInitManager, and cmdInitProgress — not just the one
the review happened to spot.
All seven are the exact same defect with the exact same one-line fix
(resolve via planningRoot(cwd) instead of the workstream-scoped
planningDir(cwd)), so all seven are fixed here via a uniform find/replace,
including cmdInitNewMilestone — an earlier plan was to leave that one for
#4456 (which already needs to touch that function to add missing --ws
forwarding), but leaving exactly one of seven identical, equally-evidenced
occurrences unfixed for no functional reason would have been an arbitrary
inconsistency, not a principled scope boundary. #4456 still needs to add
the actual --ws parameter threading to cmdInitNewMilestone and separately
fix its also-wrong config_path field (config.json is marked `# Shared` in
workstream-flag.md's diagram too — a different field, needing its own
verification, not swept up in this mechanical grep-and-replace).
Also fixed: buildInitCompletenessFields (used by cmdInitNewProject and
cmdInitResume for the `init_incomplete` partial-bootstrap discriminator)
had the same bug in a differently-shaped literal (bare
fs.existsSync(path.join(dir, 'PROJECT.md')), not the pathExistsInternal/
toPosixPath wrapper the grep matched) — only its PROJECT.md check moves to
planningRoot(cwd); the REQUIREMENTS.md/MILESTONES.md/ROADMAP.md/STATE.md
checks in the same function correctly stay workstream-scoped.
Verified: direct CLI invocation of `init ingest-docs`, `init resume`,
`init progress`, `init new-project`, and `init milestone-op` under
GSD_WORKSTREAM=alpha all now report the root PROJECT.md correctly. New
parametrized regression test in tests/init-manager.test.cjs exercises all
five through the real CLI router (catching a router-wiring regression too,
not just a src/init.cts internals check).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: PROJECT.md respects GSD_PROJECT namespacing, just not GSD_WORKSTREAM
gsd-test caught a real regression in this fix's own previous push:
resolving PROJECT.md via planningRoot(cwd) ignores BOTH GSD_PROJECT and
GSD_WORKSTREAM, but only the workstream dimension is actually meant to be
ignored for PROJECT.md. tests/init.test.cjs's pre-existing #3749 coverage
("init.new-project — GSD_PROJECT scoping") establishes that PROJECT.md
legitimately lives at `.planning/<project>/PROJECT.md` when GSD_PROJECT is
set — a genuine, tested, pre-existing multi-project namespace, distinct
from a single project's own workstreams (which DO share one PROJECT.md,
per the evidence in the prior two commits).
Corrected every PROJECT.md path resolution in this diff to
planningDir(cwd, null): `ws` explicitly nulled (so GSD_WORKSTREAM is never
consulted), `project` left as undefined so it still defaults from
GSD_PROJECT. This is the correct middle ground between the original bug
(fully workstream-scoped, #4455's mistake) and the previous commit's
overcorrection (fully root-only, breaking #3749).
Verified empirically, all three combinations: GSD_WORKSTREAM alone
resolves to root; GSD_PROJECT alone resolves to the namespaced path; both
set together resolves to the namespaced path (workstream ignored, matching
the resolution-priority contract in workstream-flag.md — project owns a
distinct planning tree, workstreams exist inside ONE project's tree). New
regression tests cover both the GSD_PROJECT-alone and
GSD_PROJECT+GSD_WORKSTREAM-together cases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs: backfill changeset PR number
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1574 lines
65 KiB
JavaScript
1574 lines
65 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, absPlanningPath } = require('./helpers.cjs');
|
|
const { seedWorkstream } = require('./fixtures/index.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('activity detection: hour-old file = not active', () => {
|
|
writeState(tmpDir);
|
|
writeRoadmap(tmpDir, [{ number: '1', name: 'Stale Phase' }]);
|
|
|
|
const PINNED_NOW_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z (second-aligned)
|
|
const phaseDir = scaffoldPhase(tmpDir, 1, { slug: 'stale-phase', context: true });
|
|
const files = fs.readdirSync(phaseDir);
|
|
const old = new Date(PINNED_NOW_MS - 60 * 60 * 1000); // 1 hour before the pinned "now"
|
|
for (const f of files) {
|
|
fs.utimesSync(path.join(phaseDir, f), old, old);
|
|
}
|
|
|
|
const result = runGsdTools('init manager', tmpDir, {
|
|
GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_NOW_MS),
|
|
});
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.phases[0].is_active, false);
|
|
});
|
|
|
|
// #4134 — a first-ever ROADMAP.md whose H1 puts the version after the name
|
|
// (`# Roadmap: <Project> — <Milestone Name> (v1.13)` — the shape nothing
|
|
// templates for a project's first milestone) used to make the §7.2 pinned
|
|
// name rule return the literal `)` left after the version token as the
|
|
// milestone's "name", and init manager displayed it verbatim. The refusal
|
|
// reports the honest ADR-3180 §7.2 rule-6 answer instead: the version is
|
|
// real, the name is not resolvable from that heading, milestone_name is
|
|
// null — never a punctuation fragment.
|
|
test('#4134 — milestone_name is null, never ")", for a name-then-version H1', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap: GSD Core — Native OMP Runtime Support (v1.13)',
|
|
'',
|
|
'## Phases',
|
|
'',
|
|
'- [ ] **Phase 1: Runtime Adapter Interface**',
|
|
'',
|
|
'## Phase Details',
|
|
'',
|
|
'### Phase 1: Runtime Adapter Interface',
|
|
'',
|
|
'**Goal:** Define the adapter contract.',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
[
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'milestone: v1.13',
|
|
'milestone_name: Native OMP Runtime Support',
|
|
'status: planning',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
|
|
const result = runGsdTools('init manager', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.milestone_version, 'v1.13');
|
|
assert.strictEqual(output.milestone_name, null);
|
|
assert.ok(!result.output.includes('")"'), 'a lone ")" value must not appear anywhere in the JSON');
|
|
});
|
|
|
|
// #3314 — ADR-456 subprocess clock pin: cmdInitManager's `is_active` gate
|
|
// (nowMs - newestMtime < 300000) is CLI-subprocess tested, so only the
|
|
// GSD_TEST_MODE+GSD_NOW_MS pin can control "now" here (t.mock.timers in the
|
|
// test process cannot reach the spawned child). newestMtime is set via
|
|
// fs.utimesSync to an exact epoch offset from the pinned "now" so the
|
|
// 300000ms boundary is provable exactly, not just "roughly recent"/"roughly old".
|
|
describe('is_active boundary (#3314 — exactly 300000ms, condition is strict <)', () => {
|
|
const PINNED_NOW_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z (second-aligned)
|
|
const PIN_ENV = { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_NOW_MS) };
|
|
|
|
function scaffoldWithMtimeOffset(tmpDirLocal, offsetMs) {
|
|
writeState(tmpDirLocal);
|
|
writeRoadmap(tmpDirLocal, [{ number: '1', name: 'Boundary Phase' }]);
|
|
const phaseDir = scaffoldPhase(tmpDirLocal, 1, { slug: 'boundary-phase', context: true });
|
|
const mtimeMs = PINNED_NOW_MS - offsetMs;
|
|
const mtimeDate = new Date(mtimeMs);
|
|
for (const f of fs.readdirSync(phaseDir)) {
|
|
fs.utimesSync(path.join(phaseDir, f), mtimeDate, mtimeDate);
|
|
}
|
|
}
|
|
|
|
test('boundary: 299999ms since last activity → is_active true', () => {
|
|
scaffoldWithMtimeOffset(tmpDir, 299_999);
|
|
const result = runGsdTools('init manager', tmpDir, PIN_ENV);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phases[0].is_active, true);
|
|
});
|
|
|
|
test('boundary: exactly 300000ms since last activity → is_active false', () => {
|
|
scaffoldWithMtimeOffset(tmpDir, 300_000);
|
|
const result = runGsdTools('init manager', tmpDir, PIN_ENV);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phases[0].is_active, false);
|
|
});
|
|
|
|
test('boundary: 300001ms since last activity → is_active false', () => {
|
|
scaffoldWithMtimeOffset(tmpDir, 300_001);
|
|
const result = runGsdTools('init manager', tmpDir, PIN_ENV);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phases[0].is_active, false);
|
|
});
|
|
});
|
|
|
|
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'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #4455 — init manager emits workstream-scoped state_path/roadmap_path/
|
|
// archive_dir (same pattern cmdInitPlanPhase already uses, plus the
|
|
// milestone.cts archive-dir composition, #1911). autonomous.md's
|
|
// discover_phases/iterate/lifecycle steps consume these instead of
|
|
// hardcoding `.planning/STATE.md` / `.planning/milestones/...`.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('init manager — state_path/roadmap_path/archive_dir (#4455)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// macOS: realpath before absPlanningPath comparisons (symlinked /var/tmp).
|
|
tmpDir = require('fs').realpathSync(createTempProject());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('flat mode (no GSD_WORKSTREAM): paths resolve to root .planning (regression guard)', () => {
|
|
writeState(tmpDir);
|
|
writeRoadmap(tmpDir, [{ number: '1', name: 'Setup' }]);
|
|
|
|
const result = runGsdTools('init manager', tmpDir, { GSD_WORKSTREAM: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones'));
|
|
});
|
|
|
|
test('GSD_WORKSTREAM=alpha: paths resolve into the workstream, not root (#4455 regression)', () => {
|
|
seedWorkstream(tmpDir, {
|
|
name: 'alpha',
|
|
state: '---\nstatus: active\n---\n# State\n',
|
|
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
|
});
|
|
|
|
const result = runGsdTools('init manager', tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'ROADMAP.md'));
|
|
assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'milestones'));
|
|
// Goodhart both-directions: must NOT be the flat root form.
|
|
assert.notStrictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.notStrictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.notStrictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones'));
|
|
});
|
|
|
|
test('state_path/roadmap_path are null when the files do not exist yet', () => {
|
|
// init manager itself requires ROADMAP.md/STATE.md to exist to proceed
|
|
// past its own readiness guard, so exercise the null branch through
|
|
// init complete-milestone instead — same fs.existsSync(...) ? ... : null
|
|
// pattern, no upstream guard blocking an empty-fixture run.
|
|
const result = runGsdTools('init complete-milestone', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.state_path, null);
|
|
assert.strictEqual(output.roadmap_path, null);
|
|
assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones'));
|
|
});
|
|
});
|
|
|
|
describe('init complete-milestone — state_path/roadmap_path/archive_dir (#4455)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = require('fs').realpathSync(createTempProject());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('flat mode (no GSD_WORKSTREAM): paths resolve to root .planning (regression guard)', () => {
|
|
writeState(tmpDir);
|
|
writeRoadmap(tmpDir, [{ number: '1', name: 'Setup' }]);
|
|
|
|
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones'));
|
|
});
|
|
|
|
test('GSD_WORKSTREAM=alpha: paths resolve into the workstream, not root (#4455 regression)', () => {
|
|
seedWorkstream(tmpDir, {
|
|
name: 'alpha',
|
|
state: '---\nstatus: active\n---\n# State\n',
|
|
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
|
});
|
|
|
|
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'ROADMAP.md'));
|
|
assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'milestones'));
|
|
assert.notStrictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.notStrictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.notStrictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones'));
|
|
});
|
|
});
|
|
|
|
describe('init complete-milestone — milestones_path/project_path/requirements_path (#4455 follow-up)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = require('fs').realpathSync(createTempProject());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
function writeRootProjectMd(dir) {
|
|
fs.writeFileSync(path.join(dir, '.planning', 'PROJECT.md'), '# Test Project\n');
|
|
}
|
|
|
|
test('flat mode: milestones_path/requirements_path resolve to root, project_path resolves to root (regression guard)', () => {
|
|
writeState(tmpDir);
|
|
writeRoadmap(tmpDir, [{ number: '1', name: 'Setup' }]);
|
|
writeRootProjectMd(tmpDir);
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), '# Milestones\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
|
|
|
|
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.milestones_path, absPlanningPath(tmpDir, 'MILESTONES.md'));
|
|
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'));
|
|
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'REQUIREMENTS.md'));
|
|
});
|
|
|
|
test('GSD_WORKSTREAM=alpha: milestones_path/requirements_path resolve into the workstream, but project_path STAYS root (PROJECT.md is shared, #4455 follow-up regression)', () => {
|
|
seedWorkstream(tmpDir, {
|
|
name: 'alpha',
|
|
state: '---\nstatus: active\n---\n# State\n',
|
|
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
|
});
|
|
// PROJECT.md is only ever written at root — cmdWorkstreamCreate never
|
|
// creates a per-workstream copy (it is a documented shared file, see
|
|
// gsd-core/references/workstream-flag.md's directory diagram).
|
|
writeRootProjectMd(tmpDir);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'MILESTONES.md'), '# Milestones\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'REQUIREMENTS.md'), '# Requirements\n');
|
|
|
|
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.milestones_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'MILESTONES.md'));
|
|
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'REQUIREMENTS.md'));
|
|
// The regression this test guards against: project_path must be the
|
|
// ROOT PROJECT.md, never a workstream-scoped path that no writer ever
|
|
// populates.
|
|
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'));
|
|
assert.notStrictEqual(output.project_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'PROJECT.md'));
|
|
});
|
|
|
|
test('GSD_PROJECT=second-product: project_path resolves into the PROJECT namespace, not root (#3749 regression — round 1 of this fix broke this)', () => {
|
|
// The FIRST version of this #4455 follow-up resolved project_path via
|
|
// planningRoot(cwd), which ignores GSD_PROJECT entirely — gsd-test caught
|
|
// this immediately (tests/init.test.cjs's pre-existing #3749 coverage) on
|
|
// this fix's own first push. PROJECT.md is shared across a project's own
|
|
// WORKSTREAMS, but a DIFFERENT project (GSD_PROJECT) legitimately gets
|
|
// its own separate PROJECT.md at `.planning/<project>/PROJECT.md`. The
|
|
// correct resolution is planningDir(cwd, null) — `ws` explicitly nulled
|
|
// (never read from GSD_WORKSTREAM), `project` left to default from
|
|
// GSD_PROJECT.
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'second-product', 'PROJECT.md'), '# Second Product\n');
|
|
writeRootProjectMd(tmpDir); // an unrelated root PROJECT.md must not win
|
|
|
|
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_PROJECT: 'second-product' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'second-product', 'PROJECT.md'));
|
|
assert.notStrictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'));
|
|
});
|
|
|
|
test('GSD_PROJECT=second-product AND GSD_WORKSTREAM=alpha together: project_path follows the PROJECT namespace, ignoring the workstream', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product', 'workstreams', 'alpha'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'second-product', 'PROJECT.md'), '# Second Product\n');
|
|
|
|
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_PROJECT: 'second-product', GSD_WORKSTREAM: 'alpha' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'second-product', 'PROJECT.md'));
|
|
assert.notStrictEqual(output.project_path,
|
|
absPlanningPath(tmpDir, 'second-product', 'workstreams', 'alpha', 'PROJECT.md'));
|
|
});
|
|
});
|
|
|
|
describe('withProjectRoot — project_title reads PROJECT.md from root even under a workstream (#4455 follow-up)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = require('fs').realpathSync(createTempProject());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// Exercised via `init complete-milestone` rather than `init manager`:
|
|
// cmdInitManager has its own readiness guard requiring STATE.md/ROADMAP.md
|
|
// to already exist (see the "state_path/roadmap_path are null" test
|
|
// above), which would obscure whether THIS test is actually exercising
|
|
// withProjectRoot. Both commands share the same withProjectRoot helper.
|
|
test('flat mode: project_title is populated from the root PROJECT.md (regression guard)', () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# My Test Project\n\nSome content.\n');
|
|
|
|
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.project_title, 'My Test Project');
|
|
});
|
|
|
|
test('GSD_WORKSTREAM=alpha: project_title is STILL populated from the root PROJECT.md, not silently dropped (#4455 follow-up regression)', () => {
|
|
seedWorkstream(tmpDir, {
|
|
name: 'alpha',
|
|
state: '---\nstatus: active\n---\n# State\n',
|
|
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
|
});
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# My Test Project\n\nSome content.\n');
|
|
|
|
const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.project_title, 'My Test Project',
|
|
'project_title must be read from the shared root PROJECT.md even when a workstream is active');
|
|
});
|
|
});
|
|
|
|
describe('init subcommands sharing the project_exists/project_path PROJECT.md pattern (#4455 follow-up)', () => {
|
|
// A code-review pass on this fix's first draft found the identical
|
|
// workstream-scoped-PROJECT.md bug (fixed above for cmdInitCompleteMilestone
|
|
// and withProjectRoot) repeated verbatim, via grep, in six more cmdInit*
|
|
// functions. Each is exercised here through its real CLI subcommand rather
|
|
// than re-asserting src/init.cts internals directly, so a regression in the
|
|
// router wiring would also be caught. `init manager` and `init new-milestone`
|
|
// are deliberately excluded: `manager` has its own STATE.md/ROADMAP.md
|
|
// readiness guard (covered separately above via `init complete-milestone`,
|
|
// which shares withProjectRoot); `new-milestone`'s project_path fix is
|
|
// real (see src/init.cts) but its dedicated coverage belongs with #4456's
|
|
// own new-milestone.md workstream-forwarding work, not duplicated here.
|
|
const SUBCOMMANDS = ['ingest-docs', 'resume', 'progress', 'new-project'];
|
|
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = require('fs').realpathSync(createTempProject());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
for (const subcommand of SUBCOMMANDS) {
|
|
test(`GSD_WORKSTREAM=alpha: "init ${subcommand}" resolves project_path to the shared root PROJECT.md`, () => {
|
|
seedWorkstream(tmpDir, {
|
|
name: 'alpha',
|
|
state: '---\nstatus: active\n---\n# State\n',
|
|
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
|
});
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Test Project\n');
|
|
|
|
const result = runGsdTools(`init ${subcommand}`, tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
|
assert.ok(result.success, `init ${subcommand} failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
|
|
assert.strictEqual(output.project_exists, true,
|
|
`init ${subcommand}: project_exists must be true (root PROJECT.md exists), got: ${JSON.stringify(output.project_exists)}`);
|
|
if ('project_path' in output) {
|
|
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'),
|
|
`init ${subcommand}: project_path must resolve to the shared root PROJECT.md, got: ${output.project_path}`);
|
|
assert.notStrictEqual(output.project_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'PROJECT.md'),
|
|
`init ${subcommand}: project_path must not resolve into the workstream directory`);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('milestone-op: GSD_WORKSTREAM=alpha does not report project_exists=false for a root-only PROJECT.md', () => {
|
|
// milestone-op does not expose a project_path field, only project_exists
|
|
// via buildInitCompletenessFields — the coreComplete/init_incomplete fix.
|
|
seedWorkstream(tmpDir, {
|
|
name: 'alpha',
|
|
state: '---\nstatus: active\n---\n# State\n',
|
|
roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n',
|
|
});
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Test Project\n');
|
|
|
|
const result = runGsdTools('init milestone-op', tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
|
assert.ok(result.success, `init milestone-op failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.project_exists, true,
|
|
`init milestone-op: project_exists must be true for a root-only PROJECT.md under a workstream, got: ${JSON.stringify(output.project_exists)}`);
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3584-runtime-slash-emitters.test.cjs — consolidation epic #1969 (B2 #1971)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3584-runtime-slash-emitters (consolidation epic #1969 B2 #1971)", () => {
|
|
/**
|
|
* Regression tests for bug #3584 — runtime emitters use the slash formatter.
|
|
*
|
|
* These tests exercise the actual runtime command handlers via `runGsdTools`
|
|
* and assert on the structured payloads they emit/persist. They prove that
|
|
* the high-impact emitters identified in the issue (`init.cjs` recommended
|
|
* actions, `phase.cjs` ROADMAP persistence, `verify.cjs` remediation hints,
|
|
* `milestone.cjs` Operator-Next-Steps persistence, `validate-command-router`
|
|
* fracture recommendations) no longer emit the unroutable `/gsd:<cmd>` colon
|
|
* form for skills-based runtimes.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const {
|
|
runGsdTools,
|
|
createTempProject,
|
|
createTempDir,
|
|
cleanup,
|
|
} = require('./helpers.cjs');
|
|
|
|
// Helper: assert no string field anywhere in `value` (recursive) contains '/gsd:'.
|
|
function assertNoColonForm(value, label) {
|
|
const stack = [{ v: value, p: label }];
|
|
while (stack.length > 0) {
|
|
const { v, p } = stack.pop();
|
|
if (typeof v === 'string') {
|
|
assert.ok(
|
|
!v.includes('/gsd:'),
|
|
`${p}: must not contain deprecated /gsd: form, got ${JSON.stringify(v)}`,
|
|
);
|
|
} else if (Array.isArray(v)) {
|
|
v.forEach((item, i) => stack.push({ v: item, p: `${p}[${i}]` }));
|
|
} else if (v && typeof v === 'object') {
|
|
for (const key of Object.keys(v)) {
|
|
stack.push({ v: v[key], p: `${p}.${key}` });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper: collect every `command` string under a recommendedActions-style array.
|
|
function collectCommandFields(value) {
|
|
const out = [];
|
|
const stack = [value];
|
|
while (stack.length > 0) {
|
|
const v = stack.pop();
|
|
if (Array.isArray(v)) {
|
|
for (const item of v) stack.push(item);
|
|
} else if (v && typeof v === 'object') {
|
|
if (typeof v.command === 'string') out.push(v.command);
|
|
for (const key of Object.keys(v)) stack.push(v[key]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe('bug-3584: init manager recommendedActions emit hyphen form', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
// Minimal ROADMAP + STATE so init manager will run.
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Milestone v1',
|
|
'',
|
|
'### Phase 1: Foundation',
|
|
'**Goal:** scaffold',
|
|
'**Plans:** 1 plan',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
'# State\n\nCurrent Phase: 1\n',
|
|
);
|
|
});
|
|
|
|
afterEach(() => cleanup(tmpDir));
|
|
|
|
test('skills runtime (claude) emits /gsd-<cmd> in recommended_actions[].command', () => {
|
|
// Plan-but-not-executed: directory exists with a PLAN.md → execute is recommended.
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
|
|
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'claude' });
|
|
assert.ok(result.success, `init manager failed: ${result.error || result.output}`);
|
|
|
|
const payload = JSON.parse(result.output);
|
|
const commands = collectCommandFields(payload.recommended_actions || []);
|
|
assert.ok(commands.length > 0, 'recommended_actions should produce at least one command');
|
|
|
|
for (const cmd of commands) {
|
|
assert.ok(
|
|
cmd.startsWith('/gsd-'),
|
|
`recommended_actions command must start with /gsd- for skills-based runtimes, got ${cmd}`,
|
|
);
|
|
assert.ok(
|
|
!cmd.includes('/gsd:'),
|
|
`recommended_actions command must not contain /gsd: colon form, got ${cmd}`,
|
|
);
|
|
}
|
|
assertNoColonForm(payload, 'init.manager payload');
|
|
});
|
|
|
|
test('codex runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
|
|
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex' });
|
|
assert.ok(result.success, `init manager (codex) failed: ${result.error || result.output}`);
|
|
|
|
const payload = JSON.parse(result.output);
|
|
const commands = collectCommandFields(payload.recommended_actions || []);
|
|
assert.ok(commands.length > 0);
|
|
|
|
for (const cmd of commands) {
|
|
assert.ok(
|
|
cmd.startsWith('$gsd-'),
|
|
`codex recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('codex alias runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
|
|
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex-app' });
|
|
assert.ok(result.success, `init manager (codex-app) failed: ${result.error || result.output}`);
|
|
|
|
const payload = JSON.parse(result.output);
|
|
const commands = collectCommandFields(payload.recommended_actions || []);
|
|
assert.ok(commands.length > 0);
|
|
|
|
for (const cmd of commands) {
|
|
assert.ok(
|
|
cmd.startsWith('$gsd-'),
|
|
`codex alias recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('bug-3584: phase add persists hyphen form into ROADMAP.md', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n## Milestone v1\n\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
'# State\n',
|
|
);
|
|
});
|
|
|
|
afterEach(() => cleanup(tmpDir));
|
|
|
|
test('phase add persists hyphen-form slash command in roadmap get-phase payload', () => {
|
|
// 1. Add a phase — the system under test persists the phase entry into ROADMAP.md.
|
|
const addResult = runGsdTools(
|
|
['phase', 'add', 'Test new feature'],
|
|
tmpDir,
|
|
{ GSD_RUNTIME: 'claude' },
|
|
);
|
|
assert.ok(addResult.success, `phase add failed: ${addResult.error || addResult.output}`);
|
|
const addPayload = JSON.parse(addResult.output);
|
|
assert.ok(addPayload.phase_number, 'phase add must return phase_number');
|
|
|
|
// 2. Read the persisted section back via the structured roadmap-get-phase
|
|
// contract (NOT via readFileSync; the `section` field on the JSON payload
|
|
// is the runtime's typed projection of the on-disk ROADMAP content).
|
|
const getResult = runGsdTools(
|
|
['roadmap', 'get-phase', String(addPayload.phase_number)],
|
|
tmpDir,
|
|
{ GSD_RUNTIME: 'claude' },
|
|
);
|
|
assert.ok(getResult.success, `roadmap get-phase failed: ${getResult.error || getResult.output}`);
|
|
const getPayload = JSON.parse(getResult.output);
|
|
assert.ok(getPayload.found, 'roadmap get-phase must find the newly-added phase');
|
|
assert.ok(typeof getPayload.section === 'string', 'roadmap get-phase must return a section field');
|
|
|
|
// 3. The persisted phase section must use the routable hyphen form.
|
|
assert.ok(
|
|
!getPayload.section.includes('/gsd:plan-phase'),
|
|
`persisted phase section must not contain /gsd:plan-phase, got: ${getPayload.section}`,
|
|
);
|
|
assert.ok(
|
|
getPayload.section.includes('/gsd-plan-phase'),
|
|
`persisted phase section must contain /gsd-plan-phase, got: ${getPayload.section}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bug-3584: validate health emits hyphen form in fix hints', () => {
|
|
test('validate health on a broken project returns hyphen-form fix strings', (t) => {
|
|
const tmpDir = createTempDir();
|
|
t.after(() => cleanup(tmpDir));
|
|
// No .planning directory → E001 fires with a `fix:` string that contains
|
|
// the slash-command form.
|
|
|
|
const result = runGsdTools(
|
|
['validate', 'health'],
|
|
tmpDir,
|
|
{ GSD_RUNTIME: 'claude' },
|
|
);
|
|
// validate health exits non-zero on broken projects but still emits JSON to stdout.
|
|
const stdout = result.output;
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(stdout);
|
|
} catch (e) {
|
|
throw new Error(`validate health did not emit parseable JSON: ${stdout.slice(0, 400)}`);
|
|
}
|
|
|
|
const allIssues = []
|
|
.concat(payload.errors || [])
|
|
.concat(payload.warnings || [])
|
|
.concat(payload.info || []);
|
|
|
|
assert.ok(allIssues.length > 0, 'validate health on a bare tmpdir should report at least one issue');
|
|
|
|
const fixesWithSlash = allIssues
|
|
.map((i) => i.fix)
|
|
.filter((f) => typeof f === 'string' && /gsd[-:]/.test(f));
|
|
assert.ok(
|
|
fixesWithSlash.length > 0,
|
|
'at least one fix hint should reference a /gsd- slash command',
|
|
);
|
|
|
|
for (const fix of fixesWithSlash) {
|
|
assert.ok(
|
|
!fix.includes('/gsd:'),
|
|
`validate health fix hint must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
|
|
);
|
|
}
|
|
assertNoColonForm(payload, 'validate health payload');
|
|
});
|
|
});
|
|
|
|
describe('bug-3584: validate context recommendation uses hyphen form', () => {
|
|
test('warning/critical recommendations emit /gsd-thread', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
// Critical band: 75% utilization. validate context emits JSON only with --json.
|
|
const result = runGsdTools(
|
|
['validate', 'context', '--tokens-used', '75000', '--context-window', '100000', '--json'],
|
|
tmpDir,
|
|
{ GSD_RUNTIME: 'claude' },
|
|
);
|
|
assert.ok(result.success, `validate context failed: ${result.error || result.output}`);
|
|
|
|
const payload = JSON.parse(result.output);
|
|
assert.ok(payload.recommendation, 'critical utilization should produce a recommendation');
|
|
assert.ok(
|
|
!payload.recommendation.includes('/gsd:'),
|
|
`recommendation must not contain /gsd:, got ${JSON.stringify(payload.recommendation)}`,
|
|
);
|
|
assert.ok(
|
|
payload.recommendation.includes('/gsd-thread'),
|
|
`recommendation must reference /gsd-thread, got ${JSON.stringify(payload.recommendation)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bug-3584: validate health uses formatter for codex runtime too', () => {
|
|
test('validate health under codex emits $gsd-<cmd> in fix strings (positive assertion)', (t) => {
|
|
const tmpDir = createTempDir();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const result = runGsdTools(
|
|
['validate', 'health'],
|
|
tmpDir,
|
|
{ GSD_RUNTIME: 'codex' },
|
|
);
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(result.output);
|
|
} catch (e) {
|
|
throw new Error(`validate health (codex) did not emit JSON: ${result.output.slice(0, 400)}`);
|
|
}
|
|
|
|
const allIssues = []
|
|
.concat(payload.errors || [])
|
|
.concat(payload.warnings || [])
|
|
.concat(payload.info || []);
|
|
|
|
// Collect fixes that mention any gsd slash-command form so we can lock
|
|
// both the absence of legacy forms AND the presence of the codex shape.
|
|
const fixesWithGsdRef = allIssues
|
|
.map((i) => i.fix)
|
|
.filter((f) => typeof f === 'string' && /(?:\$|\/)gsd[-:]/.test(f));
|
|
|
|
assert.ok(
|
|
fixesWithGsdRef.length > 0,
|
|
'validate health on a bare tmpdir must produce at least one fix hint referencing a gsd command',
|
|
);
|
|
|
|
for (const fix of fixesWithGsdRef) {
|
|
assert.ok(
|
|
fix.includes('$gsd-'),
|
|
`codex validate health fix must use shell-var $gsd- form, got ${JSON.stringify(fix)}`,
|
|
);
|
|
assert.ok(
|
|
!fix.includes('/gsd:'),
|
|
`codex validate health fix must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
|
|
);
|
|
assert.ok(
|
|
!fix.includes('/gsd-'),
|
|
`codex validate health fix must not contain /gsd- (skills) form, got ${JSON.stringify(fix)}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|