From a8b81eb8c21b7ecdd05d209a1b35db4647b0cee4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 12:53:12 -0400 Subject: [PATCH 1/4] test: add failing regression for #2056 plan-phase foreign-prefix collapse normalizePhaseName() strips any [A-Z][A-Z0-9_]*- prefix as a project code, so a foreign-prefixed workstream/task id like MEM-01 collapses to 01 and resolves to the unrelated numeric Phase 01. These tests encode the exact repro from #2056; they fail on next. --- tests/init.test.cjs | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/init.test.cjs b/tests/init.test.cjs index 2ede0d0e8..d674de6d1 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -90,6 +90,53 @@ describe('init commands', () => { assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md'); }); + // #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project + // code, so a foreign-prefixed workstream/task id like "MEM-01" collapsed to + // "01" and resolved to the unrelated numeric Phase 01. init plan-phase must + // require exact prefixed evidence (a phase dir/roadmap entry literally + // carrying the foreign prefix) before accepting a numeric-fallback match. + test('#2056 — init plan-phase does not collapse foreign-prefixed task IDs into numeric phases', () => { + seedPhase(tmpDir, '01-stable-baseline-on-main', { + '01-CONTEXT.md': '# Phase Context', + }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n', + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'LKML' }, null, 2), + ); + + const result = runGsdTools('init plan-phase MEM-01', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01'); + assert.strictEqual(output.phase_dir, null); + assert.strictEqual(output.phase_number, null); + }); + + // #2056 companion: the guard must not reject the configured project_code's + // OWN prefixed phases — LKML-01 (project_code = LKML) must still resolve. + test('#2056 — init plan-phase still resolves configured project-code-prefixed phases', () => { + seedPhase(tmpDir, 'LKML-01-stable-baseline-on-main', { + 'LKML-01-CONTEXT.md': '# Phase Context', + }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'LKML' }, null, 2), + ); + + const result = runGsdTools('init plan-phase LKML-01', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true, 'LKML-01 (own project code) must resolve'); + assert.strictEqual(output.phase_dir, '.planning/phases/LKML-01-stable-baseline-on-main'); + assert.strictEqual(output.phase_number, 'LKML-01'); + }); + test('init plan-phase exposes text_mode from config (defaults false)', () => { const result = runGsdTools('init plan-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); From 250c901b5dcefc17d3d407ded86c1121befef765 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 13:24:37 -0400 Subject: [PATCH 2/4] fix(#2056): guard init plan-phase against foreign-prefix numeric collapse normalizePhaseName() strips any [A-Z][A-Z0-9_]*- prefix as a project code, so a foreign-prefixed query like MEM-01 collapsed to 01 and resolved to the unrelated numeric Phase 01 via the dir/roadmap fallback. Add a guard in cmdInitPlanPhase: when the query carries a prefix that is not the configured project_code, require exact prefixed evidence (a phase dir whose token literally IS the prefixed query, or a roadmap entry literally headed with it) before accepting a match; otherwise report phase_found:false. The project_code's own prefixed phases pass through unchanged. --- .changeset/2056-plan-phase-foreign-prefix.md | 5 ++ src/init.cts | 52 +++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 .changeset/2056-plan-phase-foreign-prefix.md diff --git a/.changeset/2056-plan-phase-foreign-prefix.md b/.changeset/2056-plan-phase-foreign-prefix.md new file mode 100644 index 000000000..d54d28d6d --- /dev/null +++ b/.changeset/2056-plan-phase-foreign-prefix.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 0 +--- +**`init plan-phase` no longer collapses foreign-prefixed task/workstream IDs into numeric phases** — a query like `MEM-01` (where `MEM` is not the configured `project_code`) used to have its prefix stripped and resolve to the unrelated numeric Phase 01; it now reports `phase_found: false` unless a phase directory or roadmap entry literally carries that prefix. The configured `project_code`'s own prefixed phases (e.g. `LKML-01` under `project_code: LKML`) continue to resolve as before. (#2056) diff --git a/src/init.cts b/src/init.cts index 196b266e2..18b9e483d 100644 --- a/src/init.cts +++ b/src/init.cts @@ -73,7 +73,7 @@ const { extractCurrentMilestone, } = roadmapParser; const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils; -const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = phaseId; +const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = phaseId; const { pruneOrphanedWorktrees } = worktreeSafety; const { @@ -96,6 +96,44 @@ void stripShippedMilestones; // Accept all bold/colon variants of the Requirements header (#2769) const REQUIREMENTS_HEADER_RE = /^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m; +// #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project +// code (e.g. 'MEM-01' → '01'), so a foreign-prefixed workstream/task id would +// collapse to its numeric suffix and resolve to an unrelated numeric phase via +// the dir/roadmap fallback. init plan-phase must require EXACT prefixed +// evidence — a phase dir whose token literally IS the prefixed query, or a +// roadmap entry literally headed with it — before accepting such a match. The +// configured project_code's own prefix (e.g. LKML-01 under project_code: LKML) +// is never foreign and always passes through. +function parsePhasePrefix(phase: unknown): string | null { + const match = String(phase).match(/^([A-Z][A-Z0-9_]*)-(?=\d)/i); + return match ? match[1] : null; +} + +function isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean { + const prefix = parsePhasePrefix(phase); + if (!prefix) return false; + const configured = typeof projectCode === 'string' ? projectCode.trim() : ''; + return !configured || prefix.toUpperCase() !== configured.toUpperCase(); +} + +function phaseInfoMatchesExactPrefix( + phaseInfo: Record | null, + phase: string, +): boolean { + const num = phaseInfo?.['phase_number']; + const numStr = typeof num === 'string' ? num : (typeof num === 'number' ? String(num) : ''); + return numStr.toUpperCase() === phase.toUpperCase(); +} + +function roadmapPhaseMatchesExactPrefix( + roadmapPhase: Record | null, + phase: string, +): boolean { + const sectionRaw = roadmapPhase?.['section']; + const section = typeof sectionRaw === 'string' ? sectionRaw : ''; + return new RegExp(`^#{2,4}\\s*Phase\\s+${escapeRegex(phase)}(?:\\b|\\s|:)`, 'i').test(section); +} + function listPhaseSummaryFiles(phaseDir: string): string[] { return (scanPhasePlans(phaseDir) as unknown as Record)['summaryFiles']; } @@ -430,9 +468,19 @@ function cmdInitPlanPhase( } const config = loadConfig(cwd); + const foreignPrefixedPhase = isForeignPrefixedPhaseQuery(phase, config.project_code); let phaseInfo = findPhaseInternal(cwd, phase) as unknown as Record | null; + // #2056: a foreign-prefixed query must only match a phase whose token literally + // IS the prefixed query (e.g. a real 'MEM-01-*' dir); otherwise the numeric + // fallback ('MEM-01' → '01') would resolve to the unrelated numeric phase. + if (foreignPrefixedPhase && !phaseInfoMatchesExactPrefix(phaseInfo, phase)) { + phaseInfo = null; + } - const roadmapPhase = getRoadmapPhaseInternal(cwd, phase) as unknown as Record | null; + let roadmapPhase = getRoadmapPhaseInternal(cwd, phase) as unknown as Record | null; + if (foreignPrefixedPhase && !roadmapPhaseMatchesExactPrefix(roadmapPhase, phase)) { + roadmapPhase = null; + } if (phaseInfo?.['archived'] && roadmapPhase?.['found']) { phaseInfo = null; From 0a103fbbc7b34ed161ad823db6655da70d0d1994 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 14:12:44 -0400 Subject: [PATCH 3/4] test(#2056): cover accept-branch (real foreign-prefixed dir) + no-project_code edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the guard's positive leg (a real MEM-01-* dir resolves under its own prefix) and pins the strict default (no project_code → any prefixed query is foreign → not found). Covers the accept/reject/own-code matrix. --- tests/init.test.cjs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/init.test.cjs b/tests/init.test.cjs index d674de6d1..99b6412c7 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -137,6 +137,46 @@ describe('init commands', () => { assert.strictEqual(output.phase_number, 'LKML-01'); }); + // #2056 accept-branch: a foreign-prefixed query MUST still resolve when a phase + // directory literally carries that prefix (e.g. a real MEM-01-* workstream + // phase). Proves the guard accepts exact-prefixed evidence, not just rejects. + test('#2056 — init plan-phase resolves a real foreign-prefixed phase dir', () => { + seedPhase(tmpDir, 'MEM-01-integration', { + 'MEM-01-CONTEXT.md': '# Phase Context', + }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'LKML' }, null, 2), + ); + + const result = runGsdTools('init plan-phase MEM-01', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true, 'a real MEM-01-* dir must resolve under its own prefix'); + assert.strictEqual(output.phase_dir, '.planning/phases/MEM-01-integration'); + assert.strictEqual(output.phase_number, 'MEM-01'); + }); + + // #2056 edge: with NO project_code configured, any prefixed query is foreign + // and must NOT collapse to a numeric phase. Pins the strict default. + test('#2056 — init plan-phase treats prefixed queries as foreign when no project_code is configured', () => { + seedPhase(tmpDir, '01-stable-baseline-on-main', { + '01-CONTEXT.md': '# Phase Context', + }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}, null, 2), + ); + + const result = runGsdTools('init plan-phase MEM-01', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, false, 'with no project_code, MEM-01 must not resolve to numeric Phase 01'); + assert.strictEqual(output.phase_number, null); + }); + test('init plan-phase exposes text_mode from config (defaults false)', () => { const result = runGsdTools('init plan-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); From 240ce70526acbbd03060848ff1299aa10c72ea24 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 14:37:26 -0400 Subject: [PATCH 4/4] docs(changeset): backfill pr: 2105 for #2056 --- .changeset/2056-plan-phase-foreign-prefix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2056-plan-phase-foreign-prefix.md b/.changeset/2056-plan-phase-foreign-prefix.md index d54d28d6d..38f905fec 100644 --- a/.changeset/2056-plan-phase-foreign-prefix.md +++ b/.changeset/2056-plan-phase-foreign-prefix.md @@ -1,5 +1,5 @@ --- type: Fixed -pr: 0 +pr: 2105 --- **`init plan-phase` no longer collapses foreign-prefixed task/workstream IDs into numeric phases** — a query like `MEM-01` (where `MEM` is not the configured `project_code`) used to have its prefix stripped and resolve to the unrelated numeric Phase 01; it now reports `phase_found: false` unless a phase directory or roadmap entry literally carries that prefix. The configured `project_code`'s own prefixed phases (e.g. `LKML-01` under `project_code: LKML`) continue to resolve as before. (#2056)