* fix(#4455): thread workstream-scoped paths through autonomous and complete-milestone workflows autonomous.md and complete-milestone.md read/wrote hardcoded literal `.planning/STATE.md` / `.planning/ROADMAP.md` / `.planning/milestones/...` paths in their shell fences, bypassing workstream scoping entirely. With GSD_WORKSTREAM=alpha set, planningDir(cwd) correctly resolves into workstreams/alpha/, but a literal `cat .planning/STATE.md` still read the ROOT file (or silently returned empty if root state was absent) -- reproduced deterministically in the issue's own repro. Root cause: each workflow step's bash fence is a separate shell invocation, and cmdInitManager/cmdInitCompleteMilestone's JSON payloads never carried resolved state_path/roadmap_path/archive_dir fields for the workflows to extract -- unlike cmdInitPlanPhase, which already does this correctly and is the pattern this fix mirrors. - src/init.cts: cmdInitManager and cmdInitCompleteMilestone now emit state_path/roadmap_path (workstream-scoped via planningDir(cwd), existence-checked, toPosixPath'd, null when absent -- identical to cmdInitPlanPhase's existing contract) and archive_dir (the milestone archive directory, composed the same way milestone.cts's already-correct archive helper does per #1911). - autonomous.md: discover_phases and iterate now extract state_path via the already-fetched INIT_MANAGER payload instead of hardcoding `.planning/STATE.md`; iterate's second, previously-separate hardcoded read is folded into the same fence (no double-fetch); lifecycle step 5b checks the resolved archive_dir instead of a hardcoded milestones path. - complete-milestone.md's reorganize_roadmap_and_delete_originals step (which previously called no init command at all) now fetches init.complete-milestone and uses the resolved roadmap_path/state_path/ archive_dir for the backlog read, the write-guard sentinel's armed content, the Write-tool target for the reorganized ROADMAP.md (the sentinel fence now echoes the resolved path so the executing agent can see it), and the safety-commit --files list. `.planning/MILESTONES.md` and `.planning/PROJECT.md` stay literal root paths -- documented shared files, per the issue's explicit "not a blanket replacement" scope. Regression tests extract and execute the real bash fences (with a stubbed gsd_run) rather than string-matching the markdown, covering flat mode (unaffected), an active workstream (the issue's own repro shape, now correctly resolving), the no-double-fetch requirement, and a dedicated guard locking MILESTONES.md/PROJECT.md as shared. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4455): add changeset for workstream-scoped autonomous/complete-milestone fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): close write-guard gap on workstream-scoped curated paths Isolated security review of the #4455 fix (workstream-scoped STATE/ ROADMAP/milestone-archive path resolution in autonomous.md and complete-milestone.md) flagged that hooks/gsd-write-guard.js's CURATED_PATTERNS only matched root-level .planning/ paths, never .planning/[<project>/]workstreams/<ws>/... — meaning the catastrophic- shrink guard silently never engaged for a workstream-scoped write. This is directly relevant here: the #4455 change makes a workstream- scoped ROADMAP.md Write reachable via complete-milestone.md's own explicit sentinel-hatch instructions, which assume guard protection that did not actually exist for that path shape. Extended CURATED_PATTERNS with the three workstream-scoped equivalents; consumeSentinelFor's own path-derivation logic needed no change since it derives from the actual write target. Verified empirically (a 293->16 line workstream ROADMAP.md shrink now correctly returns exit 2 / decision:"block") and with 5 new regression tests. Also addressed a code-review nit on the core #4455 fix: cmdInitCompleteMilestone called planningDir(cwd) three separate times instead of caching it once. Accepted as-is (not fixed): complete-milestone.md's reorganize_roadmap_and_delete_originals step re-fetches `gsd_run query init.complete-milestone` three times across its fences rather than merging the first two (no state-changing Write between them, unlike autonomous.md's iterate step which does merge). This is an efficiency nit, not a correctness bug — merging risks disrupting the step's prose flow and its existing binding test for a non-functional gain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4455): add changeset for the write-guard workstream-scope fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): fix gsd-test-surfaced regressions from workstream-path fix Running gsd-test against the full #4455 diff (including the write-guard security fix and the cmdInitCompleteMilestone caching nit) surfaced four real, non-flaky failures, all direct consequences of editing gsd-core/workflows/autonomous.md and complete-milestone.md: 1. tests/autonomous-converge.test.cjs pinned the OLD hardcoded `STATE_CONTENT=$(cat .planning/STATE.md ...)` read in both discover_phases and iterate. That is exactly the literal-path behavior #4455 fixes, so the test needed updating to assert the new init.manager-resolved `STATE_PATH` read instead (with an explicit doesNotMatch guard against regressing to the old literal). 2. tests/workstream-scoped-paths.test.cjs's own "no-double-fetch" test counted gsd_run invocations via a shell variable incremented inside the stub function — but `INIT_MANAGER=$(gsd_run ...)` runs gsd_run inside the command-substitution SUBSHELL, so that increment never survives back to the parent shell and the counter always read 0. Switched to a file-based call log (one byte appended per call), which survives the subshell boundary. 3. tests/compact-content-partition-guard.test.cjs's disjointness check flagged the reorganize_roadmap_and_delete_originals step's new `INIT_CM=$(gsd_run query init.complete-milestone)` fetch (added 3x, per the accepted-as-is disposition in the prior commit) as byte-identical to a pre-existing, unrelated fetch already present in complete-milestone/detail/elaboration.md's handle_branches section (§2). Same idiom, same conventional variable name, coincidentally colliding across the spine/detail split boundary. Renamed the new step's local variable to INIT_REORG — a distinct, purpose-specific name is arguably better practice anyway for two logically unrelated fetches, and it removes the literal collision honestly rather than restructuring the split. 4. tests/benchmark-compact-content.test.cjs reported real byte-count drift in the committed baseline (autonomous.md and complete-milestone.md both grew from the #4455 content). Refreshed via `node scripts/benchmark-compact-content.cjs --write`. Verified: node scripts/benchmark-compact-content.cjs --check now reports the baseline up to date; a standalone invocation of checkDisjointness() against the real repo state now reports zero violations across all 6 registered splits; manual bash-fence execution of both the autonomous.md iterate fence (call count = 1) and the complete-milestone.md backlog fence (with INIT_REORG) confirms correct behavior. Emitted-Drift-Ack-Growth: autonomous.md — #4455 workstream-scoped STATE.md path resolution replaces hardcoded literal reads Emitted-Drift-Ack-Growth: complete-milestone.md — #4455 workstream-scoped STATE/ROADMAP/archive path resolution replaces hardcoded literal reads Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): MILESTONES.md/PROJECT.md/REQUIREMENTS.md are workstream-scoped too, and so is project-only mode Fresh isolated code-review and security-review passes against the full diff (run after the previous gsd-test-surfaced fixups landed) each found one real, confirmed defect: Code review: the safety-commit `--files` list and the REQUIREMENTS.md `git rm` step both hardcoded `.planning/MILESTONES.md`, `.planning/PROJECT.md`, and `.planning/REQUIREMENTS.md` as literal root paths — but src/milestone.cts's cmdMilestoneComplete writes MILESTONES.md via `planningPaths(cwd).planning` (the workstream base) and PROJECT.md/REQUIREMENTS.md resolve the same way through `planningPaths().project`/`.requirements` (src/planning-workspace.cts). Only `todos` is the documented root-scoped exception (#4256); an earlier version of this fix wrongly generalized that exception to MILESTONES.md/PROJECT.md too, and the now-corrected test previously enshrined that wrong behavior as intended. Under an active workstream, the safety commit would have silently missed the actual files `milestone complete` just wrote, and the git-rm step would have targeted the wrong (root) REQUIREMENTS.md entirely. Fixed by exposing `milestones_path`/`project_path`/`requirements_path` from init.complete-milestone (src/init.cts) and resolving all three through them, the same pattern already used for state_path/roadmap_path/ archive_dir. The four remaining literal MILESTONES.md/PROJECT.md mentions elsewhere in complete-milestone.md (lines ~12-13, ~441, ~607, ~662) are display-only prose in status/summary message templates, not actual file operations — left as-is; they are a cosmetic path-display inaccuracy under an active workstream, not a data-integrity bug like the two fixed here. Security review: confirmed the write-guard fix from the prior commit is correct and complete for workstream scoping, and independently surfaced the same project-only gap the code-review pass above also caught structurally: `CURATED_PATTERNS` had no pattern for `.planning/<project>/...` (GSD_PROJECT set, GSD_WORKSTREAM unset) — planningDir(cwd) supports that shape independently of workstream nesting, so it is reachable, not hypothetical. Fixed by adding three more patterns, verified empirically (a project-scoped 292->16 line ROADMAP.md shrink now correctly returns exit 2 / decision:"block") and with 6 new regression tests. Verified: manual bash-fence execution of the corrected commit-files and requirements-rm fences (both flat mode and GSD_WORKSTREAM=alpha) resolves to the right paths in both cases; a standalone invocation of checkDisjointness() against the real repo state still reports zero violations; the benchmark baseline was refreshed again for the further size change (already covered by the existing Emitted-Drift-Ack-Growth trailer on complete-milestone.md two commits back — that trailer is read over the whole merge-base..HEAD range, not per-commit, so it still applies here). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4455): backfill changeset PR numbers and correct final scope pr: 0 -> pr: 4542 for both fragments, and updated both bodies to reflect the final fix scope (MILESTONES/PROJECT/REQUIREMENTS are workstream-scoped too, not shared-root exceptions; the write-guard fix also covers project-only scoping, not just workstream nesting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): lifecycle-5b archive-path assertions use the fence's own separator, not path.join PR CI's windows-latest shard 3/3 failed: "expected ls to find the root archive file, got: ...\milestones-root/v1.0-ROADMAP.md". The autonomous.md lifecycle step 5b fence composes the checked path with a literal bash `/` (`"${ARCHIVE_DIR}/v${milestone_version}-ROADMAP.md"`), which on Windows yields a MIXED-separator path — Windows backslashes from archiveDir plus one trailing `/`. My test's assertion used path.join(archiveDir, 'v1.0-ROADMAP.md') instead, which on a Windows Node process produces an all-backslash path that never matches the fence's mixed-separator output. Both assertions in that describe block now mirror the fence's own literal `/` concatenation (`${archiveDir}/v1.0-ROADMAP.md`) instead of path.join — matching the style the other two describe blocks in this same file (safety-commit --files list) already used correctly for the identical archive-dir pattern, so this brings the one outlier into line rather than introducing a new idiom. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): write-guard sentinel comparison now realpath-resolves the token, not just the target PR CI's macos-latest full-test shard 2/3 failed a #4455 test: "the sentinel hatch ... unblocks a workstream ROADMAP.md write" got status 2 (still blocked) instead of 0. Root cause, unrelated to the Windows fix in the previous commit: hooks/gsd-write-guard.js's main flow realpath-resolves the Write TARGET before the curated-pattern match (round 9 Minor 1's symlink-before-match fix, `filePath = fs.realpathSync(filePath)`), but consumeSentinelFor resolved the sentinel TOKEN's absolute path via plain path.resolve() with no realpath step. On macOS, os.tmpdir() resolves through a /var -> /private/var symlink, so a test's cwd (lexically under /var/folders/...) and its realpath'd target (/private/var/folders/...) diverge — an armed, correct sentinel then never matches the realpath'd target string, and the guard stays incorrectly blocked. This is not macOS-specific in principle: ANY cwd sitting under a symlink (a symlinked project checkout, a symlinked worktree) hits the same asymmetry — gsd-test's Linux bench runs never caught it because /tmp there is not a symlink. Fixed by applying the same fs.realpathSync (with the same keep-lexical-on-failure fallback the caller already uses) to the token's resolved path before comparing. The named file is already known to exist at this point (the caller only reaches consumeSentinelFor after successfully reading the target), so realpath is expected to succeed in the legitimate case; a garbage/mismatched token still fails safe (verified — falls back to the lexical path, still mismatches, stays blocked). Verified: reproduced the exact bug locally (macOS) via os.tmpdir() before the fix, confirmed it resolves after; the negative case (sentinel armed for a DIFFERENT file) still correctly blocks; the pre-existing relative-token sentinel tests (predating #4455) still pass; a garbage/non-existent token still fails safe. Added a deterministic, cross-platform regression test using an explicit symlink (skipped on Windows, matching the existing round-9 symlink test's own skip condition) so this class of bug is caught by gsd-test's Linux bench too, not only by a real macOS CI run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1373 lines
54 KiB
JavaScript
1373 lines
54 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'));
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// 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)}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|