* fix: require fresh phase verification before transition * no-mistakes(review): Fix canonical verification closeout gates * no-mistakes(review): Fix verify-work frontmatter promotion command * no-mistakes(review): Fix stale verification gates * no-mistakes(review): Fix canonical verification routing gates * no-mistakes(review): Fix verification dependency and runtime routing gates * no-mistakes(review): Block stale verification bypasses * fix: handle large init manager outputs in verification workflows * chore: update changeset pr number * fix(verify-work): use fresh verification.status for stale gate The stale check after UAT used phase_completion.verification_status from session-start INIT while human_needed promotion already queried fresh verification.status. Align the stale gate with the canonical query so mid-session verification refresh is not ignored. * fix(init): skip roadmap-checked phases when selecting next_phase Roadmap-only phases without a disk directory were still promoted to next_phase when their checkbox was already checked. Exclude checkboxComplete phases so progress routing does not point at work the roadmap already marks done. * fix: gaps_found not overridden by stale, transition uses canonical verification - verification.cts: check gaps_found before stale so gap-closure routing is not masked by a newer summary mtime - phase.cts: remove redundant findStaleVerificationSummary — readVerificationStatus already handles stale detection - transition.md: replace raw grep on file content with verification.status query to avoid false-positive blocks from body text matching * ci: retrigger tests after rebase * fix(transition): replace gsd_run advisory check with awk frontmatter extraction The runtime launcher is not defined until the update_roadmap_and_state step bash block (~line 165). The early verify_completion block used gsd_run to query verification.status, which violated the runtime-launcher-parity test: 'preamble appears AFTER the first gsd_run reference'. Replace the gsd_run call with an awk-based frontmatter extractor that reads only the status: field between the two --- fences. This avoids both the preamble-ordering constraint and the original false-positive grep bug where body text like 'previous_status: gaps_found' would match a full-text regex. The phase.complete gate at update_roadmap_and_state is the canonical enforcement point; this early check is advisory only. Also update workflow-size-baseline.json for the updated transition.md size. Fixes: runtime-launcher-parity test (B) Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix: re-check verification under planning lock in phase complete Move readVerificationStatus into withPlanningLock so stale verification cannot slip through when a SUMMARY.md is written between the gate and the roadmap/state mutation. Return the blocked status from the lock callback and emit the error after release to avoid leaving .lock behind. * fix(transition): gate on canonical verification.status including stale Replace awk frontmatter read with verification.status query so transition blocks when summaries are newer than VERIFICATION.md, matching phase.complete and other workflows (autonomous, progress, verify-work). * Fix workflow verification gates for yolo transition and stale routing Require VERIFY_STATUS passed before yolo/interactive transition advance. Route stale verification recovery to verify-work, matching canonical projection. * fix(transition): use verification.status query for stale-aware advisory check The awk-based check read raw frontmatter status: passed, which misses the stale case where summaries are newer than the VERIFICATION.md file even though the frontmatter still says passed. The stale status is computed from file modification times, not stored in frontmatter. Move the preamble to the verify_completion bash block (the first block with a gsd_run call) so gsd_run query verification.status can be used for the advisory check. This gives the full readVerificationStatus logic including mtime-based staleness detection, matching the enforcement gate at phase.complete. Capture full JSON (VERIFY_JSON) so next_action can be included in the advisory output alongside the status. Also update workflow-size-baseline.json for the updated transition.md size. Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * ci: trigger test matrix for 525b946 Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(transition): restore awk frontmatter extraction for pre-shim verification check The gsd_run launcher shim is not defined until line ~163 of transition.md, so the verification debt check at line ~80 cannot use gsd_run. Restore the awk-based frontmatter extraction that correctly reads status without needing the runtime, and restore the shim at its proper location before phase.complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(#1522): clarify transition verification gate wording * fix(#1522): update transition workflow size baseline * fix(#1522): update workflow-size-baseline after rebase onto next Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(#1522): guard findStaleVerificationSummary FS calls + thread opts.fs seam (review) Address review blocker B1 on #1548: findStaleVerificationSummary ran fs.readdirSync and two fs.statSync calls unguarded between readVerificationStatus's try/catch sections, so a TOCTOU race (a SUMMARY listed by scanPhasePlans then removed before statSync) or any FS error threw uncaught into callers NOT under the planning lock (init.manager / init.progress / uat-predicate). Wrap the body in try/catch degrading to 'not stale', and thread the injectable opts.fs seam (add statSync to FsLike, pass fsImpl from the caller) for parity with readVerificationStatus's no-throw contract and testability. Also adds the Verification Module glossary entry to CONTEXT.md (review B3). --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -44,6 +44,10 @@ function writeUatFile(phaseDir, filename, content) {
|
||||
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf-8');
|
||||
}
|
||||
|
||||
function setMtime(filePath, time) {
|
||||
fs.utimesSync(filePath, time, time);
|
||||
}
|
||||
|
||||
function makePassingUat() {
|
||||
return [
|
||||
'---',
|
||||
@@ -207,6 +211,39 @@ describe('phase uat-passed — --require-verification flag', () => {
|
||||
assert.strictEqual(out.passed, true);
|
||||
assert.strictEqual(out.policy.require_verification, true);
|
||||
});
|
||||
|
||||
test('--require-verification with stale passed verification → passed:false', () => {
|
||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||
const verificationPath = path.join(phaseDir, 'feature-VERIFICATION.md');
|
||||
const summaryPath = path.join(phaseDir, 'feature-SUMMARY.md');
|
||||
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
|
||||
writeUatFile(phaseDir, 'feature-SUMMARY.md', '# Summary\n\nImplementation changed after verification.\n');
|
||||
const now = new Date();
|
||||
setMtime(verificationPath, new Date(now.getTime() - 60_000));
|
||||
setMtime(summaryPath, now);
|
||||
|
||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, false);
|
||||
assert.ok(
|
||||
out.blockers.some(b => /verification status=stale/i.test(b)),
|
||||
`Expected stale-verification blocker, got: ${JSON.stringify(out.blockers)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('--require-verification with non-canonical complete verification → passed:false', () => {
|
||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: complete\n---\n\nLegacy OK.');
|
||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.passed, false);
|
||||
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
|
||||
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +38,17 @@ const { cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
const phaseModule = require('../gsd-core/bin/lib/phase.cjs');
|
||||
const { cmdPhaseComplete } = phaseModule;
|
||||
|
||||
function writePassedVerificationFile(phaseDir, phase = '01') {
|
||||
fs.writeFileSync(path.join(phaseDir, `${phase}-VERIFICATION.md`), [
|
||||
'---',
|
||||
'status: passed',
|
||||
'---',
|
||||
'',
|
||||
'# Verification',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
// ── Fixture builder ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -120,6 +131,7 @@ function createFixture(prefix = 'gsd-4-regression-') {
|
||||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n');
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n');
|
||||
writePassedVerificationFile(phase01Dir);
|
||||
|
||||
// Phase 02 directory (needed for "next phase" detection)
|
||||
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
||||
@@ -443,6 +455,7 @@ function create4ColFixture(existingDate, alreadyComplete = true) {
|
||||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n');
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n');
|
||||
writePassedVerificationFile(phase01Dir);
|
||||
|
||||
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
||||
|
||||
@@ -506,6 +519,7 @@ function create5ColFixture(existingDate, alreadyComplete = true) {
|
||||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n');
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n');
|
||||
writePassedVerificationFile(phase01Dir);
|
||||
|
||||
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
||||
|
||||
@@ -823,34 +837,26 @@ describe('issue #1159 (Defect A): VERIFICATION.md historical metadata must not t
|
||||
);
|
||||
|
||||
test(
|
||||
'#1159-A-2 (boundary): status:gaps_found in frontmatter → DOES emit "has unresolved gaps" warning',
|
||||
'#1159-A-2 (boundary): status:gaps_found in frontmatter → blocks phase completion',
|
||||
() => {
|
||||
tmpDir = createVerificationFixture('gaps_found');
|
||||
const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
||||
const parsed = JSON.parse(output);
|
||||
const warnings = parsed.warnings || [];
|
||||
const gapWarnings = warnings.filter((w) => /unresolved gaps/i.test(w));
|
||||
assert.ok(
|
||||
gapWarnings.length > 0,
|
||||
`#1159-A-2 FAILED: expected a gap warning when frontmatter status=gaps_found but got none.\n` +
|
||||
`Warnings: ${JSON.stringify(warnings)}`,
|
||||
);
|
||||
const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir);
|
||||
assert.equal(result.success, false, 'gaps_found verification must block phase completion');
|
||||
const parsed = JSON.parse(result.error);
|
||||
assert.equal(parsed.reason, 'phase_verification_incomplete');
|
||||
assert.match(parsed.message, /Gaps found/i);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'#1159-A-3 (boundary): status:human_needed in frontmatter → DOES emit "needs human verification" warning',
|
||||
'#1159-A-3 (boundary): status:human_needed in frontmatter → blocks phase completion',
|
||||
() => {
|
||||
tmpDir = createVerificationFixture('human_needed');
|
||||
const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
||||
const parsed = JSON.parse(output);
|
||||
const warnings = parsed.warnings || [];
|
||||
const humanWarnings = warnings.filter((w) => /human verification/i.test(w));
|
||||
assert.ok(
|
||||
humanWarnings.length > 0,
|
||||
`#1159-A-3 FAILED: expected human-verification warning when frontmatter status=human_needed.\n` +
|
||||
`Warnings: ${JSON.stringify(warnings)}`,
|
||||
);
|
||||
const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir);
|
||||
assert.equal(result.success, false, 'human_needed verification must block phase completion');
|
||||
const parsed = JSON.parse(result.error);
|
||||
assert.equal(parsed.reason, 'phase_verification_incomplete');
|
||||
assert.match(parsed.message, /Human verification required/i);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -939,6 +945,7 @@ function createDeferredReqFixture({ includeMissingActive = false } = {}) {
|
||||
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n');
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n');
|
||||
writePassedVerificationFile(phase01Dir);
|
||||
|
||||
return tmpDir;
|
||||
}
|
||||
@@ -1075,6 +1082,7 @@ describe('issue #1159 (Defect B): deferred/future requirement IDs must not trigg
|
||||
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\n');
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\n');
|
||||
writePassedVerificationFile(phase01Dir);
|
||||
|
||||
const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
@@ -109,3 +109,103 @@ describe('autonomous --converge flag (#711)', () => {
|
||||
assert.match(howTo, /\/gsd-autonomous --only 4 --converge/, 'how-to should show single-phase converge usage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('autonomous verification deferral contract', () => {
|
||||
test('workflow records explicit deferred states instead of silently advancing (#1525)', () => {
|
||||
const workflow = read(WORKFLOW_PATH);
|
||||
|
||||
assert.match(workflow, /verification_deferred_human/);
|
||||
assert.match(workflow, /verification_deferred_gaps/);
|
||||
assert.match(workflow, /Deferred Verification/);
|
||||
assert.match(workflow, /gsd:verify-work \$\{PHASE_NUM\}/);
|
||||
assert.match(workflow, /gsd:plan-phase \$\{PHASE_NUM\} --gaps/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/\| \$\{PHASE_NUM\} \| verification_deferred_human \| \/gsd:verify-work \$\{PHASE_NUM\} \|/,
|
||||
'human deferral must persist the exact deferred STATE row',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/\| \$\{PHASE_NUM\} \| verification_deferred_gaps \| \/gsd:plan-phase \$\{PHASE_NUM\} --gaps \|/,
|
||||
'gap deferral must persist the exact deferred STATE row',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
workflow,
|
||||
/Human validation deferred` and proceed to iterate step/,
|
||||
'human-needed deferral must not silently proceed to the next phase',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
workflow,
|
||||
/Gaps deferred` and proceed to iterate step/,
|
||||
'gap deferral must not silently proceed to the next phase',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow runs normal transition post-processing after passed verification (#1526)', () => {
|
||||
const workflow = read(WORKFLOW_PATH);
|
||||
const passedIdx = workflow.indexOf('**If `passed`:**');
|
||||
const transitionIdx = workflow.indexOf('transition.md', passedIdx);
|
||||
const iterateIdx = workflow.indexOf('Proceed to iterate step', passedIdx);
|
||||
|
||||
assert.ok(transitionIdx > passedIdx, 'passed verification must invoke transition.md');
|
||||
assert.ok(
|
||||
transitionIdx < iterateIdx,
|
||||
'normal transition post-processing must run before autonomous iterates',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow reads canonical verification status before human-needed promotion (#1522)', () => {
|
||||
const workflow = read(WORKFLOW_PATH);
|
||||
const waitIdx = workflow.indexOf('After execute, read canonical verification');
|
||||
const humanNeededIdx = workflow.indexOf('**If `human_needed`:**', waitIdx);
|
||||
const promoteIdx = workflow.indexOf('set VERIFICATION frontmatter `status: passed`', humanNeededIdx);
|
||||
const section = workflow.slice(waitIdx, humanNeededIdx);
|
||||
|
||||
assert.ok(waitIdx !== -1, 'workflow must document the post-execution verification read');
|
||||
assert.ok(humanNeededIdx > waitIdx, 'human_needed branch must follow verification status read');
|
||||
assert.ok(promoteIdx > humanNeededIdx, 'human_needed branch must contain the promotion action');
|
||||
assert.match(
|
||||
section,
|
||||
/VERIFY_STATUS=\$\(gsd_run query verification\.status "\$\{PHASE_DIR\}" 2>\/dev\/null \| jq -r '\.status\/\/empty'\)/,
|
||||
'autonomous must route human validation through canonical verification.status',
|
||||
);
|
||||
assert.match(
|
||||
section,
|
||||
/jq -r '\.status\/\/empty'/,
|
||||
'autonomous must parse the projected canonical status value',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
section,
|
||||
/grep "\^status:"/,
|
||||
'autonomous must not route stale human_needed reports from raw frontmatter',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow discovers incomplete phases from canonical verification projection (#1522)', () => {
|
||||
const workflow = read(WORKFLOW_PATH);
|
||||
const discoverStart = workflow.indexOf('<step name="discover_phases">');
|
||||
const discoverEnd = workflow.indexOf('</step>', discoverStart);
|
||||
const iterateStart = workflow.indexOf('<step name="iterate">');
|
||||
const iterateEnd = workflow.indexOf('</step>', iterateStart);
|
||||
const discoverStep = workflow.slice(discoverStart, discoverEnd);
|
||||
const iterateStep = workflow.slice(iterateStart, iterateEnd);
|
||||
|
||||
assert.match(discoverStep, /INIT_MANAGER=\$\(gsd_run query init\.manager\)/);
|
||||
assert.ok(
|
||||
discoverStep.includes('if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi'),
|
||||
'autonomous discovery must dereference large init.manager payloads before parsing',
|
||||
);
|
||||
assert.match(discoverStep, /phase_complete !== true/);
|
||||
assert.match(discoverStep, /verification_status !== "passed"/);
|
||||
assert.doesNotMatch(discoverStep, /ROADMAP=\$\(gsd_run query roadmap\.analyze\)/);
|
||||
assert.doesNotMatch(discoverStep, /disk_status !== "complete"/);
|
||||
|
||||
assert.match(iterateStep, /INIT_MANAGER=\$\(gsd_run query init\.manager\)/);
|
||||
assert.ok(
|
||||
iterateStep.includes('if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi'),
|
||||
'autonomous iteration must dereference large init.manager payloads before parsing',
|
||||
);
|
||||
assert.match(iterateStep, /phase_complete !== true/);
|
||||
assert.match(iterateStep, /verification_status !== "passed"/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,10 @@ describe('bug #2769: phase complete ticks REQUIREMENTS.md across header variants
|
||||
path.join(phasesDir, '01-1-SUMMARY.md'),
|
||||
['---', 'status: complete', '---', '# Summary', 'Done.'].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(phasesDir, '01-VERIFICATION.md'),
|
||||
['---', 'status: passed', 'score: "1/1"', '---', '# Verification', 'Passed.'].join('\n'),
|
||||
);
|
||||
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
|
||||
@@ -91,6 +91,10 @@ function setupFixture(tmpDir, opts = {}) {
|
||||
path.join(phaseDir, `${paddedId}-01-SUMMARY.md`),
|
||||
'---\nstatus: complete\n---\n# Summary\nDone.'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, `${paddedId}-VERIFICATION.md`),
|
||||
'---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n'
|
||||
);
|
||||
|
||||
const extra = extraPhases
|
||||
.map((p) => `- [ ] **Phase ${p.id}: ${p.name}**`)
|
||||
|
||||
@@ -27,8 +27,13 @@ const { execFileSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SDK = path.join(ROOT, 'sdk');
|
||||
const AUDIT_TIMEOUT_MS = 180_000;
|
||||
const TEST_TIMEOUT_MS = AUDIT_TIMEOUT_MS + 30_000;
|
||||
|
||||
function auditProductionVulns(cwd) {
|
||||
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
|
||||
return null; // signal "skip" to caller
|
||||
}
|
||||
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
|
||||
return null; // signal "skip" to caller
|
||||
}
|
||||
@@ -46,7 +51,7 @@ function auditProductionVulns(cwd) {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 60_000,
|
||||
timeout: AUDIT_TIMEOUT_MS,
|
||||
shell: isWindows,
|
||||
}
|
||||
);
|
||||
@@ -76,10 +81,10 @@ function auditProductionVulns(cwd) {
|
||||
}
|
||||
|
||||
describe('#3588: npm audit --omit=dev reports zero advisories', () => {
|
||||
test('root workspace production tree has no advisories', { timeout: 90_000 }, (t) => {
|
||||
test('root workspace production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
|
||||
const vulns = auditProductionVulns(ROOT);
|
||||
if (vulns === null) {
|
||||
t.skip('node_modules/ not present — run `npm install` before this test');
|
||||
t.skip('auditable npm package not present or node_modules/ missing');
|
||||
return;
|
||||
}
|
||||
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
|
||||
@@ -91,10 +96,10 @@ describe('#3588: npm audit --omit=dev reports zero advisories', () => {
|
||||
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
|
||||
});
|
||||
|
||||
test('sdk/ production tree has no advisories', { timeout: 90_000 }, (t) => {
|
||||
test('sdk/ production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
|
||||
const vulns = auditProductionVulns(SDK);
|
||||
if (vulns === null) {
|
||||
t.skip('sdk/node_modules/ not present — run `npm ci` inside sdk/ before this test');
|
||||
t.skip('sdk/ is not an auditable npm package or sdk/node_modules/ is missing');
|
||||
return;
|
||||
}
|
||||
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
|
||||
|
||||
@@ -45,6 +45,26 @@ describe('bug-853 — manager/autonomous gate background dispatch by runtime', (
|
||||
);
|
||||
});
|
||||
|
||||
test('manager.md compound actions only background plan/execute on Codex', () => {
|
||||
const compoundActionSection = MANAGER.match(
|
||||
/### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/,
|
||||
);
|
||||
assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch');
|
||||
|
||||
assert.match(
|
||||
compoundActionSection[0],
|
||||
/On Codex:[\s\S]{0,260}?Spawn all background agents first[\s\S]{0,220}?plan\/execute/,
|
||||
);
|
||||
assert.match(
|
||||
compoundActionSection[0],
|
||||
/On Claude Code or any other non-Codex runtime:[\s\S]{0,260}?inline/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
compoundActionSection[0],
|
||||
/On other runtimes:[\s\S]{0,260}?Spawn all background agents first/,
|
||||
);
|
||||
});
|
||||
|
||||
test('autonomous.md gates interactive background dispatch by runtime', () => {
|
||||
const autoRuntimeMatches = AUTONOMOUS.match(/config-get runtime/g) || [];
|
||||
assert.ok(autoRuntimeMatches.length >= 2, 'autonomous.md must resolve runtime in both 3b (plan) and 3c (execute) interactive branches');
|
||||
|
||||
@@ -126,6 +126,10 @@ describe('planning lock integration', () => {
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(
|
||||
path.join(p1, '01-VERIFICATION.md'),
|
||||
'---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n',
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
@@ -637,6 +641,10 @@ describe('stress tests with 50+ phases', () => {
|
||||
path.join(phase26Dir, '26-01-SUMMARY.md'),
|
||||
'# Phase 26 Plan 1 Summary\n\nFeature 26 completed.\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(phase26Dir, '26-VERIFICATION.md'),
|
||||
'---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n',
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase complete 26', tmpDir);
|
||||
assert.ok(result.success, `phase complete 26 should succeed: ${result.error}`);
|
||||
|
||||
@@ -186,6 +186,8 @@ describe('scanPhasePlans — nested layout', () => {
|
||||
assert.strictEqual(result.planCount, 1);
|
||||
assert.strictEqual(result.summaryCount, 1);
|
||||
assert.strictEqual(result.completed, true);
|
||||
assert.deepStrictEqual(result.planFiles, ['plans/PLAN-01-setup.md']);
|
||||
assert.deepStrictEqual(result.summaryFiles, ['plans/SUMMARY-01-setup.md']);
|
||||
});
|
||||
|
||||
test('flat root + nested plans combined', () => {
|
||||
|
||||
@@ -55,6 +55,13 @@ function scaffoldPhase(tmpDir, num, opts = {}) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writePassedVerification(phaseDir, padded) {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, `${padded}-VERIFICATION.md`),
|
||||
['---', 'status: passed', '---', '', '# Verification', ''].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
describe('init manager', () => {
|
||||
let tmpDir;
|
||||
|
||||
@@ -110,8 +117,8 @@ describe('init manager', () => {
|
||||
{ number: '5', name: 'Not Started' },
|
||||
]);
|
||||
|
||||
// Phase 1: complete (plans + matching summaries)
|
||||
scaffoldPhase(tmpDir, 1, { slug: 'complete-phase', context: true, plans: 2, summaries: 2 });
|
||||
// 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)
|
||||
@@ -158,7 +165,7 @@ describe('init manager', () => {
|
||||
{ number: '1', name: 'Foundation', complete: true },
|
||||
{ number: '2', name: 'Depends on 1', depends_on: 'Phase 1' },
|
||||
]);
|
||||
scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 });
|
||||
writePassedVerification(scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }), '01');
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
const output = JSON.parse(result.output);
|
||||
@@ -240,7 +247,7 @@ describe('init manager', () => {
|
||||
{ number: '5', name: 'Polish' },
|
||||
]);
|
||||
|
||||
scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 });
|
||||
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
|
||||
|
||||
@@ -269,7 +276,7 @@ describe('init manager', () => {
|
||||
{ number: '4', name: 'Ready to Discuss' },
|
||||
]);
|
||||
|
||||
scaffoldPhase(tmpDir, 1, { slug: 'complete', plans: 1, summaries: 1 });
|
||||
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 });
|
||||
|
||||
@@ -306,8 +313,8 @@ describe('init manager', () => {
|
||||
{ number: '1', name: 'Done', complete: true },
|
||||
{ number: '2', name: 'Also Done', complete: true },
|
||||
]);
|
||||
scaffoldPhase(tmpDir, 1, { slug: 'done', plans: 1, summaries: 1 });
|
||||
scaffoldPhase(tmpDir, 2, { slug: 'also-done', plans: 1, summaries: 1 });
|
||||
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);
|
||||
@@ -316,6 +323,93 @@ describe('init manager', () => {
|
||||
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' }]);
|
||||
@@ -564,9 +658,9 @@ describe('init manager', () => {
|
||||
]);
|
||||
|
||||
// Scaffold completed phases on disk
|
||||
scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 2, summaries: 2 });
|
||||
scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 });
|
||||
scaffoldPhase(tmpDir, 3, { slug: 'polish', plans: 1, summaries: 1 });
|
||||
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}`);
|
||||
@@ -584,8 +678,8 @@ describe('init manager', () => {
|
||||
{ number: '999.1', name: 'Backlog idea' },
|
||||
]);
|
||||
|
||||
scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 1, summaries: 1 });
|
||||
scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 });
|
||||
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);
|
||||
|
||||
@@ -1082,6 +1082,13 @@ describe('cmdInitPhaseOp fallback', () => {
|
||||
describe('cmdInitProgress', () => {
|
||||
let tmpDir;
|
||||
|
||||
function writePassedVerification(phaseDir, phaseToken) {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, `${phaseToken}-VERIFICATION.md`),
|
||||
['---', 'status: passed', '---', '', '# Verification', ''].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
});
|
||||
@@ -1108,6 +1115,7 @@ describe('cmdInitProgress', () => {
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
|
||||
writePassedVerification(phase1, '01');
|
||||
|
||||
// Phase 02: in_progress (has plan, no summary)
|
||||
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||||
@@ -1161,6 +1169,7 @@ describe('cmdInitProgress', () => {
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
|
||||
writePassedVerification(phase1, '01');
|
||||
|
||||
const result = runGsdTools('init progress', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
@@ -1171,6 +1180,26 @@ describe('cmdInitProgress', () => {
|
||||
assert.strictEqual(output.next_phase, null);
|
||||
});
|
||||
|
||||
test('implementation-complete phase without passed verification remains current work', () => {
|
||||
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('init progress', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.completed_count, 0);
|
||||
assert.strictEqual(output.in_progress_count, 1);
|
||||
assert.strictEqual(output.has_work_in_progress, true);
|
||||
assert.strictEqual(output.current_phase.number, '01');
|
||||
assert.strictEqual(output.current_phase.status, 'executed');
|
||||
assert.strictEqual(output.current_phase.implementation_complete, true);
|
||||
assert.strictEqual(output.current_phase.verification_status, 'missing');
|
||||
assert.strictEqual(output.current_phase.verification_passed, false);
|
||||
});
|
||||
|
||||
test('paused_at detected from STATE.md', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
|
||||
@@ -21,6 +21,14 @@ const repoRoot = path.resolve(__dirname, '..');
|
||||
const commandPath = path.join(repoRoot, 'commands', 'gsd', 'milestone-summary.md');
|
||||
const workflowPath = path.join(repoRoot, 'gsd-core', 'workflows', 'milestone-summary.md');
|
||||
|
||||
function extractStep(content, stepName) {
|
||||
const start = content.indexOf(`<step name="${stepName}">`);
|
||||
assert.ok(start !== -1, `${stepName} step must exist`);
|
||||
const end = content.indexOf('</step>', start);
|
||||
assert.ok(end !== -1, `${stepName} step must close`);
|
||||
return content.slice(start, end);
|
||||
}
|
||||
|
||||
describe('milestone-summary command', () => {
|
||||
test('command file exists', () => {
|
||||
assert.ok(fs.existsSync(commandPath), 'commands/gsd/milestone-summary.md should exist');
|
||||
@@ -405,6 +413,32 @@ describe('complete-milestone workflow has pre-close audit gate (#2158)', () => {
|
||||
completeMilestoneContent.includes('sanitiz') || completeMilestoneContent.includes('SECURITY'),
|
||||
);
|
||||
});
|
||||
|
||||
test('complete-milestone distinguishes verified and override closeout (#1527)', () => {
|
||||
assert.match(completeMilestoneContent, /all_phases_verified/);
|
||||
assert.match(completeMilestoneContent, /closeout_type/);
|
||||
assert.match(completeMilestoneContent, /verified_closeout/);
|
||||
assert.match(completeMilestoneContent, /override_closeout/);
|
||||
assert.match(completeMilestoneContent, /Known verification overrides/);
|
||||
});
|
||||
|
||||
test('verified closeout uses init.manager canonical verification projection (#1522)', () => {
|
||||
const readinessStep = extractStep(completeMilestoneContent, 'verify_readiness');
|
||||
|
||||
assert.match(readinessStep, /INIT_MANAGER=\$\(gsd_run query init\.manager\)/);
|
||||
assert.ok(
|
||||
readinessStep.includes('if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi'),
|
||||
'complete-milestone readiness must dereference large init.manager payloads before jq',
|
||||
);
|
||||
assert.match(readinessStep, /select\(\(\.number \| tostring \| test\("\^999/);
|
||||
assert.match(readinessStep, /\| not\)\)/);
|
||||
assert.match(readinessStep, /phase_complete === true/);
|
||||
assert.match(readinessStep, /verification_status === 'passed'/);
|
||||
assert.match(readinessStep, /If not all_phases_verified/);
|
||||
assert.match(readinessStep, /verified_closeout must not proceed/);
|
||||
assert.doesNotMatch(readinessStep, /ROADMAP=\$\(gsd_run query roadmap\.analyze\)/);
|
||||
assert.doesNotMatch(readinessStep, /disk_status === 'complete'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify-work workflow has phase artifact check (#2157)', () => {
|
||||
|
||||
@@ -24,6 +24,108 @@ const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
function normalizePhaseToken(token) {
|
||||
return String(token).replace(/\d+/g, (digits) => String(Number(digits)));
|
||||
}
|
||||
|
||||
function phaseTokenFromDirName(name) {
|
||||
const match = name.match(/^(?:[A-Z][A-Z0-9]*-)?(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function writePassedVerificationForPhase(tmpDir, phase) {
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
const wanted = normalizePhaseToken(phase);
|
||||
const phaseDirName = fs.readdirSync(phasesDir)
|
||||
.find((name) => normalizePhaseToken(phaseTokenFromDirName(name) || '') === wanted);
|
||||
|
||||
assert.ok(phaseDirName, `expected phase directory for Phase ${phase}`);
|
||||
|
||||
const phaseDir = path.join(phasesDir, phaseDirName);
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, `${phase}-VERIFICATION.md`),
|
||||
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function runVerifiedPhaseComplete(args, tmpDir, env) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
||||
.map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
|
||||
const completeIdx = argv.findIndex((token, index) => token === 'complete' && argv[index - 1] === 'phase');
|
||||
assert.notEqual(completeIdx, -1, `expected phase complete command, got ${argv.join(' ')}`);
|
||||
const phase = argv[completeIdx + 1];
|
||||
assert.ok(phase, `expected phase number in command ${argv.join(' ')}`);
|
||||
writePassedVerificationForPhase(tmpDir, phase);
|
||||
return runGsdTools(args, tmpDir, env);
|
||||
}
|
||||
|
||||
function writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus) {
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
const phase1Dir = path.join(planningDir, 'phases', '01-foundation');
|
||||
const phase2Dir = path.join(planningDir, 'phases', '02-api');
|
||||
fs.mkdirSync(phase1Dir, { recursive: true });
|
||||
fs.mkdirSync(phase2Dir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [ ] Phase 1: Foundation',
|
||||
'- [ ] Phase 2: API',
|
||||
'',
|
||||
'### Phase 1: Foundation',
|
||||
'**Goal:** Setup',
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
'### Phase 2: API',
|
||||
'**Goal:** Build API',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|----------------|--------|-----------|',
|
||||
'| 01. Foundation | 0/1 | Not started | - |',
|
||||
'| 02. API | 0/1 | Not started | - |',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'STATE.md'),
|
||||
[
|
||||
'# State',
|
||||
'',
|
||||
'**Current Phase:** 01',
|
||||
'**Current Phase Name:** Foundation',
|
||||
'**Status:** In progress',
|
||||
'**Current Plan:** 01-01',
|
||||
'**Last Activity:** 2025-01-01',
|
||||
'**Last Activity Description:** Working on phase 1',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
fs.writeFileSync(path.join(phase1Dir, '01-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phase1Dir, '01-01-SUMMARY.md'), '# Summary\n');
|
||||
|
||||
if (verificationStatus !== null) {
|
||||
fs.writeFileSync(
|
||||
path.join(phase1Dir, '01-VERIFICATION.md'),
|
||||
[
|
||||
'---',
|
||||
`status: ${verificationStatus}`,
|
||||
'---',
|
||||
'',
|
||||
'# Verification',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('phases list command', () => {
|
||||
let tmpDir;
|
||||
|
||||
@@ -2098,6 +2200,82 @@ Plans:
|
||||
// phase complete command
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('phase complete canonical verification gate (#1522)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
for (const [name, verificationStatus, expectedMessage] of [
|
||||
['missing verification report', null, /No verification report found/i],
|
||||
['unknown verification status', 'unexpected_value', /Unexpected verification status/i],
|
||||
['human-needed verification status', 'human_needed', /Human verification required/i],
|
||||
['gap-bearing verification status', 'gaps_found', /Gaps found/i],
|
||||
]) {
|
||||
test(`blocks ${name} before mutating ROADMAP or STATE`, () => {
|
||||
writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus);
|
||||
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const beforeState = fs.readFileSync(statePath, 'utf-8');
|
||||
|
||||
const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir);
|
||||
|
||||
assert.equal(result.success, false, 'phase complete must fail when verification has not passed');
|
||||
const errorPayload = JSON.parse(result.error);
|
||||
assert.equal(errorPayload.reason, 'phase_verification_incomplete');
|
||||
assert.match(errorPayload.message, expectedMessage);
|
||||
assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap);
|
||||
assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState);
|
||||
});
|
||||
}
|
||||
|
||||
test('allows passed verification to complete and advance the phase', () => {
|
||||
writePhaseCompleteVerificationGateFixture(tmpDir, 'passed');
|
||||
|
||||
const result = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
||||
|
||||
assert.equal(result.success, true, `phase complete failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.equal(output.completed_phase, '1');
|
||||
assert.equal(output.next_phase, '02');
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.match(roadmap, /- \[x\] Phase 1: Foundation/);
|
||||
assert.match(state, /\*\*Current Phase:\*\* 02/);
|
||||
});
|
||||
|
||||
test('blocks stale passed verification when summaries changed later', () => {
|
||||
writePhaseCompleteVerificationGateFixture(tmpDir, 'passed');
|
||||
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-01-SUMMARY.md');
|
||||
const verificationPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-VERIFICATION.md');
|
||||
const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const beforeState = fs.readFileSync(statePath, 'utf-8');
|
||||
|
||||
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(['--json-errors', 'phase', 'complete', '1'], tmpDir);
|
||||
|
||||
assert.equal(result.success, false, 'phase complete must fail when verification is stale');
|
||||
const errorPayload = JSON.parse(result.error);
|
||||
assert.equal(errorPayload.reason, 'phase_verification_incomplete');
|
||||
assert.match(errorPayload.message, /stale/i);
|
||||
assert.match(errorPayload.message, /\/gsd:verify-work 0?1/);
|
||||
assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap);
|
||||
assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase complete command', () => {
|
||||
let tmpDir;
|
||||
@@ -2137,7 +2315,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
@@ -2173,7 +2351,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
@@ -2238,7 +2416,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
||||
@@ -2311,7 +2489,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
||||
@@ -2367,7 +2545,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
// REQUIREMENTS.md should be unchanged
|
||||
@@ -2398,7 +2576,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`);
|
||||
});
|
||||
|
||||
@@ -2440,7 +2618,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.requirements_updated, true, 'requirements_updated should be true');
|
||||
@@ -2486,7 +2664,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
||||
@@ -2538,7 +2716,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
// Phase 1 has no Requirements field, so Phase 2's AUTH-01 should NOT be updated
|
||||
@@ -2609,7 +2787,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p321, '03.2.1-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p321, '03.2.1-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 03.2.1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 03.2.1', tmpDir);
|
||||
assert.ok(result.success, `Command should not crash on regex metacharacters: ${result.error}`);
|
||||
|
||||
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
||||
@@ -2644,7 +2822,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
@@ -2671,7 +2849,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
@@ -2715,7 +2893,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
@@ -2755,7 +2933,7 @@ describe('phase complete command', () => {
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
@@ -2795,7 +2973,7 @@ Plans:
|
||||
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
@@ -2833,7 +3011,7 @@ Plans:
|
||||
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
@@ -3033,7 +3211,7 @@ describe('phase complete milestone-scoped next-phase', () => {
|
||||
// Phase 6 — next phase in milestone
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 5', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 5', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
@@ -3068,7 +3246,7 @@ describe('phase complete milestone-scoped next-phase', () => {
|
||||
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
|
||||
}
|
||||
|
||||
const result = runGsdTools('phase complete 5', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 5', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
@@ -3204,7 +3382,7 @@ describe('phase complete updates Performance Metrics', () => {
|
||||
`# Roadmap\n\n## Phase 2: Core\n\n- [ ] Phase 2: Core Systems\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase complete 2', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 2', tmpDir);
|
||||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||||
|
||||
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
@@ -3229,7 +3407,7 @@ describe('phase complete updates Performance Metrics', () => {
|
||||
`# Roadmap\n\n## Phase 1: Setup\n\n- [ ] Phase 1: Setup\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||||
|
||||
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
@@ -3310,7 +3488,7 @@ describe('phase complete excludes 999.x backlog from next-phase (#2129)', () =>
|
||||
// Backlog stub on disk — this is what triggers the bug
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-idea'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 2', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 2', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
@@ -3390,6 +3568,7 @@ describe('bug #1962: normalizePhaseName preserves letter suffix case', () => {
|
||||
* that may exit non-zero in these minimal fixtures.
|
||||
*/
|
||||
function runPhaseComplete(tmpDir, { phase = '1', tolerateExit = false } = {}) {
|
||||
writePassedVerificationForPhase(tmpDir, phase);
|
||||
try {
|
||||
return execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', phase], {
|
||||
cwd: tmpDir,
|
||||
@@ -4138,6 +4317,9 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
||||
|
||||
{
|
||||
function runSdkQuery(args, cwd) {
|
||||
if (Array.isArray(args) && args[0] === 'phase.complete') {
|
||||
writePassedVerificationForPhase(cwd, args[1]);
|
||||
}
|
||||
const result = runGsdTools(args, cwd);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
try {
|
||||
@@ -4501,6 +4683,7 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
||||
|
||||
test('prose-block STATE keeps next phase name without field-miss warnings (#1316)', () => {
|
||||
const { planningDir } = setupPhase1316Project(tmpDir);
|
||||
writePassedVerificationForPhase(tmpDir, '32');
|
||||
|
||||
const result = spawnSync(process.execPath, [GSD_TOOLS_BIN, 'phase', 'complete', '32'], {
|
||||
cwd: tmpDir,
|
||||
@@ -4906,7 +5089,7 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
||||
|
||||
setupPhaseForAutoPrune(tmpDir, 6, 2);
|
||||
|
||||
const result = runGsdTools('phase complete 6', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 6', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const newState = readStateMdForAutoPrune(tmpDir);
|
||||
@@ -4946,7 +5129,7 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
||||
|
||||
setupPhaseForAutoPrune(tmpDir, 6, 2);
|
||||
|
||||
const result = runGsdTools('phase complete 6', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 6', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const newState = readStateMdForAutoPrune(tmpDir);
|
||||
@@ -4982,7 +5165,7 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
||||
|
||||
setupPhaseForAutoPrune(tmpDir, 6, 2);
|
||||
|
||||
const result = runGsdTools('phase complete 6', tmpDir);
|
||||
const result = runVerifiedPhaseComplete('phase complete 6', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const newState = readStateMdForAutoPrune(tmpDir);
|
||||
|
||||
@@ -160,18 +160,32 @@ describe('#1107: progress routing consults verification.status before reporting
|
||||
workflow.includes('verification_status'),
|
||||
'progress workflow must track a verification_status value for routing'
|
||||
);
|
||||
assert.ok(
|
||||
workflow.includes('stale verification'),
|
||||
'progress workflow must document that verification.status projects stale verification'
|
||||
);
|
||||
});
|
||||
|
||||
test('routing table has gaps_found and human_needed rows BEFORE the generic complete row', () => {
|
||||
const workflow = readWorkflow();
|
||||
const missingIdx = workflow.indexOf('verification_status = missing');
|
||||
const unknownIdx = workflow.indexOf('verification_status = unknown');
|
||||
const staleIdx = workflow.indexOf('verification_status = stale');
|
||||
const gapsIdx = workflow.indexOf('verification_status = gaps_found');
|
||||
const humanIdx = workflow.indexOf('verification_status = human_needed');
|
||||
const completeIdx = workflow.indexOf('Phase complete (verification passed');
|
||||
const completeIdx = workflow.indexOf('Phase complete (verification passed)');
|
||||
assert.ok(missingIdx > -1, 'routing table must have a missing verification row');
|
||||
assert.ok(unknownIdx > -1, 'routing table must have an unknown verification row');
|
||||
assert.ok(staleIdx > -1, 'routing table must have a stale verification row');
|
||||
assert.ok(gapsIdx > -1, 'routing table must have a gaps_found row');
|
||||
assert.ok(humanIdx > -1, 'routing table must have a human_needed row');
|
||||
assert.ok(completeIdx > -1, 'routing table must keep a generic complete row');
|
||||
assert.ok(
|
||||
gapsIdx < completeIdx && humanIdx < completeIdx,
|
||||
missingIdx < completeIdx &&
|
||||
unknownIdx < completeIdx &&
|
||||
staleIdx < completeIdx &&
|
||||
gapsIdx < completeIdx &&
|
||||
humanIdx < completeIdx,
|
||||
'verification rows must precede the generic "summaries = plans" complete row (first-match-wins)'
|
||||
);
|
||||
});
|
||||
@@ -204,11 +218,26 @@ describe('#1107: progress routing consults verification.status before reporting
|
||||
);
|
||||
});
|
||||
|
||||
test('missing/passed verification still routes as complete (no false blocker)', () => {
|
||||
test('stale verification routes to verify-work (Route V.stale)', () => {
|
||||
const workflow = readWorkflow();
|
||||
assert.ok(workflow.includes('**Route V.stale:'), 'must define a Route V.stale section');
|
||||
const route = workflow.slice(
|
||||
workflow.indexOf('**Route V.stale:'),
|
||||
workflow.indexOf('**Route V.gaps:')
|
||||
);
|
||||
assert.ok(
|
||||
workflow.includes('Phase complete (verification passed, missing, or n/a)'),
|
||||
'the generic complete row must still cover passed/missing/unknown so unverified phases are not falsely blocked'
|
||||
route.includes('verify-work'),
|
||||
'Route V.stale must route to /gsd:verify-work {phase}'
|
||||
);
|
||||
});
|
||||
|
||||
test('missing and unknown verification do not route as complete', () => {
|
||||
const workflow = readWorkflow();
|
||||
assert.ok(
|
||||
workflow.includes('Phase complete (verification passed)'),
|
||||
'the generic complete row must only cover passed verification'
|
||||
);
|
||||
assert.ok(!workflow.includes('verification passed, missing, or n/a'),
|
||||
'missing or unknown verification must not be documented as complete');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,13 @@ const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { createFixture } = require('./fixtures/index.cjs');
|
||||
|
||||
function writePassedVerification(tmpDir, phaseDirName, paddedPhase) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'phases', phaseDirName, `${paddedPhase}-VERIFICATION.md`),
|
||||
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('state-snapshot command', () => {
|
||||
let tmpDir;
|
||||
|
||||
@@ -1927,6 +1934,7 @@ describe('updatePerformanceMetricsSection', () => {
|
||||
fs.writeFileSync(path.join(phaseDir, '03-02-PLAN.md'), '# Plan 2\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), '# Summary 1\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '03-02-SUMMARY.md'), '# Summary 2\n');
|
||||
writePassedVerification(tmpDir, '03-api', '03');
|
||||
|
||||
// Also need ROADMAP.md for phase complete
|
||||
fs.writeFileSync(
|
||||
@@ -1977,6 +1985,7 @@ describe('updatePerformanceMetricsSection', () => {
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '04-01-PLAN.md'), '# Plan 1\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '04-01-SUMMARY.md'), '# Summary 1\n');
|
||||
writePassedVerification(tmpDir, '04-ui', '04');
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
@@ -2018,6 +2027,7 @@ describe('updatePerformanceMetricsSection', () => {
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '05-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '05-01-SUMMARY.md'), '# Summary\n');
|
||||
writePassedVerification(tmpDir, '05-final', '05');
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
@@ -2079,6 +2089,7 @@ describe('updatePerformanceMetricsSection', () => {
|
||||
fs.writeFileSync(path.join(phaseDir, '06-02-PLAN.md'), '# Plan 2\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '06-01-SUMMARY.md'), '# Summary\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '06-02-SUMMARY.md'), '# Summary 2\n');
|
||||
writePassedVerification(tmpDir, '06-lock', '06');
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
|
||||
19
tests/transition-verification-gate.test.cjs
Normal file
19
tests/transition-verification-gate.test.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// allow-test-rule: source-text-is-the-product see #1522
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const transitionWorkflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'transition.md');
|
||||
|
||||
test('transition workflow treats unresolved verification as a blocking phase gate (#1522)', () => {
|
||||
const content = fs.readFileSync(transitionWorkflowPath, 'utf-8');
|
||||
|
||||
assert.match(content, /preliminary check blocks obviously unresolved verification/i);
|
||||
assert.match(content, /phase\.complete[\s\S]*fail-closes/i);
|
||||
assert.match(content, /authoritative stale-aware gate/i);
|
||||
assert.match(content, /canonical verification\s+status is `passed`/i);
|
||||
assert.doesNotMatch(content, /does NOT block transition/i);
|
||||
assert.doesNotMatch(content, /carry forward as debt/i);
|
||||
});
|
||||
@@ -531,19 +531,32 @@ describe('evaluateUatPassed — VERIFICATION files', () => {
|
||||
assert.strictEqual(report.passed, true);
|
||||
});
|
||||
|
||||
test('VERIFICATION status complete satisfies --require-verification', () => {
|
||||
test('VERIFICATION status complete does NOT satisfy --require-verification', () => {
|
||||
writeFile(tmpDir, 'phase-UAT.md', makePassingUat(1));
|
||||
writeFile(tmpDir, 'phase-VERIFICATION.md', '---\nstatus: complete\n---\n\nAll good.');
|
||||
const report = evaluateUatPassed(tmpDir, { policy: { requireVerification: true } });
|
||||
assert.strictEqual(report.passed, true);
|
||||
assert.strictEqual(report.passed, false);
|
||||
assert.strictEqual(report.policy.require_verification, true);
|
||||
assert.ok(report.blockers.some(b => /verification required/i.test(b)),
|
||||
`Expected verification-required blocker, got: ${JSON.stringify(report.blockers)}`);
|
||||
});
|
||||
|
||||
test('VERIFICATION status verified satisfies --require-verification', () => {
|
||||
test('VERIFICATION status verified does NOT satisfy --require-verification', () => {
|
||||
writeFile(tmpDir, 'phase-UAT.md', makePassingUat(1));
|
||||
writeFile(tmpDir, 'phase-VERIFICATION.md', '---\nstatus: verified\n---\n\nAll good.');
|
||||
const report = evaluateUatPassed(tmpDir, { policy: { requireVerification: true } });
|
||||
assert.strictEqual(report.passed, true);
|
||||
assert.strictEqual(report.passed, false);
|
||||
assert.ok(report.blockers.some(b => /verification required/i.test(b)),
|
||||
`Expected verification-required blocker, got: ${JSON.stringify(report.blockers)}`);
|
||||
});
|
||||
|
||||
test('VERIFICATION status human_passed does NOT satisfy --require-verification', () => {
|
||||
writeFile(tmpDir, 'phase-UAT.md', makePassingUat(1));
|
||||
writeFile(tmpDir, 'phase-VERIFICATION.md', '---\nstatus: human_passed\n---\n\nAll good.');
|
||||
const report = evaluateUatPassed(tmpDir, { policy: { requireVerification: true } });
|
||||
assert.strictEqual(report.passed, false);
|
||||
assert.ok(report.blockers.some(b => /verification required/i.test(b)),
|
||||
`Expected verification-required blocker, got: ${JSON.stringify(report.blockers)}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
50
tests/ui-review-next-guidance.test.cjs
Normal file
50
tests/ui-review-next-guidance.test.cjs
Normal file
@@ -0,0 +1,50 @@
|
||||
// allow-test-rule: source-text-is-the-product see #1528
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const UI_REVIEW = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ui-review.md');
|
||||
const MANAGER = path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md');
|
||||
|
||||
describe('ui-review next guidance', () => {
|
||||
test('prioritizes current-phase verification over next-phase planning (#1528)', () => {
|
||||
const content = fs.readFileSync(UI_REVIEW, 'utf-8');
|
||||
const nextBlock = content.slice(
|
||||
content.indexOf('## ▶ Next'),
|
||||
content.indexOf('## Automated UI Verification'),
|
||||
);
|
||||
|
||||
assert.match(nextBlock, /verify-work \{N\}/, 'ui-review must route to current-phase UAT');
|
||||
assert.doesNotMatch(
|
||||
nextBlock,
|
||||
/plan-phase \{N\+1\}/,
|
||||
'ui-review must not present next-phase planning before current-phase verification passes',
|
||||
);
|
||||
assert.equal(
|
||||
(nextBlock.match(/verify-work \{N\}/g) || []).length,
|
||||
1,
|
||||
'ui-review next block must not duplicate verify-work guidance',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manager verify dispatch', () => {
|
||||
test('dispatches verify recommendations through their command field (#1523)', () => {
|
||||
const content = fs.readFileSync(MANAGER, 'utf-8');
|
||||
const compoundBlock = content.slice(
|
||||
content.indexOf('### Compound Action'),
|
||||
content.indexOf('### Discuss Phase N'),
|
||||
);
|
||||
|
||||
assert.match(compoundBlock, /recommended action's `command`/);
|
||||
assert.match(compoundBlock, /gsd-execute-phase/);
|
||||
assert.match(compoundBlock, /gsd-verify-work/);
|
||||
assert.doesNotMatch(
|
||||
compoundBlock,
|
||||
/Inline verification:\s*```[\s\S]*Skill\(skill="gsd-verify-work", args="\{PHASE_NUM\}"\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,11 @@ function writeVerificationMd(dir, filename, status, body = '') {
|
||||
fs.writeFileSync(path.join(dir, filename), frontmatter + body);
|
||||
}
|
||||
|
||||
function setMtime(filePath, iso) {
|
||||
const time = new Date(iso);
|
||||
fs.utimesSync(filePath, time, time);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('verification-status', () => {
|
||||
@@ -312,6 +317,69 @@ describe('verification-status', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('passed verification older than a summary returns stale', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-'));
|
||||
const dir = path.join(baseDir, '01-stale-passed');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
||||
const summaryPath = path.join(dir, '01-01-SUMMARY.md');
|
||||
writeVerificationMd(dir, '01-VERIFICATION.md', 'passed');
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
setMtime(verificationPath, '2026-01-01T00:00:00.000Z');
|
||||
setMtime(summaryPath, '2026-01-01T00:01:00.000Z');
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.match(result.next_action, /stale/i);
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('gaps_found verification older than a summary still returns gaps_found (not stale)', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-'));
|
||||
const dir = path.join(baseDir, '01-stale-gaps');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
||||
const summaryPath = path.join(dir, '01-01-SUMMARY.md');
|
||||
writeVerificationMd(dir, '01-VERIFICATION.md', 'gaps_found');
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
setMtime(verificationPath, '2026-01-01T00:00:00.000Z');
|
||||
setMtime(summaryPath, '2026-01-01T00:01:00.000Z');
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'gaps_found');
|
||||
assert.equal(result.next_command, '/gsd:plan-phase 01 --gaps');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('human_needed verification older than nested plans/SUMMARY-NN.md returns stale', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-'));
|
||||
const dir = path.join(baseDir, '01-stale-human-nested');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
const plansDir = path.join(dir, 'plans');
|
||||
fs.mkdirSync(plansDir);
|
||||
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
||||
const summaryPath = path.join(plansDir, 'SUMMARY-01-manual.md');
|
||||
writeVerificationMd(dir, '01-VERIFICATION.md', 'human_needed');
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
setMtime(verificationPath, '2026-01-01T00:00:00.000Z');
|
||||
setMtime(summaryPath, '2026-01-01T00:01:00.000Z');
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Task 2 (B1): ship.md gate sentinel contract anchor ────────────────────
|
||||
//
|
||||
// The deleted tests/ship-586-verification-routing.test.cjs was the only
|
||||
|
||||
@@ -78,6 +78,47 @@ describe('verify-work.md — auto-transition after UAT passes with 0 issues', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('auto-transition is gated by UAT plus canonical verification predicate', () => {
|
||||
const content = fs.readFileSync(VERIFY_WORK, 'utf-8');
|
||||
const predicateIdx = content.indexOf('phase uat-passed');
|
||||
const requireVerificationIdx = content.indexOf('--require-verification');
|
||||
const transitionIdx = content.indexOf('transition.md');
|
||||
|
||||
assert.ok(predicateIdx !== -1, 'verify-work.md must call phase uat-passed before transition');
|
||||
assert.ok(
|
||||
requireVerificationIdx > predicateIdx,
|
||||
'verify-work.md must require canonical verification in the UAT predicate'
|
||||
);
|
||||
assert.ok(
|
||||
predicateIdx < transitionIdx,
|
||||
'UAT-plus-verification predicate must run before transition.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('human_needed verification is promoted to passed only after successful human UAT', () => {
|
||||
const content = fs.readFileSync(VERIFY_WORK, 'utf-8');
|
||||
const statusIdx = content.indexOf('VERIFICATION_STATUS=$(gsd_run query verification.status "$PHASE_DIR"');
|
||||
const humanNeededIdx = content.indexOf('if [ "$VERIFICATION_STATUS_VALUE" = "human_needed" ]; then');
|
||||
const setPassedIdx = content.indexOf('gsd_run query frontmatter.set "$VERIFICATION_FILE" --field status --value passed');
|
||||
const predicateIdx = content.indexOf('PHASE_COMPLETE=$(gsd_run phase uat-passed "{phase}" --require-verification)');
|
||||
|
||||
assert.ok(statusIdx !== -1, 'verify-work.md must inspect canonical verification status');
|
||||
assert.ok(humanNeededIdx > statusIdx, 'status=passed promotion must be restricted to human_needed');
|
||||
assert.ok(setPassedIdx > humanNeededIdx, 'human_needed verification must be promoted after status check');
|
||||
assert.ok(setPassedIdx < predicateIdx, 'verification must be canonicalized before the required predicate runs');
|
||||
});
|
||||
|
||||
test('stale verification blocks before phase transition', () => {
|
||||
const content = fs.readFileSync(VERIFY_WORK, 'utf-8');
|
||||
const staleIdx = content.indexOf('If `PHASE_VERIFICATION_STATUS` is `stale`');
|
||||
const predicateIdx = content.indexOf('PHASE_COMPLETE=$(gsd_run phase uat-passed "{phase}" --require-verification)');
|
||||
const transitionIdx = content.indexOf('transition.md');
|
||||
|
||||
assert.ok(staleIdx !== -1, 'verify-work.md must stop on stale verification');
|
||||
assert.ok(staleIdx < predicateIdx, 'stale verification must be checked before the required predicate');
|
||||
assert.ok(staleIdx < transitionIdx, 'stale verification must be checked before transition');
|
||||
});
|
||||
|
||||
test('transition is NOT suggested when security enforcement is enabled and no SECURITY.md exists', () => {
|
||||
const content = fs.readFileSync(VERIFY_WORK, 'utf-8');
|
||||
// The workflow should suggest /gsd-secure-phase when security is enabled but no file exists
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
"audit-fix.md": 10988,
|
||||
"audit-milestone.md": 17637,
|
||||
"audit-uat.md": 7425,
|
||||
"autonomous.md": 42778,
|
||||
"autonomous.md": 42675,
|
||||
"check-todos.md": 9431,
|
||||
"cleanup.md": 9897,
|
||||
"code-review-fix.md": 23890,
|
||||
"code-review.md": 31602,
|
||||
"complete-milestone.md": 29987,
|
||||
"complete-milestone.md": 31134,
|
||||
"debug.md": 13505,
|
||||
"diagnose-issues.md": 12820,
|
||||
"discovery-phase.md": 8651,
|
||||
@@ -40,7 +40,7 @@
|
||||
"list-phase-assumptions.md": 4305,
|
||||
"list-seeds.md": 6943,
|
||||
"list-workspaces.md": 5655,
|
||||
"manager.md": 26265,
|
||||
"manager.md": 26966,
|
||||
"map-codebase.md": 20789,
|
||||
"milestone-summary.md": 11774,
|
||||
"mvp-phase.md": 13582,
|
||||
@@ -57,7 +57,7 @@
|
||||
"plant-seed.md": 11741,
|
||||
"pr-branch.md": 15919,
|
||||
"profile-user.md": 20650,
|
||||
"progress.md": 29387,
|
||||
"progress.md": 30555,
|
||||
"quick.md": 48830,
|
||||
"reapply-patches.md": 20393,
|
||||
"remove-phase.md": 8469,
|
||||
@@ -79,13 +79,13 @@
|
||||
"stats.md": 6718,
|
||||
"sync-skills.md": 6125,
|
||||
"thread.md": 12400,
|
||||
"transition.md": 21787,
|
||||
"transition.md": 22016,
|
||||
"ui-phase.md": 15477,
|
||||
"ui-review.md": 11289,
|
||||
"ui-review.md": 11172,
|
||||
"ultraplan-phase.md": 10468,
|
||||
"undo.md": 10431,
|
||||
"update.md": 21053,
|
||||
"validate-phase.md": 10745,
|
||||
"verify-phase.md": 38228,
|
||||
"verify-work.md": 33436
|
||||
"verify-work.md": 35212
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user