From 0ebc3cf27925a1ad8a183782a360fb1478ba7d2d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 8 Sep 2026 06:26:54 -0400 Subject: [PATCH] fix(#4455): PROJECT.md is shared across workstreams, not workstream-scoped (#4543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: PROJECT.md is shared across workstreams, not workstream-scoped (#4455 follow-up) Self-discovered regression, found while diagnosing #4456: #4455 (PR #4542, merged as c6df4e1e46) resolved `project_path` in cmdInitCompleteMilestone via the workstream-scoped planningDir(cwd), reasoning by analogy from MILESTONES.md (which cmdMilestoneComplete genuinely does write workstream-scoped, per an explicit #1911 comment) without checking an actual PROJECT.md write call site. PROJECT.md is documented as SHARED across workstreams, not cloned per workstream: - gsd-core/references/workstream-flag.md's directory diagram marks it `# Shared`. - new-milestone.md states it outright: "PROJECT.md is shared across workstreams" — and explicitly SKIPS writing its `## Current Milestone` heading under an active workstream specifically to avoid clobbering the one shared file (#2308): "whichever workstream runs new-milestone last would silently win the shared heading." - cmdWorkstreamCreate (src/workstream.cts) never creates a PROJECT.md under a new workstream directory — only STATE.md and phases/. Under an active workstream, complete-milestone.md's safety commit was therefore silently missing the real PROJECT.md from its --files list (staging a path that never exists instead). Also fixed, in the same change: withProjectRoot (src/init.cts) — a helper every cmdInit* function calls to enrich its JSON output — read PROJECT.md via the workstream-aware planningDir(cwd) to extract project_title. This predates #4455 entirely (unrelated diff, no prior test either direction) but is the identical defect class, one line, directly adjacent to what this fix already touches: under an active workstream, every init.* command's project_title field silently vanished, since no PROJECT.md ever exists at the workstream path. Deliberately NOT fixed here: cmdInitNewMilestone (src/init.cts, the function backing new-milestone.md's own init.new-milestone call) has the identical bug for project_path/project_exists/config_path (config.json is ALSO marked `# Shared` in the same diagram). That function is exactly what #4456 (new-milestone.md's own missing --ws forwarding) already needs to modify — its field-by-field scoping belongs in that follow-up, not here. Also deliberately NOT touched: getLatestCompletedMilestone reads MILESTONES.md via the ROOT-ONLY planningRoot(cwd), contradicting cmdMilestoneComplete's workstream-scoped WRITE. Resolving that disagreement requires a genuine product-intent call (is "latest completed milestone" scoped to the current workstream or pooled project-wide?) that isn't derivable from the code alone — left alone rather than guessed. Verified: direct CLI invocation confirms project_path/project_title now resolve to the root PROJECT.md under GSD_WORKSTREAM=alpha instead of a workstream-scoped path that no writer ever populates. New regression tests cover both the real cmdInitCompleteMilestone function (tests/init-manager.test.cjs) and withProjectRoot's project_title (same file); the existing fence-level test in tests/workstream-scoped-paths.test.cjs (which enshrined the wrong behavior) is corrected to reflect reality. Co-Authored-By: Claude Sonnet 5 * fix: extend PROJECT.md root-scoping to 6 more cmdInit* functions found via grep A code-review pass on this fix's first draft (which only touched cmdInitCompleteMilestone and withProjectRoot) flagged that cmdInitNewProject had the identical bug and asked whether other call sites were missed. Grepping the exact literal patterns found SEVEN total occurrences across cmdInitNewProject, cmdInitNewMilestone, cmdInitIngestDocs, cmdInitResume, cmdInitMilestoneOp, cmdInitManager, and cmdInitProgress — not just the one the review happened to spot. All seven are the exact same defect with the exact same one-line fix (resolve via planningRoot(cwd) instead of the workstream-scoped planningDir(cwd)), so all seven are fixed here via a uniform find/replace, including cmdInitNewMilestone — an earlier plan was to leave that one for #4456 (which already needs to touch that function to add missing --ws forwarding), but leaving exactly one of seven identical, equally-evidenced occurrences unfixed for no functional reason would have been an arbitrary inconsistency, not a principled scope boundary. #4456 still needs to add the actual --ws parameter threading to cmdInitNewMilestone and separately fix its also-wrong config_path field (config.json is marked `# Shared` in workstream-flag.md's diagram too — a different field, needing its own verification, not swept up in this mechanical grep-and-replace). Also fixed: buildInitCompletenessFields (used by cmdInitNewProject and cmdInitResume for the `init_incomplete` partial-bootstrap discriminator) had the same bug in a differently-shaped literal (bare fs.existsSync(path.join(dir, 'PROJECT.md')), not the pathExistsInternal/ toPosixPath wrapper the grep matched) — only its PROJECT.md check moves to planningRoot(cwd); the REQUIREMENTS.md/MILESTONES.md/ROADMAP.md/STATE.md checks in the same function correctly stay workstream-scoped. Verified: direct CLI invocation of `init ingest-docs`, `init resume`, `init progress`, `init new-project`, and `init milestone-op` under GSD_WORKSTREAM=alpha all now report the root PROJECT.md correctly. New parametrized regression test in tests/init-manager.test.cjs exercises all five through the real CLI router (catching a router-wiring regression too, not just a src/init.cts internals check). Co-Authored-By: Claude Sonnet 5 * fix: PROJECT.md respects GSD_PROJECT namespacing, just not GSD_WORKSTREAM gsd-test caught a real regression in this fix's own previous push: resolving PROJECT.md via planningRoot(cwd) ignores BOTH GSD_PROJECT and GSD_WORKSTREAM, but only the workstream dimension is actually meant to be ignored for PROJECT.md. tests/init.test.cjs's pre-existing #3749 coverage ("init.new-project — GSD_PROJECT scoping") establishes that PROJECT.md legitimately lives at `.planning//PROJECT.md` when GSD_PROJECT is set — a genuine, tested, pre-existing multi-project namespace, distinct from a single project's own workstreams (which DO share one PROJECT.md, per the evidence in the prior two commits). Corrected every PROJECT.md path resolution in this diff to planningDir(cwd, null): `ws` explicitly nulled (so GSD_WORKSTREAM is never consulted), `project` left as undefined so it still defaults from GSD_PROJECT. This is the correct middle ground between the original bug (fully workstream-scoped, #4455's mistake) and the previous commit's overcorrection (fully root-only, breaking #3749). Verified empirically, all three combinations: GSD_WORKSTREAM alone resolves to root; GSD_PROJECT alone resolves to the namespaced path; both set together resolves to the namespaced path (workstream ignored, matching the resolution-priority contract in workstream-flag.md — project owns a distinct planning tree, workstreams exist inside ONE project's tree). New regression tests cover both the GSD_PROJECT-alone and GSD_PROJECT+GSD_WORKSTREAM-together cases. Co-Authored-By: Claude Sonnet 5 * docs: backfill changeset PR number Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: sim Co-authored-by: Claude Sonnet 5 --- .changeset/nimble-geese-zip.md | 5 + src/init.cts | 105 ++++++++++--- tests/init-manager.test.cjs | 201 +++++++++++++++++++++++++ tests/workstream-scoped-paths.test.cjs | 31 ++-- 4 files changed, 306 insertions(+), 36 deletions(-) create mode 100644 .changeset/nimble-geese-zip.md diff --git a/.changeset/nimble-geese-zip.md b/.changeset/nimble-geese-zip.md new file mode 100644 index 000000000..0d61c0ed1 --- /dev/null +++ b/.changeset/nimble-geese-zip.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4543 +--- +**`/gsd-complete-milestone`'s safety commit and every `/gsd-init`-family command now correctly treat PROJECT.md as a file shared across workstreams, not a per-workstream file** — a #4455 follow-up regression (and one pre-existing, adjacent bug) resolved PROJECT.md through the workstream-scoped path instead of the documented shared root path, so under an active workstream the safety commit silently missed the real PROJECT.md and every init command's `project_title` field silently disappeared. diff --git a/src/init.cts b/src/init.cts index f6b9ee26f..733ecd12a 100644 --- a/src/init.cts +++ b/src/init.cts @@ -344,7 +344,15 @@ function withProjectRoot(cwd: string, result: Record): Record { const planningExists = fs.existsSync(dir); const requirementsExists = fs.existsSync(path.join(dir, 'REQUIREMENTS.md')); const milestonesExists = fs.existsSync(path.join(dir, 'MILESTONES.md')); + // #4455 follow-up (code-review finding): PROJECT.md is shared across + // workstreams (see cmdInitCompleteMilestone's projectPath comment for the + // full evidence) — checked at planningRoot(cwd), never the workstream-scoped + // `dir`, so a workstream whose own REQUIREMENTS/ROADMAP/STATE are all + // present isn't wrongly reported incomplete just because the shared + // PROJECT.md isn't ALSO duplicated under its own directory. const coreComplete = - fs.existsSync(path.join(dir, 'PROJECT.md')) && + fs.existsSync(path.join(planningDir(cwd, null), 'PROJECT.md')) && requirementsExists && fs.existsSync(path.join(dir, 'ROADMAP.md')) && fs.existsSync(path.join(dir, 'STATE.md')); @@ -1363,7 +1377,10 @@ function cmdInitNewProject(cwd: string, raw: boolean, options: Record = { - project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))), + // #4455 follow-up (code-review finding): PROJECT.md is shared across + // workstreams — see cmdInitCompleteMilestone's projectPath comment for + // the full evidence. + project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))), planning_exists: fs.existsSync(planningRoot(cwd)), ...getInitGitState(cwd), // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase. The @@ -1625,7 +1650,8 @@ function cmdInitIngestDocs(cwd: string, raw: boolean): void { // hardcoded bare '.planning/intel/...', '.planning/PROJECT.md', etc. // literals into their Agent(prompt=...) blocks; those now interpolate // these fields instead. - project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')), + // #4455 follow-up: PROJECT.md is shared across workstreams. + project_path: toPosixPath(path.join(planningDir(cwd, null), 'PROJECT.md')), requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')), roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')), state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')), @@ -1672,13 +1698,17 @@ function cmdInitResume(cwd: string, raw: boolean): void { 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')))), + // #4455 follow-up (code-review finding): PROJECT.md is shared across + // workstreams — see cmdInitCompleteMilestone's projectPath comment for + // the full evidence. + project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))), planning_exists: fs.existsSync(planningRoot(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')), - project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')), + // #4455 follow-up: PROJECT.md is shared across workstreams. + project_path: toPosixPath(path.join(planningDir(cwd, null), 'PROJECT.md')), has_interrupted_agent: !!interruptedAgentId, interrupted_agent_id: interruptedAgentId, @@ -2594,7 +2624,10 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void { archived_milestones: archivedMilestones, archive_count: archivedMilestones.length, - project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))), + // #4455 follow-up (code-review finding): PROJECT.md is shared across + // workstreams — see cmdInitCompleteMilestone's projectPath comment for + // the full evidence. + project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))), roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')), archive_exists: fs.existsSync(path.join(planningRoot(cwd), 'archive')), @@ -3039,7 +3072,10 @@ function cmdInitManager(cwd: string, raw: boolean): void { waiting_signal: waitingSignal, all_complete: completedCount === nonBacklogPhases.length && nonBacklogPhases.length > 0, - project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md')))), + // #4455 follow-up (code-review finding): PROJECT.md is shared across + // workstreams — see cmdInitCompleteMilestone's projectPath comment for + // the full evidence. + project_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd, null), 'PROJECT.md')))), roadmap_exists: true, state_exists: true, manager_flags: managerFlags, @@ -3086,16 +3122,35 @@ function cmdInitCompleteMilestone( const statePath = path.join(planningBase, 'STATE.md'); const roadmapPath = path.join(planningBase, 'ROADMAP.md'); const archiveDir = path.join(planningBase, 'milestones'); - // #4455 follow-up (code-review finding): MILESTONES.md and PROJECT.md are - // workstream-scoped too — cmdMilestoneComplete (src/milestone.cts) writes - // MILESTONES.md via planningPaths(cwd).planning (the workstream base, not - // root), and planningPaths().project resolves PROJECT.md the same way. - // Neither is the deliberately-root-scoped exception `todos` is (#4256) — - // an earlier version of this fix wrongly treated both as shared root - // files, which would have made the safety commit below silently miss the - // actual files milestone.complete just wrote under an active workstream. + // #4455 follow-up (code-review finding): MILESTONES.md is workstream-scoped + // too — cmdMilestoneComplete (src/milestone.cts) writes it via + // planningPaths(cwd).planning (the workstream base, not root; #1911). It is + // not the deliberately-root-scoped exception `todos` is (#4256) — an + // earlier version of this fix wrongly treated it as a shared root file, + // which would have made the safety commit below silently miss the actual + // file milestone.complete just wrote under an active workstream. const milestonesPath = path.join(planningBase, 'MILESTONES.md'); - const projectPath = path.join(planningBase, 'PROJECT.md'); + // #4455 follow-up round 2 (self-discovered regression): PROJECT.md, unlike + // MILESTONES.md, is genuinely SHARED across a project's own workstreams — + // never cloned per workstream. gsd-core/references/workstream-flag.md's + // directory diagram marks it `# Shared`; new-milestone.md states it + // outright ("PROJECT.md is shared across workstreams") and explicitly + // SKIPS writing its `## Current Milestone` heading under an active + // workstream specifically to avoid clobbering the one shared file (#2308); + // cmdWorkstreamCreate (src/workstream.cts) never creates a PROJECT.md + // under a workstream directory. The first version of this #4455 follow-up + // wrongly generalized from planningPaths()'s structural shape (which + // composes `project` under the workstream base) without checking an + // actual PROJECT.md write path — resolved against planningRoot(cwd) + // (round 2), but that ALSO ignores the separate GSD_PROJECT dimension + // (multi-project namespacing, #3749: PROJECT.md legitimately lives at + // `.planning//PROJECT.md` when GSD_PROJECT is set — a real, + // tested, pre-existing feature planningRoot's blanket root-only read + // broke), caught by gsd-test on this fix's own first push. `planningDir` + // with `ws` explicitly nulled (never read from GSD_WORKSTREAM) but + // `project` left to default from GSD_PROJECT is the correct middle + // ground: respects project-namespacing, ignores workstream-namespacing. + const projectPath = path.join(planningDir(cwd, null), 'PROJECT.md'); // REQUIREMENTS.md is workstream-scoped the same way (planningPaths(cwd).requirements, // src/planning-workspace.cts) — the git-rm-after-archive step needs the // resolved path too, not the literal root file. @@ -3595,7 +3650,10 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record { + let tmpDir; + + beforeEach(() => { + tmpDir = require('fs').realpathSync(createTempProject()); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + function writeRootProjectMd(dir) { + fs.writeFileSync(path.join(dir, '.planning', 'PROJECT.md'), '# Test Project\n'); + } + + test('flat mode: milestones_path/requirements_path resolve to root, project_path resolves to root (regression guard)', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [{ number: '1', name: 'Setup' }]); + writeRootProjectMd(tmpDir); + fs.writeFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), '# Milestones\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n'); + + const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.milestones_path, absPlanningPath(tmpDir, 'MILESTONES.md')); + assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md')); + assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'REQUIREMENTS.md')); + }); + + test('GSD_WORKSTREAM=alpha: milestones_path/requirements_path resolve into the workstream, but project_path STAYS root (PROJECT.md is shared, #4455 follow-up regression)', () => { + seedWorkstream(tmpDir, { + name: 'alpha', + state: '---\nstatus: active\n---\n# State\n', + roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n', + }); + // PROJECT.md is only ever written at root — cmdWorkstreamCreate never + // creates a per-workstream copy (it is a documented shared file, see + // gsd-core/references/workstream-flag.md's directory diagram). + writeRootProjectMd(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'MILESTONES.md'), '# Milestones\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'REQUIREMENTS.md'), '# Requirements\n'); + + const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: 'alpha' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.milestones_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'MILESTONES.md')); + assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'REQUIREMENTS.md')); + // The regression this test guards against: project_path must be the + // ROOT PROJECT.md, never a workstream-scoped path that no writer ever + // populates. + assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md')); + assert.notStrictEqual(output.project_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'PROJECT.md')); + }); + + test('GSD_PROJECT=second-product: project_path resolves into the PROJECT namespace, not root (#3749 regression — round 1 of this fix broke this)', () => { + // The FIRST version of this #4455 follow-up resolved project_path via + // planningRoot(cwd), which ignores GSD_PROJECT entirely — gsd-test caught + // this immediately (tests/init.test.cjs's pre-existing #3749 coverage) on + // this fix's own first push. PROJECT.md is shared across a project's own + // WORKSTREAMS, but a DIFFERENT project (GSD_PROJECT) legitimately gets + // its own separate PROJECT.md at `.planning//PROJECT.md`. The + // correct resolution is planningDir(cwd, null) — `ws` explicitly nulled + // (never read from GSD_WORKSTREAM), `project` left to default from + // GSD_PROJECT. + fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'second-product', 'PROJECT.md'), '# Second Product\n'); + writeRootProjectMd(tmpDir); // an unrelated root PROJECT.md must not win + + const result = runGsdTools('init complete-milestone', tmpDir, { GSD_PROJECT: 'second-product' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'second-product', 'PROJECT.md')); + assert.notStrictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md')); + }); + + test('GSD_PROJECT=second-product AND GSD_WORKSTREAM=alpha together: project_path follows the PROJECT namespace, ignoring the workstream', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'second-product', 'workstreams', 'alpha'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'second-product', 'PROJECT.md'), '# Second Product\n'); + + const result = runGsdTools('init complete-milestone', tmpDir, { GSD_PROJECT: 'second-product', GSD_WORKSTREAM: 'alpha' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'second-product', 'PROJECT.md')); + assert.notStrictEqual(output.project_path, + absPlanningPath(tmpDir, 'second-product', 'workstreams', 'alpha', 'PROJECT.md')); + }); +}); + +describe('withProjectRoot — project_title reads PROJECT.md from root even under a workstream (#4455 follow-up)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = require('fs').realpathSync(createTempProject()); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // Exercised via `init complete-milestone` rather than `init manager`: + // cmdInitManager has its own readiness guard requiring STATE.md/ROADMAP.md + // to already exist (see the "state_path/roadmap_path are null" test + // above), which would obscure whether THIS test is actually exercising + // withProjectRoot. Both commands share the same withProjectRoot helper. + test('flat mode: project_title is populated from the root PROJECT.md (regression guard)', () => { + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# My Test Project\n\nSome content.\n'); + + const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.project_title, 'My Test Project'); + }); + + test('GSD_WORKSTREAM=alpha: project_title is STILL populated from the root PROJECT.md, not silently dropped (#4455 follow-up regression)', () => { + seedWorkstream(tmpDir, { + name: 'alpha', + state: '---\nstatus: active\n---\n# State\n', + roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n', + }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# My Test Project\n\nSome content.\n'); + + const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: 'alpha' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.project_title, 'My Test Project', + 'project_title must be read from the shared root PROJECT.md even when a workstream is active'); + }); +}); + +describe('init subcommands sharing the project_exists/project_path PROJECT.md pattern (#4455 follow-up)', () => { + // A code-review pass on this fix's first draft found the identical + // workstream-scoped-PROJECT.md bug (fixed above for cmdInitCompleteMilestone + // and withProjectRoot) repeated verbatim, via grep, in six more cmdInit* + // functions. Each is exercised here through its real CLI subcommand rather + // than re-asserting src/init.cts internals directly, so a regression in the + // router wiring would also be caught. `init manager` and `init new-milestone` + // are deliberately excluded: `manager` has its own STATE.md/ROADMAP.md + // readiness guard (covered separately above via `init complete-milestone`, + // which shares withProjectRoot); `new-milestone`'s project_path fix is + // real (see src/init.cts) but its dedicated coverage belongs with #4456's + // own new-milestone.md workstream-forwarding work, not duplicated here. + const SUBCOMMANDS = ['ingest-docs', 'resume', 'progress', 'new-project']; + + let tmpDir; + + beforeEach(() => { + tmpDir = require('fs').realpathSync(createTempProject()); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const subcommand of SUBCOMMANDS) { + test(`GSD_WORKSTREAM=alpha: "init ${subcommand}" resolves project_path to the shared root PROJECT.md`, () => { + seedWorkstream(tmpDir, { + name: 'alpha', + state: '---\nstatus: active\n---\n# State\n', + roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n', + }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Test Project\n'); + + const result = runGsdTools(`init ${subcommand}`, tmpDir, { GSD_WORKSTREAM: 'alpha' }); + assert.ok(result.success, `init ${subcommand} failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.project_exists, true, + `init ${subcommand}: project_exists must be true (root PROJECT.md exists), got: ${JSON.stringify(output.project_exists)}`); + if ('project_path' in output) { + assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'), + `init ${subcommand}: project_path must resolve to the shared root PROJECT.md, got: ${output.project_path}`); + assert.notStrictEqual(output.project_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'PROJECT.md'), + `init ${subcommand}: project_path must not resolve into the workstream directory`); + } + }); + } + + test('milestone-op: GSD_WORKSTREAM=alpha does not report project_exists=false for a root-only PROJECT.md', () => { + // milestone-op does not expose a project_path field, only project_exists + // via buildInitCompletenessFields — the coreComplete/init_incomplete fix. + seedWorkstream(tmpDir, { + name: 'alpha', + state: '---\nstatus: active\n---\n# State\n', + roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n', + }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Test Project\n'); + + const result = runGsdTools('init milestone-op', tmpDir, { GSD_WORKSTREAM: 'alpha' }); + assert.ok(result.success, `init milestone-op failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.project_exists, true, + `init milestone-op: project_exists must be true for a root-only PROJECT.md under a workstream, got: ${JSON.stringify(output.project_exists)}`); + }); +}); + // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3584-runtime-slash-emitters.test.cjs — consolidation epic #1969 (B2 #1971) diff --git a/tests/workstream-scoped-paths.test.cjs b/tests/workstream-scoped-paths.test.cjs index 419a75daa..00eb57216 100644 --- a/tests/workstream-scoped-paths.test.cjs +++ b/tests/workstream-scoped-paths.test.cjs @@ -324,37 +324,42 @@ describe('complete-milestone.md workstream-scoped paths (#4455)', () => { assert.ok(out.includes(projectPath), `expected root PROJECT.md in --files, got: ${out}`); }); - test('GSD_WORKSTREAM=alpha: --files lists workstream-scoped STATE/ROADMAP/archive/MILESTONES/PROJECT paths, not root (#4455 regression)', () => { + test('GSD_WORKSTREAM=alpha: --files lists workstream-scoped STATE/ROADMAP/archive/MILESTONES paths, but PROJECT.md STAYS root (#4455 regression)', () => { const wsStatePath = path.join(tmpDir, 'STATE-alpha.md'); const wsRoadmapPath = path.join(tmpDir, 'ROADMAP-alpha.md'); const wsArchiveDir = path.join(tmpDir, 'milestones-alpha'); const wsMilestonesPath = path.join(tmpDir, 'MILESTONES-alpha.md'); - const wsProjectPath = path.join(tmpDir, 'PROJECT-alpha.md'); + // PROJECT.md is genuinely SHARED across workstreams — never cloned per + // workstream (gsd-core/references/workstream-flag.md marks it + // `# Shared`; new-milestone.md states it outright; cmdWorkstreamCreate + // never creates one under a workstream dir). The real + // cmdInitCompleteMilestone therefore ALWAYS returns the root path for + // project_path regardless of GSD_WORKSTREAM — reflected here by + // passing the SAME root-shaped value the flat-mode test above uses, + // not a workstream-shaped one. An earlier version of this fix wrongly + // pinned MILESTONES.md and PROJECT.md as identically workstream-scoped + // (caught by isolated code review, then re-caught as a genuine + // regression after merge — see tests/init-manager.test.cjs's + // "milestones_path/project_path/requirements_path" describe block for + // the test that exercises the real init.cts function, not just this + // fence-mechanics stub). + const rootProjectPath = path.join(tmpDir, 'PROJECT-root.md'); const out = runCommitFence({ state_path: wsStatePath, roadmap_path: wsRoadmapPath, archive_dir: wsArchiveDir, - milestones_path: wsMilestonesPath, project_path: wsProjectPath, + milestones_path: wsMilestonesPath, project_path: rootProjectPath, }); assert.ok(out.includes(wsStatePath), `expected workstream STATE.md in --files, got: ${out}`); assert.ok(out.includes(wsRoadmapPath), `expected workstream ROADMAP.md in --files, got: ${out}`); assert.ok(out.includes(`${wsArchiveDir}/v[X.Y]-ROADMAP.md`), `expected workstream archive ROADMAP in --files, got: ${out}`); - // MILESTONES.md and PROJECT.md are workstream-scoped too — cmdMilestoneComplete - // (src/milestone.cts) writes MILESTONES.md via planningPaths(cwd).planning (the - // workstream base), and PROJECT.md resolves the same way (planningPaths().project). - // An earlier version of this fix wrongly pinned both as shared root files, which - // would have made this safety commit silently miss the actual files - // `milestone complete` just wrote under an active workstream (#4455 follow-up, - // caught by isolated code review). assert.ok(out.includes(wsMilestonesPath), `expected workstream MILESTONES.md in --files, got: ${out}`); - assert.ok(out.includes(wsProjectPath), `expected workstream PROJECT.md in --files, got: ${out}`); + assert.ok(out.includes(rootProjectPath), `expected root PROJECT.md in --files, got: ${out}`); assert.ok(!out.includes(path.join(tmpDir, '.planning', 'STATE.md')), `must not fall back to the flat root STATE.md path, got: ${out}`); assert.ok(!out.includes(path.join(tmpDir, '.planning', 'ROADMAP.md')), `must not fall back to the flat root ROADMAP.md path, got: ${out}`); assert.ok(!out.includes(path.join(tmpDir, '.planning', 'MILESTONES.md')), `must not fall back to the flat root MILESTONES.md path, got: ${out}`); - assert.ok(!out.includes(path.join(tmpDir, '.planning', 'PROJECT.md')), - `must not fall back to the flat root PROJECT.md path, got: ${out}`); }); });