From 580059251a9a2fb472b81e78d24c93807b907c5c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 4 Sep 2026 16:03:42 -0400 Subject: [PATCH] fix(#4040): route partially-created .planning to initialization recovery (#4283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#4040): add failing-first regression tests for partial-init routing Red: init.progress/init.resume/init.new-project payloads carry no partial-init discriminator, and progress.md/resume-project.md/ new-project.md route an interrupted bootstrap (.planning/PROJECT.md + config.json only) to Route F / STATE reconstruction / a hard error. * fix(#4040): route partially-created .planning to initialization recovery A bootstrap interrupted after .planning/PROJECT.md (but before REQUIREMENTS.md/ROADMAP.md/STATE.md) was mis-routed three ways: progress.md read it as between-milestones (Route F) or 'no planning structure', resume-project.md offered STATE.md reconstruction, and new-project.md errored 'already initialized' — a routing loop with no recovery exit. Add a shared buildInitCompletenessFields discriminator (planning_exists / requirements_exists / milestones_exists / init_incomplete) to the init.progress, init.resume and init.new-project payloads, and branch on init_incomplete in progress.md, resume-project.md and new-project.md BEFORE the legacy branches. MILESTONES.md presence excludes the archival between-milestones state, so Route F and the STATE-reconstruction path keep working. Emitted-Drift-Ack-Growth: progress.md — deliberate #4040 growth: new init_incomplete recovery branch (routing text + guard on the no-planning and Route F branches) added ahead of the legacy init_context routes. Emitted-Drift-Ack-Growth: resume-project.md — deliberate #4040 growth: new init_incomplete branch routing an interrupted bootstrap to initialization recovery before the STATE.md-reconstruction branch. Emitted-Drift-Ack-Growth: new-project.md — deliberate #4040 growth: project_exists gate split on init_incomplete so a partial bootstrap resumes initialization instead of erroring. * chore(#4040): add changeset fragment * chore(#4040): backfill PR number in changeset fragment --------- Co-authored-by: sim --- .changeset/happy-ravens-zip.md | 5 ++ gsd-core/workflows/new-project.md | 6 +- gsd-core/workflows/progress.md | 26 +++++++- gsd-core/workflows/resume-project.md | 6 +- src/init.cts | 46 +++++++++++++ tests/init.test.cjs | 97 ++++++++++++++++++++++++++++ tests/partial-init-routing.test.cjs | 95 +++++++++++++++++++++++++++ 7 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 .changeset/happy-ravens-zip.md create mode 100644 tests/partial-init-routing.test.cjs diff --git a/.changeset/happy-ravens-zip.md b/.changeset/happy-ravens-zip.md new file mode 100644 index 000000000..a4b69fa4e --- /dev/null +++ b/.changeset/happy-ravens-zip.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4283 +--- +**Partial `.planning` directories now route to initialization recovery** — a bootstrap interrupted after `.planning/PROJECT.md` no longer mis-routes `/gsd:progress` to between-milestones or "no project", nor `resume` to STATE.md reconstruction; both now resume `/gsd:new-project` until the missing REQUIREMENTS.md/ROADMAP.md/STATE.md exist. (#4040) diff --git a/gsd-core/workflows/new-project.md b/gsd-core/workflows/new-project.md index 9bd2b9f78..7d79929d8 100644 --- a/gsd-core/workflows/new-project.md +++ b/gsd-core/workflows/new-project.md @@ -37,7 +37,7 @@ AGENT_SKILLS_SYNTHESIZER=$(gsd_run query agent-skills gsd-research-synthesizer) AGENT_SKILLS_ROADMAPPER=$(gsd_run query agent-skills gsd-roadmapper) ``` -Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `project_exists`, `has_codebase_map`, `planning_exists`, `has_existing_code`, `has_package_file`, `is_brownfield`, `needs_codebase_map`, `has_git`, `git_worktree_root`, `in_nested_subdir`, `project_path`, `agents_installed`, `missing_agents`, `agent_runtime`, `agents_dir`, `required_agents`, `required_agents_installed`, `missing_required_agents`, `agent_skill_payloads_available`, `agent_skill_payload_agents`, `requirements_path`, `roadmap_path`, `config_path`, `research_dir`, `response_language`. +Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `project_exists`, `has_codebase_map`, `planning_exists`, `has_existing_code`, `has_package_file`, `is_brownfield`, `needs_codebase_map`, `has_git`, `git_worktree_root`, `in_nested_subdir`, `project_path`, `agents_installed`, `missing_agents`, `agent_runtime`, `agents_dir`, `required_agents`, `required_agents_installed`, `missing_required_agents`, `agent_skill_payloads_available`, `agent_skill_payload_agents`, `requirements_exists`, `init_incomplete`, `requirements_path`, `roadmap_path`, `config_path`, `research_dir`, `response_language`. **If `response_language` is set:** All user-facing questions, prompts, and explanations in this workflow MUST be presented in `{response_language}`. Technical terms, code, file paths, and subagent prompts stay in English — only user-facing output is translated. @@ -92,7 +92,9 @@ INSTRUCTION_FILE=$(gsd_run query project-instruction-file --runtime "$RUNTIME") All subsequent references to the project instruction file use `$INSTRUCTION_FILE`. -**If `project_exists` is true:** Error — project already initialized. Use `/gsd:progress`. +**If `project_exists` is true and `init_incomplete` is true (#4040 — interrupted bootstrap):** Resume initialization instead of erroring. `.planning/` exists but initialization stopped before all core artifacts landed. Keep the existing `PROJECT.md` and any already-created artifacts (`REQUIREMENTS.md` if present, `config.json`); skip the steps that would recreate them and continue the flow from the first missing artifact in init order — `REQUIREMENTS.md` → `ROADMAP.md` + `STATE.md` — until all exist. Do not error and do not bounce the user back to `/gsd:progress` (that routing loop is the #4040 bug). + +**If `project_exists` is true and `init_incomplete` is false:** Error — project already initialized. Use `/gsd:progress`. **Git init (#3491 — never nest `.git` inside an existing worktree):** diff --git a/gsd-core/workflows/progress.md b/gsd-core/workflows/progress.md index 85feb9d44..b0d753e84 100644 --- a/gsd-core/workflows/progress.md +++ b/gsd-core/workflows/progress.md @@ -18,13 +18,33 @@ INIT=$(gsd_run query init.progress $FORENSIC_PARAM) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` -Extract from init JSON: `project_exists`, `roadmap_exists`, `state_exists`, `phases`, `current_phase`, `next_phase`, `milestone_version`, `completed_count`, `phase_count`, `paused_at`, `state_path`, `roadmap_path`, `project_path`, `config_path`, `phase_mvp_mode`. +Extract from init JSON: `project_exists`, `roadmap_exists`, `state_exists`, `requirements_exists`, `planning_exists`, `milestones_exists`, `init_incomplete`, `phases`, `current_phase`, `next_phase`, `milestone_version`, `completed_count`, `phase_count`, `paused_at`, `state_path`, `roadmap_path`, `project_path`, `config_path`, `phase_mvp_mode`. ```bash DISCUSS_MODE=$(gsd_run query config-get workflow.discuss_mode --raw 2>/dev/null || echo "discuss") ``` -If `project_exists` is false (no `.planning/` directory): +**If `init_incomplete` is true (#4040 — interrupted bootstrap):** + +`.planning/` exists but the core initialization artifacts are missing (one or more of `REQUIREMENTS.md`, `ROADMAP.md`, `STATE.md` were never created — a bootstrap that stopped partway, e.g. after PROJECT.md). This is NOT a new project, NOT a missing STATE.md, and NOT a between-milestones state — do not fall through to any of those routes. Route to initialization recovery: + +``` +--- + +## ⚠ Initialization Incomplete + +A partial `.planning/` was found: project initialization started but stopped before creating all core artifacts (missing: REQUIREMENTS.md, ROADMAP.md, STATE.md — whichever `requirements_exists` / `roadmap_exists` / `state_exists` report as false). + +`/clear` then: + +`/gsd:new-project` — resumes initialization from the first missing artifact; existing PROJECT.md (and any already-created artifacts) are kept, not regenerated. + +--- +``` + +Exit. (The payload's `init_incomplete` is computed with the between-milestones archive case excluded — `MILESTONES.md` present means missing ROADMAP/REQUIREMENTS is archival, and that state still routes to Route F below.) + +If `init_incomplete` is false and `planning_exists` is false and `project_exists` is false (no `.planning/` directory at all): ``` No planning structure found. @@ -36,7 +56,7 @@ Exit. If missing STATE.md: suggest `/gsd:new-project`. -**If ROADMAP.md missing but PROJECT.md exists:** +**If ROADMAP.md missing but PROJECT.md exists (and `init_incomplete` is false):** This means a milestone was completed and archived. Go to **Route F** (between milestones). diff --git a/gsd-core/workflows/resume-project.md b/gsd-core/workflows/resume-project.md index 93d758e9e..d1fbe113c 100644 --- a/gsd-core/workflows/resume-project.md +++ b/gsd-core/workflows/resume-project.md @@ -25,10 +25,12 @@ INIT=$(gsd_run query init.resume) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` -Parse JSON for: `state_exists`, `roadmap_exists`, `project_exists`, `planning_exists`, `has_interrupted_agent`, `interrupted_agent_id`, `commit_docs`. +Parse JSON for: `state_exists`, `roadmap_exists`, `project_exists`, `planning_exists`, `requirements_exists`, `init_incomplete`, `has_interrupted_agent`, `interrupted_agent_id`, `commit_docs`. + +**If `init_incomplete` is true (#4040 — interrupted bootstrap):** `.planning/` exists but initialization never finished — one or more of `REQUIREMENTS.md`, `ROADMAP.md`, `STATE.md` were never created. This is NOT a STATE.md-reconstruction case (there is no project history to reconstruct from). Route to initialization recovery: resume `/gsd:new-project`, which continues from the first missing artifact and keeps the existing PROJECT.md and any already-created artifacts. Do not proceed to load_state. **If `state_exists` is true:** Proceed to load_state -**If `state_exists` is false but `roadmap_exists` or `project_exists` is true:** Offer to reconstruct STATE.md +**If `state_exists` is false but `roadmap_exists` or `project_exists` is true (and `init_incomplete` is false):** Offer to reconstruct STATE.md **If `planning_exists` is false:** This is a new project - route to /gsd:new-project diff --git a/src/init.cts b/src/init.cts index 8e547c69a..52a37db13 100644 --- a/src/init.cts +++ b/src/init.cts @@ -1299,6 +1299,36 @@ function cmdInitPlanPhase( output(withProjectRoot(cwd, result), raw); } +// #4040: shared partial-init discriminator for the init.progress / init.resume / +// init.new-project payloads. A bootstrap interrupted before the core quartet +// (PROJECT.md / REQUIREMENTS.md / ROADMAP.md / STATE.md) all landed is a +// DISTINCT routing state from "new project" and from "between milestones"; +// pre-#4040 the payloads could not express it, so progress.md mis-routed it to +// Route F and resume-project.md offered STATE.md reconstruction. +// +// Negative-space guard: `milestone.complete` archives ROADMAP.md (and +// REQUIREMENTS.md) but always leaves MILESTONES.md behind — so MILESTONES.md +// present proves missing core files are archival (between-milestones), never an +// unfinished bootstrap. REQUIREMENTS.md is written by new-project BEFORE +// ROADMAP/STATE, so its absence also proves init never finished. +function buildInitCompletenessFields(cwd: string): Record { + const dir = planningDir(cwd); + const planningExists = fs.existsSync(dir); + const requirementsExists = fs.existsSync(path.join(dir, 'REQUIREMENTS.md')); + const milestonesExists = fs.existsSync(path.join(dir, 'MILESTONES.md')); + const coreComplete = + fs.existsSync(path.join(dir, 'PROJECT.md')) && + requirementsExists && + fs.existsSync(path.join(dir, 'ROADMAP.md')) && + fs.existsSync(path.join(dir, 'STATE.md')); + return { + planning_exists: planningExists, + requirements_exists: requirementsExists, + milestones_exists: milestonesExists, + init_incomplete: planningExists && !coreComplete && !milestonesExists, + }; +} + function cmdInitNewProject(cwd: string, raw: boolean, options: Record = {}): void { const config = loadConfig(cwd); @@ -1325,6 +1355,12 @@ function cmdInitNewProject(cwd: string, raw: boolean, options: Record = { + // #4040: partial-init discriminator (see buildInitCompletenessFields). + // Spread FIRST so this literal's own root-scoped planning_exists + // (planningRoot) keeps its existing semantics; init_incomplete itself + // keys off the artifact dir (planningDir) where the core docs live. + ...buildInitCompletenessFields(cwd), + state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')), roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))), @@ -3329,6 +3371,10 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record { assert.equal(out['codebase_dir_exists'], true, 'unscoped probe of the root codebase dir'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #4040: partial-init routing signal (interrupted bootstrap detection) +// ───────────────────────────────────────────────────────────────────────────── + +describe('#4040 partial-init completeness fields', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.realpathSync(createFixture()); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('#4040 init.progress: interrupted bootstrap (PROJECT.md only) is flagged init_incomplete', () => { + // Issue repro: bootstrap died after PROJECT.md + config.json, before + // REQUIREMENTS.md / ROADMAP.md / STATE.md. + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\nTest\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); + + const result = runGsdTools('init progress', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.planning_exists, true); + assert.strictEqual(output.project_exists, true); + assert.strictEqual(output.requirements_exists, false); + assert.strictEqual(output.roadmap_exists, false); + assert.strictEqual(output.state_exists, false); + assert.strictEqual(output.milestones_exists, false); + assert.strictEqual(output.init_incomplete, true, + 'interrupted bootstrap must be distinguishable from new project / between milestones'); + }); + + test('#4040 init.progress: complete project is not init_incomplete', () => { + writePlanningDocs(tmpDir); // STATE.md + ROADMAP.md + REQUIREMENTS.md + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\nTest\n'); + + const result = runGsdTools('init progress', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.requirements_exists, true); + assert.strictEqual(output.init_incomplete, false); + }); + + test('#4040 init.progress: between-milestones archive state is not init_incomplete', () => { + // milestone.complete archives ROADMAP (and REQUIREMENTS) but leaves + // MILESTONES.md + STATE.md — Route F territory, NOT a partial bootstrap. + writePlanningDocs(tmpDir, { roadmap: false, requirements: false }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\nTest\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), '# Milestones\n\n## v1.0\n'); + + const result = runGsdTools('init progress', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.milestones_exists, true); + assert.strictEqual(output.init_incomplete, false, + 'an archived milestone (MILESTONES.md present) must keep the between-milestones route'); + }); + + test('#4040 init.progress: empty .planning (config only) is init_incomplete, not "no planning"', () => { + // Bootstrap that died before even PROJECT.md: .planning/ exists, so the + // workflow must not claim "no planning structure found". + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); + + const result = runGsdTools('init progress', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.planning_exists, true); + assert.strictEqual(output.project_exists, false); + assert.strictEqual(output.init_incomplete, true); + }); + + test('#4040 init.resume: interrupted bootstrap flagged init_incomplete', () => { + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\nTest\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); + + const result = runGsdTools('init resume', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.requirements_exists, false); + assert.strictEqual(output.init_incomplete, true, + 'resume must route to initialization recovery, not STATE.md reconstruction'); + }); + + test('#4040 init.new-project: interrupted bootstrap flagged init_incomplete', () => { + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\nTest\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); + + const result = runGsdTools('init new-project', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.init_incomplete, true, + 'new-project gate must resume a partial bootstrap instead of erroring'); + }); +}); diff --git a/tests/partial-init-routing.test.cjs b/tests/partial-init-routing.test.cjs new file mode 100644 index 000000000..8ca1694d2 --- /dev/null +++ b/tests/partial-init-routing.test.cjs @@ -0,0 +1,95 @@ +// allow-test-rule: source-text-is-the-product (see #4040) +// Workflow .md files — their text IS what the runtime loads. Testing text +// content tests the deployed contract. Per CONTRIBUTING.md exception matrix. + +/** + * GSD Tools Tests - #4040 partial-init routing contract + * + * Validates that the progress / resume-project / new-project workflows route + * an interrupted bootstrap (`init_incomplete` from the init.progress / + * init.resume payload) to initialization RECOVERY, and that this branch runs + * BEFORE the branches that mis-classified the state pre-fix: + * - progress.md Route F ("between milestones": PROJECT.md present, + * ROADMAP.md missing) + * - progress.md "no planning structure found" (project_exists=false while + * .planning/ exists) + * - resume-project.md "reconstruct STATE.md" + * - new-project.md "project already initialized" error + * + * Closes: #4040 + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +describe('#4040 partial-init routing', () => { + const workflowsDir = path.join(__dirname, '..', 'gsd-core', 'workflows'); + const progressMd = () => fs.readFileSync(path.join(workflowsDir, 'progress.md'), 'utf8'); + const resumeMd = () => fs.readFileSync(path.join(workflowsDir, 'resume-project.md'), 'utf8'); + const newProjectMd = () => fs.readFileSync(path.join(workflowsDir, 'new-project.md'), 'utf8'); + + test('progress.md routes init_incomplete to initialization recovery before Route F', () => { + const content = progressMd(); + const recoveryIdx = content.indexOf('init_incomplete'); + assert.ok(recoveryIdx > -1, 'progress.md must branch on init_incomplete'); + const routeFIdx = content.indexOf('Route F: Between milestones'); + assert.ok(routeFIdx > -1, 'Route F definition must exist'); + assert.ok( + recoveryIdx < routeFIdx, + 'the init_incomplete recovery branch must appear before the Route F (between milestones) definition' + ); + const recoverySlice = content.slice(recoveryIdx, recoveryIdx + 1200); + assert.ok( + /REQUIREMENTS\.md/.test(recoverySlice) && /ROADMAP\.md/.test(recoverySlice) && /STATE\.md/.test(recoverySlice), + 'the recovery branch must name the missing REQUIREMENTS.md / ROADMAP.md / STATE.md artifacts' + ); + assert.ok( + recoverySlice.includes('new-project'), + 'the recovery branch must route to /gsd:new-project to resume initialization' + ); + }); + + test('progress.md init_context no-planning branch is gated on planning_exists', () => { + const content = progressMd(); + const noPlanningIdx = content.indexOf('No planning structure found'); + assert.ok(noPlanningIdx > -1, 'no-planning branch must exist'); + const branchSlice = content.slice(Math.max(0, noPlanningIdx - 600), noPlanningIdx); + assert.ok( + branchSlice.includes('planning_exists'), + 'the no-planning branch must check planning_exists so an existing-but-partial .planning/ is not reported as absent' + ); + }); + + test('resume-project.md routes init_incomplete to initialization recovery before STATE reconstruction', () => { + const content = resumeMd(); + const recoveryIdx = content.indexOf('init_incomplete'); + assert.ok(recoveryIdx > -1, 'resume-project.md must branch on init_incomplete'); + const reconstructIdx = content.indexOf('Offer to reconstruct STATE.md'); + assert.ok(reconstructIdx > -1, 'STATE reconstruction branch must exist'); + assert.ok( + recoveryIdx < reconstructIdx, + 'the init_incomplete recovery branch must appear before the reconstruct-STATE.md branch' + ); + const parseIdx = content.indexOf('Parse JSON for'); + const parseSlice = content.slice(parseIdx, parseIdx + 400); + assert.ok( + parseSlice.includes('init_incomplete'), + 'the resume parse list must include init_incomplete (payload is the source of truth)' + ); + }); + + test('new-project.md resumes initialization when init_incomplete instead of erroring', () => { + const content = newProjectMd(); + const gateIdx = content.indexOf('project_already'); + const gateText = 'If `project_exists` is true'; + const gate = gateIdx > -1 ? gateIdx : content.indexOf(gateText); + assert.ok(gate > -1, 'the project_exists gate must exist'); + const gateSlice = content.slice(gate, gate + 1500); + assert.ok( + gateSlice.includes('init_incomplete'), + 'the project_exists gate must discriminate on init_incomplete (resume a partial bootstrap instead of erroring)' + ); + }); +});