From af36416d14d2843ef61bb2e069bc20a099e4f5bf Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 27 May 2026 20:22:14 -0400 Subject: [PATCH] fix(#160): resume partially-executed phases before current_phase routing (#406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session dies mid-execution (hang, token exhaustion, API drop), STATE.md's current_phase can be advanced past a phase that still has PLAN.md files without matching SUMMARY.md files. Without this fix, /gsd-next and /gsd-progress would route by current_phase and silently skip the partially-executed phase, producing a data-loss-shape outcome. Adds a Route 0 cross-phase incomplete-execution scan to both next.md and progress.md. Before any current_phase-based routing, the scan finds the lowest-numbered phase where plans outnumber summaries and routes to /gsd-execute-phase to resume it. Opt out with --no-resume to fall back to the prior-phase defer prompt; --force bypasses all gates as before. Rework (codex review): - Route 0 now ordered AFTER Gates 1-3 (repo/state validity always run) but BEFORE the prior-phase completeness-scan defer prompt — eliminating the double-decision where the default path would both prompt the user (C/S/F) AND resume the phase anyway. Prior-phase defer prompt moved to a new prior_phase_completeness step; only reached via --no-resume. - --force flow made coherent across all three steps: safety_gates jumps directly to determine_next_action, skipping Gates, Route 0, AND prior_phase_completeness. resume_incomplete_phase and prior_phase_completeness now correctly state --force never reaches them. success_criteria entry updated to reflect --force → determine_next_action (not prior_phase_completeness). - Scan uses $GSD_SDK (canonical resolver form) throughout next.md, matching the file's existing convention. progress.md uses $ROADMAP already loaded by analyze_roadmap. Neither file uses bare gsd-sdk. - Errors are surfaced rather than suppressed: removed 2>/dev/null on the main roadmap.analyze call; added explicit WARNING emission when the scan cannot run, so the invariant fails closed instead of failing open. - Predicate aligned to plans-without-summaries (plans.length > summaries.length) in both files, consistent with determine_next_action Route 4. - command references use canonical /gsd: namespace form Fixes #160 Co-authored-by: Claude Opus 4.7 (1M context) --- .../160-route0-resume-incomplete-phase.md | 5 + get-shit-done/workflows/next.md | 72 ++- get-shit-done/workflows/progress.md | 49 ++ tests/policy-160-route0-resume.test.cjs | 424 ++++++++++++++++++ 4 files changed, 547 insertions(+), 3 deletions(-) create mode 100644 .changeset/160-route0-resume-incomplete-phase.md create mode 100644 tests/policy-160-route0-resume.test.cjs diff --git a/.changeset/160-route0-resume-incomplete-phase.md b/.changeset/160-route0-resume-incomplete-phase.md new file mode 100644 index 000000000..3d555b18b --- /dev/null +++ b/.changeset/160-route0-resume-incomplete-phase.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 160 +--- +`/gsd-next` and `/gsd-progress` no longer silently skip partially-executed phases when `current_phase` was advanced past unfinished work. New Route 0 invariant scans all phases for plans without summaries and routes to `/gsd-execute-phase ` before any current_phase-based routing decision. Opt out with `--no-resume` to use the existing prior-phase defer prompt instead; `--force` bypasses all gates. diff --git a/get-shit-done/workflows/next.md b/get-shit-done/workflows/next.md index c002129f2..b4cbdbd8d 100644 --- a/get-shit-done/workflows/next.md +++ b/get-shit-done/workflows/next.md @@ -38,9 +38,9 @@ Exit. Run hard-stop checks before routing. Exit on first hit unless `--force` was passed. -If `--force` flag was passed, skip all gates and the consecutive guard. +If `--force` flag was passed, skip all gates, Route 0, and the prior-phase completeness prompt. Print a one-line warning: `⚠ --force: skipping safety gates` -Then proceed directly to `determine_next_action`. +Then proceed directly to `determine_next_action`. (Route 0 and `prior_phase_completeness` are NOT reached under `--force`.) **Gate 1: Unresolved checkpoint** Check if `.planning/.continue-here.md` exists: @@ -83,8 +83,67 @@ Use `--force` to bypass this check. ``` Exit. +After all three hard-stop gates pass, continue to `resume_incomplete_phase`. + + + +**Hard invariant: any phase with PLAN.md files lacking matching SUMMARY.md files must be completed before `/gsd:progress --next` routes to any forward action.** + +This catches the common failure mode where a session died mid-execution (hang, token exhaustion, API connection drop) and STATE.md's `current_phase` got advanced past the phase that actually has unfinished work. Without this gate, `/gsd:progress --next` would route by `current_phase` and silently skip the partially-executed phase. + +**Skip if `--no-resume` was passed** (fall through to `prior_phase_completeness`). (`--force` already bypassed all gates and Route 0 at `safety_gates` — it never reaches this step.) + +**Why Route 0 runs here (after Gates 1-3, before the prior-phase defer prompt):** This step is a hard invariant independent of `current_phase`'s value — it must run before any routing rule that reads `current_phase`. Gates 1-3 are cheap repo/state validity checks that must always run — skipping them on the resume path would risk advancing into a broken-state project. The prior-phase completeness-scan DEFER PROMPT, however, must NOT run in the default (no-flag) case when Route 0 is about to resume the phase automatically: that would force a double-decision (prompt first, then resume anyway), overriding the user's choice. Route 0 placed here means: default = resume silently (no defer prompt); `--no-resume` = skip Route 0 and fall through to the prior-phase defer prompt in `prior_phase_completeness`; `--force` = jump straight to `determine_next_action` at `safety_gates` (never reaches Route 0 or `prior_phase_completeness` at all). + +Scan ALL phases in ROADMAP order (lowest-numbered to highest) for incomplete-execution state. Use `$GSD_SDK query roadmap.analyze` to get the phase list, then for each phase number `N` query `$GSD_SDK query find-phase ` JSON and inspect its `plans` and `summaries` arrays. A phase is **incomplete-execution** when `plans.length > summaries.length` (at least one PLAN.md has no matching SUMMARY.md). + +Stop at the first such phase. Record its phase number as `INCOMPLETE_PHASE`. This is the lowest-numbered phase that needs continued execution. + +Illustrative bash: + +```bash +INCOMPLETE_PHASE="" +ROADMAP_JSON=$($GSD_SDK query roadmap.analyze) +if [ $? -ne 0 ] || [ -z "$ROADMAP_JSON" ]; then + echo "⚠ WARNING: resume-incomplete-phase scan could not run (roadmap.analyze failed)." >&2 + echo " The incomplete-phase invariant (#160) could not be verified." >&2 + echo " Proceeding to prior-phase completeness check — review project state carefully." >&2 + # Fall through to prior_phase_completeness rather than silently skipping +else + for PHASE_NUM in $(echo "$ROADMAP_JSON" | jq -r '.phases[] | (.number // .phase_number // empty)'); do + PHASE_JSON=$($GSD_SDK query find-phase "$PHASE_NUM") + if [ $? -ne 0 ] || [ -z "$PHASE_JSON" ]; then + echo "⚠ WARNING: Could not query phase $PHASE_NUM — skipping in resume scan." >&2 + continue + fi + PLAN_COUNT=$(echo "$PHASE_JSON" | jq '(.plans // []) | length') + SUMMARY_COUNT=$(echo "$PHASE_JSON" | jq '(.summaries // []) | length') + if [ "${PLAN_COUNT:-0}" -gt "${SUMMARY_COUNT:-0}" ]; then + INCOMPLETE_PHASE="$PHASE_NUM" + break + fi + done +fi +``` + +**If `INCOMPLETE_PHASE` is non-empty:** route to `/gsd:execute-phase $INCOMPLETE_PHASE` and exit. Display a one-line notice before invoking: + +``` +▶ Resuming incomplete Phase ${INCOMPLETE_PHASE} (plans without summaries detected) + /gsd:execute-phase ${INCOMPLETE_PHASE} + (use --no-resume to skip this check and defer via the prior-phase prompt) +``` + +Then invoke via SlashCommand. Do not continue to subsequent steps. + +**If `INCOMPLETE_PHASE` is empty:** continue to `prior_phase_completeness`. + + + +**Prior-phase completeness scan (runs when `--no-resume` was passed and Route 0 was skipped, or when Route 0 found no incomplete-execution phases in the default case). NOT reached under `--force` — that flag jumps directly to `determine_next_action` at `safety_gates`.** + **Prior-phase completeness scan:** -After passing all three hard-stop gates, scan all phases that precede the current phase in ROADMAP.md order for incomplete work. For each prior phase number `N`, use `gsd-tools.cjs query find-phase ` JSON (plans, summaries, incomplete_plans, etc.) to inspect that phase. +Scan all phases that precede the current phase in ROADMAP.md order for incomplete work. For each prior phase number `N`, use `$GSD_SDK query find-phase ` JSON (plans, summaries, incomplete_plans, etc.) to inspect that phase. Detect three categories of incomplete work: 1. **Plans without summaries** — a PLAN.md exists in a prior phase directory but no matching SUMMARY.md exists (execution started but not completed). @@ -227,6 +286,13 @@ Resume with: `/gsd:progress --next --auto` once resolved. - [ ] Project state correctly detected +- [ ] Gates 1-3 (repo/state validity) run first — always, even on the resume path +- [ ] Route 0 (resume_incomplete_phase) runs AFTER Gates 1-3 and BEFORE the prior-phase defer prompt — no double-decision in the default (no-flag) case +- [ ] Default (no flag): Route 0 resumes incomplete phase silently, exits — user never sees the prior-phase defer prompt +- [ ] `--no-resume`: Route 0 skipped, prior_phase_completeness defer prompt runs as before +- [ ] `--force`: everything skipped (Gates, Route 0, prior_phase_completeness) → straight to `determine_next_action` +- [ ] Scan uses `$GSD_SDK` (canonical resolver form); errors are surfaced rather than suppressed +- [ ] Predicate is plans-without-summaries (`plans.length > summaries.length`) — consistent with `determine_next_action` Route 4 - [ ] Next action correctly determined from routing rules - [ ] Command invoked immediately without user confirmation - [ ] Clear status shown before invoking diff --git a/get-shit-done/workflows/progress.md b/get-shit-done/workflows/progress.md index 4a8251c16..dd1825a1a 100644 --- a/get-shit-done/workflows/progress.md +++ b/get-shit-done/workflows/progress.md @@ -169,6 +169,55 @@ When `MVP_MODE=false` (mode is null, absent, or the phase has no `**Mode:**` lin **Determine next action based on verified counts.** +**Step 0: Resume-incomplete-phase invariant (Route 0)** + +Before any current-phase-scoped counting, scan ALL phases for incomplete execution. This catches the case where STATE.md's `current_phase` was advanced past the phase that actually has unfinished work (common after a mid-execution session death from hang, token exhaustion, or API disruption). Without this guard, the current-phase-scoped count in Step 1 would inspect the wrong phase and the routing would skip the unfinished work. + +**Skip if `--no-resume` or `--force` is present in `$ARGUMENTS`.** + +Scan all phases via the `$ROADMAP` JSON already loaded in `analyze_roadmap`. For each phase entry, compare `plans` length to `summaries` length using the same plans-without-summaries predicate as `determine_next_action` Route 4 (`plans.length > summaries.length`). Stop at the first (lowest-numbered) phase where the predicate is true. Record its phase number as `INCOMPLETE_PHASE`. + +If `$ROADMAP` is empty or the query failed, surface a warning rather than silently proceeding: + +```bash +INCOMPLETE_PHASE="" +if [ -z "$ROADMAP" ]; then + echo "⚠ WARNING: resume-incomplete-phase scan could not run (\$ROADMAP is empty)." >&2 + echo " The incomplete-phase invariant (#160) could not be verified." >&2 + echo " Review project state carefully before continuing." >&2 +else + for PHASE_NUM in $(echo "$ROADMAP" | jq -r '.phases[] | (.number // .phase_number)'); do + PHASE_DATA=$(echo "$ROADMAP" | jq --arg n "$PHASE_NUM" '.phases[] | select((.number // .phase_number) == ($n | tonumber))') + PLAN_COUNT=$(echo "$PHASE_DATA" | jq '(.plans // []) | length') + SUMMARY_COUNT=$(echo "$PHASE_DATA" | jq '(.summaries // []) | length') + if [ "${PLAN_COUNT:-0}" -gt "${SUMMARY_COUNT:-0}" ]; then + INCOMPLETE_PHASE="$PHASE_NUM" + break + fi + done +fi +``` + +**If `INCOMPLETE_PHASE` is non-empty:** emit a one-line resume notice in the routing output and route to `/gsd:execute-phase ${INCOMPLETE_PHASE}` instead of running Step 1's current-phase routing. The progress report (already displayed by the `report` step above) gives the user full project status before this routing decision is shown. + +``` +--- + +## ▶ Next Up — Resuming incomplete Phase ${INCOMPLETE_PHASE} + +`/clear` then: + +`/gsd:execute-phase ${INCOMPLETE_PHASE} ${GSD_WS}` + +(plans without summaries detected; use --no-resume to skip this check and route by current_phase instead; --force to skip all gates) + +--- +``` + +Then exit the route step. Do NOT run Steps 1 through Routes A-F. + +**If `INCOMPLETE_PHASE` is empty:** continue to Step 1. + **Step 1: Count plans, summaries, and issues in current phase** List files in the current phase directory: diff --git a/tests/policy-160-route0-resume.test.cjs b/tests/policy-160-route0-resume.test.cjs new file mode 100644 index 000000000..384aa860c --- /dev/null +++ b/tests/policy-160-route0-resume.test.cjs @@ -0,0 +1,424 @@ +// allow-test-rule: pending-migration-to-typed-ir [#2974] +// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md +// "Prohibited: Raw Text Matching on Test Outputs". Per-file review may +// reclassify some entries as source-text-is-the-product during migration. + +/** + * GSD Tools Tests - Route 0 resume-incomplete-phase invariant (#160) + * + * Validates that BOTH next.md and progress.md contain the Route 0 cross-phase + * incomplete-execution scan that runs BEFORE any current_phase-based routing. + * This prevents the data-loss scenario where a crashed session advances + * current_phase past a phase that has PLAN.md files without matching + * SUMMARY.md files, causing /gsd-next or /gsd-progress to silently skip + * partially-executed work. + * + * Closes: #160 + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +describe('Route 0: resume_incomplete_phase invariant (#160)', () => { + const nextMdPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'next.md'); + const progressMdPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'progress.md'); + + // ── next.md ─────────────────────────────────────────────────────────────── + + describe('next.md', () => { + test('contains a resume_incomplete_phase step', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + assert.ok( + content.includes('name="resume_incomplete_phase"'), + 'next.md must have a step named resume_incomplete_phase (Route 0)' + ); + }); + + test('resume_incomplete_phase step appears BEFORE determine_next_action', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const route0Idx = content.indexOf('name="resume_incomplete_phase"'); + const routeIdx = content.indexOf('name="determine_next_action"'); + assert.ok(route0Idx > -1, 'resume_incomplete_phase step must exist'); + assert.ok(routeIdx > -1, 'determine_next_action step must exist'); + assert.ok( + route0Idx < routeIdx, + 'resume_incomplete_phase (Route 0) must appear before determine_next_action' + ); + }); + + test('resume_incomplete_phase step appears AFTER safety_gates', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const gatesIdx = content.indexOf('name="safety_gates"'); + const route0Idx = content.indexOf('name="resume_incomplete_phase"'); + assert.ok(gatesIdx > -1, 'safety_gates step must exist'); + assert.ok(route0Idx > -1, 'resume_incomplete_phase step must exist'); + assert.ok( + gatesIdx < route0Idx, + 'resume_incomplete_phase must appear after safety_gates' + ); + }); + + test('scans ALL phases (not just current_phase) for incomplete execution', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + // Must describe a cross-phase scan independent of current_phase + assert.ok( + content.includes('Scan ALL phases') || content.includes('scan ALL phases'), + 'Route 0 in next.md must scan ALL phases, not just current_phase' + ); + }); + + test('detects plans without summaries across all phases', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + assert.ok( + content.includes('plans.length > summaries.length') || + content.includes('plans without summaries') || + content.includes('plans-without-summaries'), + 'Route 0 in next.md must detect phases where plans outnumber summaries' + ); + }); + + test('routes to lowest-numbered incomplete phase via execute-phase', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + assert.ok( + content.includes('INCOMPLETE_PHASE'), + 'Route 0 must record the lowest incomplete phase number' + ); + assert.ok( + content.includes('gsd-execute-phase') || content.includes('gsd:execute-phase'), + 'Route 0 must route to execute-phase to resume the incomplete phase' + ); + }); + + test('provides --no-resume opt-out', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + assert.ok( + content.includes('--no-resume'), + 'Route 0 in next.md must provide --no-resume opt-out' + ); + }); + + test('--force also bypasses Route 0', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + // The --force skip must be mentioned within or near the Route 0 step + const route0Start = content.indexOf('name="resume_incomplete_phase"'); + const route0End = content.indexOf('', route0Start); + const route0Block = content.slice(route0Start, route0End); + assert.ok( + route0Block.includes('--force'), + 'Route 0 step in next.md must mention --force as a bypass' + ); + }); + + test('success_criteria includes Route 0 entry', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + assert.ok( + content.includes('Route 0') || content.includes('resume_incomplete_phase'), + 'success_criteria must reference the Route 0 / resume_incomplete_phase invariant' + ); + }); + + test('explains why Route 0 must precede current_phase routing', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + // Must contain the rationale: invariant independent of current_phase + assert.ok( + content.includes('independent of') || + content.includes('before any routing rule that reads current_phase') || + content.includes('before any current-phase'), + 'Route 0 must explain it is independent of current_phase value' + ); + }); + + // ── SHOULD-FIX 1: Route 0 ordering relative to prior-phase defer prompt ── + + test('resume_incomplete_phase runs BEFORE the prior-phase defer prompt (no double-decision)', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + // Route 0 must be ordered BEFORE the step that contains the prior-phase defer prompt. + // After the rework, the prior-phase defer prompt lives in prior_phase_completeness. + const route0Idx = content.indexOf('name="resume_incomplete_phase"'); + const deferPromptIdx = content.indexOf('name="prior_phase_completeness"'); + assert.ok(route0Idx > -1, 'resume_incomplete_phase step must exist'); + assert.ok( + deferPromptIdx > -1, + 'prior_phase_completeness step must exist (holds the C/S/F defer prompt)' + ); + assert.ok( + route0Idx < deferPromptIdx, + 'resume_incomplete_phase (Route 0) must appear BEFORE prior_phase_completeness in next.md — prevents double-decision' + ); + }); + + test('safety_gates contains ONLY Gates 1-3 (no prior-phase defer prompt)', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + // Extract just the safety_gates step body + const gatesStart = content.indexOf(''); + const gatesEnd = content.indexOf('', gatesStart); + const gatesBlock = content.slice(gatesStart, gatesEnd); + // The defer prompt's C/S/F options must NOT be inside safety_gates + assert.ok( + !gatesBlock.includes('[C] Continue and defer'), + 'safety_gates must not contain the C/S/F prior-phase defer prompt — that belongs in prior_phase_completeness' + ); + // Gates 1-3 must still be present + assert.ok(gatesBlock.includes('Gate 1'), 'safety_gates must still contain Gate 1'); + assert.ok(gatesBlock.includes('Gate 2'), 'safety_gates must still contain Gate 2'); + assert.ok(gatesBlock.includes('Gate 3'), 'safety_gates must still contain Gate 3'); + }); + + test('--no-resume routes to prior_phase_completeness (not silently skips)', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const route0Start = content.indexOf('name="resume_incomplete_phase"'); + const route0End = content.indexOf('', route0Start); + const route0Block = content.slice(route0Start, route0End); + // When --no-resume is passed, must send user to prior_phase_completeness (the defer prompt) + assert.ok( + route0Block.includes('prior_phase_completeness'), + 'Route 0 step must reference prior_phase_completeness as the --no-resume/--force fallthrough path' + ); + }); + + // ── --force flag-flow coherence (SHOULD-FIX: contradictory semantics) ── + + test('prior_phase_completeness does NOT claim to run under --force', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const ppcStart = content.indexOf('name="prior_phase_completeness"'); + const ppcEnd = content.indexOf('', ppcStart); + const ppcBlock = content.slice(ppcStart, ppcEnd); + // The step header must not say it runs "via --force" or "or --force" + assert.ok( + !ppcBlock.includes('--no-resume` or `--force`') && + !ppcBlock.includes('--force`, or'), + 'prior_phase_completeness must NOT claim to run when --force is passed ' + + '(--force jumps directly to determine_next_action at safety_gates)' + ); + }); + + test('resume_incomplete_phase does NOT route --force through to prior_phase_completeness', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const route0Start = content.indexOf('name="resume_incomplete_phase"'); + const route0End = content.indexOf('', route0Start); + const route0Block = content.slice(route0Start, route0End); + // Must NOT say "--force or --no-resume … proceed to prior_phase_completeness" + assert.ok( + !route0Block.includes('`--force` or `--no-resume`') && + !route0Block.includes('--force` was passed.** On those flags, proceed directly to `prior_phase_completeness`'), + 'resume_incomplete_phase must NOT route --force to prior_phase_completeness; ' + + '--force already jumped to determine_next_action at safety_gates' + ); + }); + + test('safety_gates --force description makes explicit it skips Route 0 and prior_phase_completeness', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const gatesStart = content.indexOf(''); + const gatesEnd = content.indexOf('', gatesStart); + const gatesBlock = content.slice(gatesStart, gatesEnd); + // The --force jump description must mention Route 0 / prior_phase_completeness skip + assert.ok( + (gatesBlock.includes('Route 0') || gatesBlock.includes('prior-phase completeness')) && + gatesBlock.includes('determine_next_action'), + 'safety_gates --force description must explicitly state it skips Route 0 and/or ' + + 'prior_phase_completeness and routes to determine_next_action' + ); + }); + + test('success_criteria --force entry routes straight to determine_next_action (not prior_phase_completeness)', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const scStart = content.indexOf(''); + const scEnd = content.indexOf('', scStart); + const scBlock = content.slice(scStart, scEnd); + // Must have a --force criterion pointing to determine_next_action + assert.ok( + scBlock.includes('--force') && scBlock.includes('determine_next_action'), + 'success_criteria must document --force as routing to determine_next_action' + ); + // Must NOT group --force with --no-resume as both triggering prior_phase_completeness + assert.ok( + !scBlock.includes('`--no-resume`/`--force`: Route 0 skipped, prior_phase_completeness'), + 'success_criteria must not conflate --force and --no-resume as both running prior_phase_completeness' + ); + }); + + // ── SHOULD-FIX 2: SDK form and fail-closed error surfacing ── + + test('scan uses $GSD_SDK (canonical resolver form, not bare gsd-sdk)', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const route0Start = content.indexOf('name="resume_incomplete_phase"'); + const route0End = content.indexOf('', route0Start); + const route0Block = content.slice(route0Start, route0End); + // Must use $GSD_SDK, not bare gsd-sdk + assert.ok( + route0Block.includes('$GSD_SDK'), + 'Route 0 scan must use $GSD_SDK (canonical resolver), not bare gsd-sdk' + ); + // Must NOT use bare gsd-sdk (without $) + const bareGsdSdkPattern = /(?/dev/null on the main query', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const route0Start = content.indexOf('name="resume_incomplete_phase"'); + const route0End = content.indexOf('', route0Start); + const route0Block = content.slice(route0Start, route0End); + // The roadmap.analyze call must not pipe to /dev/null (which causes fail-open data loss) + assert.ok( + !route0Block.includes('roadmap.analyze 2>/dev/null') && + !route0Block.includes('roadmap.analyze --pick phases 2>/dev/null'), + 'Route 0 must not suppress roadmap.analyze errors with 2>/dev/null — failure must surface, not fail open' + ); + }); + + test('scan surfaces an error warning when roadmap query fails (fail-closed)', () => { + const content = fs.readFileSync(nextMdPath, 'utf8'); + const route0Start = content.indexOf('name="resume_incomplete_phase"'); + const route0End = content.indexOf('', route0Start); + const route0Block = content.slice(route0Start, route0End); + // Must emit a warning when the scan cannot run — not silently no-op + assert.ok( + route0Block.includes('WARNING') || route0Block.includes('could not run') || route0Block.includes('could not be verified'), + 'Route 0 must emit a warning when the incomplete-phase scan fails, not silently proceed as if no incomplete phase exists' + ); + }); + }); + + // ── progress.md ─────────────────────────────────────────────────────────── + + describe('progress.md', () => { + test('contains a Step 0 / Route 0 resume-incomplete-phase invariant', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + assert.ok( + content.includes('Step 0') || content.includes('Route 0'), + 'progress.md route step must contain a Step 0 / Route 0 invariant' + ); + }); + + test('Route 0 appears BEFORE Step 1 (current-phase counting)', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + const route0Idx = content.search(/Step 0[^9]/); + const step1Idx = content.search(/Step 1:/); + assert.ok(route0Idx > -1, 'Step 0 invariant must exist in progress.md'); + assert.ok(step1Idx > -1, 'Step 1 current-phase counting must exist'); + assert.ok( + route0Idx < step1Idx, + 'Step 0 (Route 0) must appear before Step 1 in progress.md' + ); + }); + + test('scans all phases for incomplete execution before current-phase routing', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + assert.ok( + content.includes('scan ALL phases') || content.includes('Scan ALL phases') || + content.includes('scan all phases') || content.includes('all phases for incomplete'), + 'Route 0 in progress.md must scan ALL phases (not just current_phase)' + ); + }); + + test('detects plans without summaries across all phases', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + assert.ok( + content.includes('plans.length > summaries.length') || + content.includes('plans without summaries') || + content.includes('plans-without-summaries'), + 'Route 0 in progress.md must detect phases where plans outnumber summaries' + ); + }); + + test('routes to the lowest incomplete phase via execute-phase', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + assert.ok( + content.includes('INCOMPLETE_PHASE'), + 'Route 0 in progress.md must record the lowest incomplete phase number' + ); + assert.ok( + content.includes('gsd-execute-phase') || content.includes('gsd:execute-phase'), + 'Route 0 in progress.md must route to execute-phase' + ); + }); + + test('provides --no-resume opt-out', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + assert.ok( + content.includes('--no-resume'), + 'Route 0 in progress.md must provide --no-resume opt-out' + ); + }); + + test('--force also bypasses Route 0', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + // Find Route 0 block and verify --force is mentioned + const route0Start = content.indexOf('Step 0: Resume-incomplete-phase'); + assert.ok(route0Start > -1, 'Route 0 step text must exist'); + const route0End = content.indexOf('Step 1:', route0Start); + const route0Block = content.slice(route0Start, route0End); + assert.ok( + route0Block.includes('--force'), + 'Route 0 in progress.md must mention --force as a bypass' + ); + }); + + test('does not proceed to Step 1 when incomplete phase found', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + // Must explicitly state that Steps 1-F are skipped when Route 0 fires + assert.ok( + content.includes('Do NOT run Steps 1') || + content.includes('exit the route step') || + content.includes('Do not run Step 1'), + 'Route 0 in progress.md must exit before Steps 1-F when an incomplete phase is found' + ); + }); + + test('explains rationale: current_phase may have been advanced past unfinished work', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + assert.ok( + content.includes('advanced past') || + content.includes('current_phase was advanced') || + content.includes("current_phase' was advanced"), + 'Route 0 in progress.md must explain the current_phase-advanced-past-unfinished scenario' + ); + }); + + // ── SHOULD-FIX 2: SDK form and fail-closed error surfacing (progress.md) ── + + test('scan uses $ROADMAP already loaded (not a fresh bare gsd-sdk call)', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + const route0Start = content.indexOf('Step 0: Resume-incomplete-phase'); + const route0End = content.indexOf('Step 1:', route0Start); + const route0Block = content.slice(route0Start, route0End); + // progress.md convention: data comes from $ROADMAP already loaded by analyze_roadmap + assert.ok( + route0Block.includes('$ROADMAP'), + 'Route 0 in progress.md must use the $ROADMAP variable already loaded, not issue a fresh bare SDK call' + ); + }); + + test('scan surfaces an error warning when $ROADMAP is empty (fail-closed)', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + const route0Start = content.indexOf('Step 0: Resume-incomplete-phase'); + const route0End = content.indexOf('Step 1:', route0Start); + const route0Block = content.slice(route0Start, route0End); + // Must NOT silently no-op when $ROADMAP is empty — must surface a warning + assert.ok( + route0Block.includes('WARNING') || route0Block.includes('could not run') || route0Block.includes('could not be verified'), + 'Route 0 in progress.md must emit a warning when $ROADMAP is empty, not silently proceed as if no incomplete phase exists' + ); + }); + + test('predicate uses plans-without-summaries consistent with determine_next_action Route 4', () => { + const content = fs.readFileSync(progressMdPath, 'utf8'); + const route0Start = content.indexOf('Step 0: Resume-incomplete-phase'); + const route0End = content.indexOf('Step 1:', route0Start); + const route0Block = content.slice(route0Start, route0End); + // Must use the same predicate: plans.length > summaries.length + assert.ok( + route0Block.includes('plans.length > summaries.length') || + route0Block.includes('plans without summaries') || + route0Block.includes('plans-without-summaries'), + 'Route 0 in progress.md must use plans-without-summaries predicate consistent with determine_next_action Route 4' + ); + }); + }); +});