From 9f219d05ba36860c07123c0134ed1d411188f690 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 28 Aug 2026 02:25:52 -0400 Subject: [PATCH] fix(#3964): route init's waiting-signal, codebase, and skill-manifest paths through the project-aware resolver (#3971) * test(#3964): waiting_signal, codebase_dir, and skill-manifest must be project-scoped * fix(#3964): route waiting_signal, codebase_dir, and skill-manifest through the project-aware resolver * chore(#3964): changeset fragment (pr number backfilled after PR creation) * chore(#3964): backfill changeset PR number (3971) * test(#3964): assert on the POSIX-normalized codebase_dir across platforms --------- Co-authored-by: sim --- .changeset/kind-orcas-sprint.md | 5 ++ src/artifacts.cts | 1 + src/init.cts | 26 ++++-- src/onboard-projection.cts | 6 +- tests/init.test.cjs | 135 ++++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 .changeset/kind-orcas-sprint.md diff --git a/.changeset/kind-orcas-sprint.md b/.changeset/kind-orcas-sprint.md new file mode 100644 index 000000000..91fb2f891 --- /dev/null +++ b/.changeset/kind-orcas-sprint.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3971 +--- +**`GSD_PROJECT`-scoped projects keep their signals and probes in their own tree** — `init manager`'s waiting signal, `map-codebase`'s dir/maps probes, `skill-manifest --write`, and `init.new-project`'s codebase-map readiness now all resolve through the project-aware planning dir instead of the repo root. (#3964) diff --git a/src/artifacts.cts b/src/artifacts.cts index 1fc6431aa..0bcfc9d59 100644 --- a/src/artifacts.cts +++ b/src/artifacts.cts @@ -28,6 +28,7 @@ export const CANONICAL_EXACT: ReadonlySet = new Set([ 'STATE-ARCHIVE.md', // state.cts's cmdStatePrune writes this at the .planning/ root 'milestone.lock', // #3311: milestone (phase + session) claim (src/milestone-lock.cts); persistent, unlike the transient STATE.md.lock/WAITING.json 'state.json', // #3227: machine-readable state contract published at step boundaries (src/state-contract.cts) + 'skill-manifest.json', // init.cts routeSkillManifest --write (project-scoped planning root, #3964) ]); // Pattern-match canonical file names (regex tests on the basename) diff --git a/src/init.cts b/src/init.cts index 46352541d..d236c55ca 100644 --- a/src/init.cts +++ b/src/init.cts @@ -2308,7 +2308,12 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void { function cmdInitMapCodebase(cwd: string, raw: boolean): void { const config = loadConfig(cwd); - const codebaseDir = path.join(planningRoot(cwd), 'codebase'); + // #3964: scoped like the payload's own codebase_dir/codebase_dir_exists + // below (and verify.cts's codebase drift check) — has_maps/existing_maps + // reading the flat root made the same payload claim a scoped codebase dir + // exists while reporting zero maps, so map-codebase's Refresh/Skip gate + // always forced a re-map under GSD_PROJECT. + const codebaseDir = path.join(planningDir(cwd), 'codebase'); let existingMaps: string[] = []; try { existingMaps = fs.readdirSync(codebaseDir).filter((f) => f.endsWith('.md')); @@ -2328,13 +2333,16 @@ function cmdInitMapCodebase(cwd: string, raw: boolean): void { timestamp: realClock.nowIso(), // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase. - codebase_dir: toPosixPath(path.join(planningRoot(cwd), 'codebase')), + // #3964: scoped like verify.cts's codebase drift check (planningDir, not + // the flat planningRoot) so the two surfaces cannot disagree under + // GSD_PROJECT. + codebase_dir: toPosixPath(path.join(planningDir(cwd), 'codebase')), existing_maps: existingMaps, has_maps: existingMaps.length > 0, planning_exists: pathExistsInternal(cwd, '.planning'), - codebase_dir_exists: pathExistsInternal(cwd, '.planning/codebase'), + codebase_dir_exists: pathExistsInternal(cwd, toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'codebase')))), }; output(withProjectRoot(cwd, result), raw); @@ -2584,7 +2592,13 @@ function cmdInitManager(cwd: string, raw: boolean): void { let waitingSignal: unknown = null; try { - const waitingPath = path.join(cwd, '.planning', 'WAITING.json'); + // #3964: mirror cmdSignalWaiting's write locations exactly — `.gsd/` + // first when it exists, else the project-aware planning dir — so the + // signal is read from the project (and location) it is written to. + const gsdWaiting = path.join(cwd, '.gsd', 'WAITING.json'); + const waitingPath = fs.existsSync(path.join(cwd, '.gsd')) + ? gsdWaiting + : path.join(planningDir(cwd), 'WAITING.json'); const waitingRaw = platformReadSync(waitingPath); if (waitingRaw !== null) { waitingSignal = JSON.parse(waitingRaw); @@ -4001,7 +4015,9 @@ function cmdSkillManifest(cwd: string, args: string[], raw: boolean): void { const manifest = buildSkillManifest(cwd, skillsDir); if (args.includes('--write')) { - const planDir = path.join(cwd, '.planning'); + // #3964: write beside the project's own artifacts (planningDir is + // project- and workstream-aware), not the flat root. + const planDir = planningDir(cwd); if (fs.existsSync(planDir)) { const manifestPath = path.join(planDir, 'skill-manifest.json'); platformWriteSync(manifestPath, JSON.stringify(manifest, null, 2)); diff --git a/src/onboard-projection.cts b/src/onboard-projection.cts index b2021fbae..39f1fdd42 100644 --- a/src/onboard-projection.cts +++ b/src/onboard-projection.cts @@ -200,7 +200,11 @@ function listPlanningDocCandidates(cwd: string): string[] { } function listCodebaseMapFiles(cwd: string): string[] { - const codebaseDir = path.join(planningRoot(cwd), 'codebase'); + // #3964: project-scoped, agreeing with init's map-codebase surface and + // verify.cts's codebase drift check — a flat-root read made + // has_codebase_map/needs_codebase_map answer for the wrong project under + // GSD_PROJECT. + const codebaseDir = path.join(planningDir(cwd), 'codebase'); if (!fs.existsSync(codebaseDir)) return []; return REQUIRED_CODEBASE_MAP_FILES.filter((file) => fs.existsSync(path.join(codebaseDir, file)), diff --git a/tests/init.test.cjs b/tests/init.test.cjs index 31bcb4f1a..0a197d451 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -4742,3 +4742,138 @@ describe('init.new-project — GSD_PROJECT scoping (#3749)', () => { assert.equal(JSON.parse(r.output)['project_exists'], true, 'default (unscoped) behavior unchanged'); }); }); + +// ─── #3964: three GSD_PROJECT-blind planning literals ──────────────────────── +// Found in the #3955 review and filed as their own issue: waiting_signal read +// the root WAITING.json, skill-manifest --write wrote the root planning dir, +// and codebase_dir/exists were root-pinned while verify.cts scopes codebase/ +// through the project-aware resolver. +describe('init — GSD_PROJECT scoping (#3964)', () => { + function writeScopedScaffolding(tmpDir, slug) { + const scoped = path.join(tmpDir, '.planning', slug); + fs.mkdirSync(path.join(scoped, 'phases', '01-probe'), { recursive: true }); + fs.writeFileSync(path.join(scoped, 'ROADMAP.md'), '# Roadmap\n\n## Phase 1: Probe\n- [ ] w\n'); + fs.writeFileSync(path.join(scoped, 'STATE.md'), [ + '---', + 'gsd_state_version: 1.0', + 'current_phase: 01', + 'status: executing', + 'progress:', + ' total_phases: 1', + '---', + '', + '## Current Position', + '', + '**Status:** Executing', + '', + ].join('\n')); + return scoped; + } + + test('#3964: waiting_signal reads the scoped WAITING.json under GSD_PROJECT', (t) => { + const tmpDir = createTempDir('gsd-3964-waiting-'); + t.after(() => cleanup(tmpDir)); + const scoped = writeScopedScaffolding(tmpDir, 'second-product'); + fs.writeFileSync(path.join(scoped, 'WAITING.json'), JSON.stringify({ type: 'decision_point', since: 'x' })); + + const r = runGsdTools(['query', 'init', 'manager'], tmpDir, { GSD_PROJECT: 'second-product' }); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.equal(out['waiting_signal'] && out['waiting_signal']['type'], 'decision_point', + `#3964: waiting_signal must reflect the scoped WAITING.json; got ${JSON.stringify(out['waiting_signal'])}`); + }); + + test('#3964: a .gsd/WAITING.json wins over the planning-dir copy (mirrors the writer)', (t) => { + const tmpDir = createTempDir('gsd-3964-waiting2-'); + t.after(() => cleanup(tmpDir)); + const scoped = writeScopedScaffolding(tmpDir, 'second-product'); + fs.writeFileSync(path.join(scoped, 'WAITING.json'), JSON.stringify({ type: 'from-planning' })); + fs.mkdirSync(path.join(tmpDir, '.gsd'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.gsd', 'WAITING.json'), JSON.stringify({ type: 'from-gsd' })); + + const r = runGsdTools(['query', 'init', 'manager'], tmpDir, { GSD_PROJECT: 'second-product' }); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.equal(out['waiting_signal'] && out['waiting_signal']['type'], 'from-gsd', + 'the writer\'s primary location (.gsd) must win, matching cmdSignalWaiting'); + }); + + test('#3964: codebase_dir and codebase_dir_exists are scoped under GSD_PROJECT', (t) => { + const tmpDir = createTempDir('gsd-3964-codebase-'); + t.after(() => cleanup(tmpDir)); + const scoped = writeScopedScaffolding(tmpDir, 'second-product'); + fs.mkdirSync(path.join(scoped, 'codebase'), { recursive: true }); + + const r = runGsdTools(['query', 'init', 'map-codebase'], tmpDir, { GSD_PROJECT: 'second-product' }); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + // codebase_dir is POSIX-normalized (toPosixPath) — compare against a + // literal forward-slash path, not path.join (backslashes on Windows). + assert.ok(String(out['codebase_dir']).includes('.planning/second-product'), + `#3964: codebase_dir must be scoped, got ${out['codebase_dir']}`); + assert.equal(out['codebase_dir_exists'], true, + '#3964: the scoped codebase dir exists — must agree with verify scoping'); + }); + + test('#3964: skill-manifest --write targets the scoped planning dir', (t) => { + const tmpDir = createTempDir('gsd-3964-manifest-'); + t.after(() => cleanup(tmpDir)); + writeScopedScaffolding(tmpDir, 'second-product'); + + const r = runGsdTools(['skill-manifest', '--write'], tmpDir, { GSD_PROJECT: 'second-product' }); + assert.ok(r.success, r.error); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'second-product', 'skill-manifest.json')), + '#3964: skill-manifest.json must be written inside the scoped project'); + assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'skill-manifest.json')), + '#3964: the root planning dir must not gain a manifest under GSD_PROJECT'); + }); + + test('#3964: existing_maps/has_maps read the scoped codebase dir (same payload agreement)', (t) => { + const tmpDir = createTempDir('gsd-3964-maps-'); + t.after(() => cleanup(tmpDir)); + const scoped = writeScopedScaffolding(tmpDir, 'second-product'); + fs.mkdirSync(path.join(scoped, 'codebase'), { recursive: true }); + fs.writeFileSync(path.join(scoped, 'codebase', 'STRUCTURE.md'), '# Structure\n'); + + const r = runGsdTools(['query', 'init', 'map-codebase'], tmpDir, { GSD_PROJECT: 'second-product' }); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.equal(out['codebase_dir_exists'], true); + assert.equal(out['has_maps'], true, + '#3964: has_maps must agree with codebase_dir_exists — the scoped dir holds STRUCTURE.md'); + assert.ok((out['existing_maps'] || []).includes('STRUCTURE.md'), + `#3964: existing_maps must list the scoped maps, got ${JSON.stringify(out['existing_maps'])}`); + }); + + test('#3964: init.new-project has_codebase_map is project-scoped (onboard projection)', (t) => { + const tmpDir = createTempDir('gsd-3964-onboard-'); + t.after(() => cleanup(tmpDir)); + const scoped = writeScopedScaffolding(tmpDir, 'second-product'); + fs.mkdirSync(path.join(scoped, 'codebase'), { recursive: true }); + // has_codebase_map requires the COMPLETE map set (onboard-projection's + // REQUIRED_CODEBASE_MAP_FILES), not just STRUCTURE.md. + for (const f of ['STACK.md', 'ARCHITECTURE.md', 'STRUCTURE.md', 'CONVENTIONS.md', 'TESTING.md', 'INTEGRATIONS.md', 'CONCERNS.md']) { + fs.writeFileSync(path.join(scoped, 'codebase', f), '# Map\n'); + } + + const r = runGsdTools(['query', 'init.new-project'], tmpDir, { GSD_PROJECT: 'second-product' }); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.equal(out['has_codebase_map'], true, + `#3964: has_codebase_map must answer for the scoped project, got ${out['has_codebase_map']}`); + }); + + test('#3964 control: unscoped behavior unchanged (root paths)', (t) => { + const tmpDir = createTempDir('gsd-3964-unscoped-'); + t.after(() => cleanup(tmpDir)); + writeScopedScaffolding(tmpDir, 'rootproj'); + // No GSD_PROJECT: the effective project is the plain .planning root; give it + // the same scaffolding so the command runs. + fs.mkdirSync(path.join(tmpDir, '.planning', 'codebase'), { recursive: true }); + + const r = runGsdTools(['query', 'init', 'map-codebase'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.equal(out['codebase_dir_exists'], true, 'unscoped probe of the root codebase dir'); + }); +});