fix(#4040): route partially-created .planning to initialization recovery (#4283)

* 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 <sim@local>
This commit is contained in:
Tom Boucher
2026-09-04 16:03:42 -04:00
committed by GitHub
parent b1b7cabfb5
commit 580059251a
7 changed files with 274 additions and 7 deletions

View File

@@ -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)

View File

@@ -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):**

View File

@@ -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).

View File

@@ -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
</step>

View File

@@ -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<string, boolean> {
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<string, unknown> = {}): void {
const config = loadConfig(cwd);
@@ -1325,6 +1355,12 @@ function cmdInitNewProject(cwd: string, raw: boolean, options: Record<string, un
commit_docs: config.commit_docs,
// #4040: partial-init discriminator (see buildInitCompletenessFields).
// Spread BEFORE this literal's own planning_exists so the existing
// root-scoped (`pathExistsInternal(cwd, '.planning')`) semantics for that
// one key stay byte-identical for existing consumers.
...buildInitCompletenessFields(cwd),
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
has_codebase_map: hasCodebaseMap,
planning_exists: pathExistsInternal(cwd, '.planning'),
@@ -1626,6 +1662,12 @@ function cmdInitResume(cwd: string, raw: boolean): void {
if (agentIdRaw !== null) interruptedAgentId = agentIdRaw.trim();
const result: Record<string, unknown> = {
// #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<string, unkn
project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
// #4040: partial-init discriminator (see buildInitCompletenessFields) —
// also adds planning_exists / requirements_exists / milestones_exists so
// progress.md's init_context routing never has to fall back to Glob.
...buildInitCompletenessFields(cwd),
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),

View File

@@ -5155,3 +5155,100 @@ describe('init — GSD_PROJECT scoping (#3964)', () => {
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');
});
});

View File

@@ -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)'
);
});
});