From a8394713d13c388ff2bb14c5c557466c8e778cf6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 20 Sep 2026 06:45:38 -0400 Subject: [PATCH] fix(#4801): init.manager resolves archived phase directories through findPhaseInternal (#4893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#4801): failing-first — an archived phase directory must resolve and report complete in init.manager * fix(#4801): init.manager resolves archived phase directories through findPhaseInternal The private current-milestone-only scan (matchPhaseDirs over listMilestonePhaseDirs) counted archived phase directories as missing, so an archived phase with a passed verification reported no_directory/phase_complete:false. findPhaseInternal — already imported, already the shared primitive for five other init commands — searches the live directory first and falls back through the workstream-scoped archive (#2855); the private copy and its single-consumer entries list are retired. * chore(#4801): backfill changeset PR number (4893) --------- Co-authored-by: sim --- .changeset/rapid-lynx-leap.md | 5 ++ docs/CONFIGURATION.md | 2 +- src/init.cts | 35 +++++++------- src/phase-locator.cts | 13 +++-- tests/roadmap.test.cjs | 90 +++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 .changeset/rapid-lynx-leap.md diff --git a/.changeset/rapid-lynx-leap.md b/.changeset/rapid-lynx-leap.md new file mode 100644 index 000000000..277024b18 --- /dev/null +++ b/.changeset/rapid-lynx-leap.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4893 +--- +**init.manager resolves archived phase directories** — a phase archived to .planning/milestones/vX.Y-phases/ with a passing verification reported no_directory / phase_complete: false (indistinguishable from never started), so /gsd-autonomous's default discovery skipped or mis-sequenced it; the manager's phase lookup now threads the convention through findPhaseInternal, which resolves live directories first and archived milestones second. (#4801) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 37a569057..a62a3985f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -243,7 +243,7 @@ derived from the shipped agent declaration. | `dynamic_routing.max_escalations` | integer | `0`, `1`, `2`, … | `1` | Hard cap on retries per agent invocation. Beyond the cap the resolver returns the cap-tier model. Also caps `provider_escalation`. Added in v1.40 | | `dynamic_routing.provider_escalation` | string[] | ordered model IDs | (none) | Opt-in fallback providers tried when a run dies on a quota / rate limit — see [provider escalation](#provider-escalation-on-quota-exceeded--added-in-v143). Added in v1.43 ([#2296](https://github.com/open-gsd/gsd-core/issues/2296)) | | `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 | -| `phase_id_convention` | enum | `"sequential"`, `"milestone-prefixed"`, `"bracket"`, `null` | `null` | Phase ID naming convention. `null` and `"sequential"` use legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` uses globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`); it remains the only `roadmap upgrade` target on this release line. Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. `"bracket"` carries the milestone ahead of the phase number — heading `### [GSD.02] 05: Name`, directory `GSD.02-05-name` — per [ADR-612](adr/612-bracket-phase-id-convention.md) and the [compact convention card](../gsd-core/references/phase-id-convention.md). `config-set` accepts only these three exact strings (or `null` to unset the key). **`"bracket"` currently affects read and display paths only:** read-path surfaces — `roadmap analyze` / `roadmap get-phase`, the W005/W006/W007 phase checks, `validate health` (including an advisory W021 covering a bracket phase's milestone disagreeing with its enclosing section, or a phase heading still spelled in legacy form and not yet migrated to bracket form), both `total_phases` derivations, state, and phase-directory membership — recognize the bracket spelling once it is set; display-path surfaces — progress, stats, manager-init, and statusline — recognize and display canonical IDs, while `progress` / `stats` expose per-phase `display_id` and keep the bare `number`. Phase-directory membership on a bracket repo scopes by the directory's real bracket token, so an artifact misfiled from another phase (`01-VERIFICATION.md` sitting in a phase `03` directory) no longer supplies `phase complete`'s pass/fail verdict for the phase it was misfiled into — the same protection legacy directories already have. Call sites that do not yet resolve a convention keep the wider include-everything fail-safe on bracket directories until they thread one: the aggregate scans (`uat`, `audit`, `init` projections, `gap-checker`, `phase-locator`); `phase complete`'s advisory UAT/VERIFICATION warning pre-scan, which can still surface a spurious warning but cannot decide completion; and the workstream inventory's per-phase completion projection, which can still report a bracket phase complete or incomplete from a cross-phase stray. There is no bracket migrator and no bracket emit yet, so set it only on a project whose ROADMAP.md and phase directories already use that spelling. A project on any other value compiles the same heading patterns and retains the same output shape it did before. **What opting in costs:** on a bracket repo a heading whose bracket is followed directly by a digit is read as a phase heading, so shapes that are legal prose headings on any other convention — `### [RFC.2119] 5:`, `### [v1.0] 2024:`, `### [ADR.612] 3:` — are claimed as phases and will move `phase_count`, `total_phases` and W006. A bracket repo cedes that heading shape; that is the trade the opt-in buys, and it is why the widened read is selected at construction time from this value rather than applied everywhere. ([#2761](https://github.com/open-gsd/gsd-core/issues/2761), [#3638](https://github.com/open-gsd/gsd-core/issues/3638)) | +| `phase_id_convention` | enum | `"sequential"`, `"milestone-prefixed"`, `"bracket"`, `null` | `null` | Phase ID naming convention. `null` and `"sequential"` use legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` uses globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`); it remains the only `roadmap upgrade` target on this release line. Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. `"bracket"` carries the milestone ahead of the phase number — heading `### [GSD.02] 05: Name`, directory `GSD.02-05-name` — per [ADR-612](adr/612-bracket-phase-id-convention.md) and the [compact convention card](../gsd-core/references/phase-id-convention.md). `config-set` accepts only these three exact strings (or `null` to unset the key). **`"bracket"` currently affects read and display paths only:** read-path surfaces — `roadmap analyze` / `roadmap get-phase`, the W005/W006/W007 phase checks, `validate health` (including an advisory W021 covering a bracket phase's milestone disagreeing with its enclosing section, or a phase heading still spelled in legacy form and not yet migrated to bracket form), both `total_phases` derivations, state, and phase-directory membership — recognize the bracket spelling once it is set; display-path surfaces — progress, stats, manager-init, and statusline — recognize and display canonical IDs, while `progress` / `stats` expose per-phase `display_id` and keep the bare `number`. Phase-directory membership on a bracket repo scopes by the directory's real bracket token, so an artifact misfiled from another phase (`01-VERIFICATION.md` sitting in a phase `03` directory) no longer supplies `phase complete`'s pass/fail verdict for the phase it was misfiled into — the same protection legacy directories already have. Call sites that do not yet resolve a convention keep the wider include-everything fail-safe on bracket directories until they thread one: the aggregate scans (`uat`, `audit`, `gap-checker`) and — except for `init.manager`, whose phase lookup threads the convention through `findPhaseInternal` since #4801 — `phase-locator`'s remaining consumers; `phase complete`'s advisory UAT/VERIFICATION warning pre-scan, which can still surface a spurious warning but cannot decide completion; and the workstream inventory's per-phase completion projection, which can still report a bracket phase complete or incomplete from a cross-phase stray. There is no bracket migrator and no bracket emit yet, so set it only on a project whose ROADMAP.md and phase directories already use that spelling. A project on any other value compiles the same heading patterns and retains the same output shape it did before. **What opting in costs:** on a bracket repo a heading whose bracket is followed directly by a digit is read as a phase heading, so shapes that are legal prose headings on any other convention — `### [RFC.2119] 5:`, `### [v1.0] 2024:`, `### [ADR.612] 3:` — are claimed as phases and will move `phase_count`, `total_phases` and W006. A bracket repo cedes that heading shape; that is the trade the opt-in buys, and it is why the widened read is selected at construction time from this value rather than applied everywhere. ([#2761](https://github.com/open-gsd/gsd-core/issues/2761), [#3638](https://github.com/open-gsd/gsd-core/issues/3638)) | | `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32. UAT checkpoint frames (`/gsd-verify-work`) render a localized banner/instruction for English, Spanish, French, German, Portuguese, Japanese, Chinese, Korean, Italian, Dutch, Polish, Russian, Ukrainian, Turkish, Hindi, Arabic, Vietnamese, and Indonesian (endonyms and ISO codes also accepted); any other value falls back to the English frame. One deliberate exception: the `spec-phase` edge-completeness probe is fed an English translation of each requirement's text, because its shape cues are English-only — the SPEC itself stays in this language. See [Spec-Phase Edge-Completeness Probe](FEATURES.md#144-spec-phase-edge-completeness-probe). Every workflow is required to carry a directive honouring this setting, including for inter-tool narration; authors add or fix one per [response-language coverage](contributing/response-language-coverage.md), and `npm run lint:response-language` enforces it. | | `context_window` | number | any integer | `200000` | Context window size in tokens. Set `1000000` for 1M-context models (e.g., `claude-fable-5`). Values `>= 500000` enable adaptive context enrichment (full-body reads of prior SUMMARY.md, deeper anti-pattern reads). Configured via `/gsd-config --advanced`. | | `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 | diff --git a/src/init.cts b/src/init.cts index 8e8667efd..e773b2b21 100644 --- a/src/init.cts +++ b/src/init.cts @@ -102,7 +102,6 @@ const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils; const { comparePhaseNum, normalizePhaseName, - matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, PHASE_DEP_REF_SOURCE, @@ -2865,18 +2864,12 @@ function cmdInitManager(cwd: string, raw: boolean): void { } const rawContent = fs.readFileSync(paths.roadmap, 'utf-8'); const content = extractCurrentMilestone(rawContent, cwd); - const phasesDir = paths.phases; // #3185 (ADR-3180 Decision 1): "which phase directories belong to the // CURRENT milestone" is the scoped question listMilestonePhaseDirs owns — // routed through it instead of a hand-rolled readdirSync + a separate // getMilestonePhaseFilter window check (which also never excluded // sentinels, unlike the owner). - const _phaseDirEntries = listMilestonePhaseDirs(phasesDir, { - cwd, - phaseIdConvention, - }).value; - const _checkboxStates = new Map(); const _cbPattern = new RegExp( `-\\s*\\[(x| )\\]\\s*.*${phaseHeadingPrefix}(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, @@ -2928,7 +2921,6 @@ function cmdInitManager(cwd: string, raw: boolean): void { const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i); const depends_on = dependsMatch ? dependsMatch[1].trim() : null; - const normalized = normalizePhaseName(phaseNum); let diskStatus = 'no_directory'; let planCount = 0; let summaryCount = 0; @@ -2949,19 +2941,24 @@ function cmdInitManager(cwd: string, raw: boolean): void { ); try { - // #3185 (ADR-3180 Decision 2) moved this lookup off the - // milestone-scoped set and onto the physical one; that scope choice is - // kept. Only the matcher is this PR's: matchPhaseDirs resolves - // digit-leading directory names the token predicate cannot (#2528). - const dirMatch = matchPhaseDirs( - _phaseDirEntries, - normalized, - phaseIdConvention, - ).matches[0]; + // #4801: resolve through the canonical locator instead of a private + // current-milestone-only scan. findPhaseInternal searches the live + // .planning/phases directory FIRST (same set the retired + // matchPhaseDirs/_phaseDirEntries pair scanned — the #3185 physical-dir + // scope choice is inherited by the locator's live arm) and then falls + // back through listArchiveVersionDirs (workstream-scoped, #2855), so an + // ARCHIVED phase directory with a passing verification resolves instead + // of reporting no_directory/phase_complete:false. Five other init + // commands already route through this same primitive; this was the + // holdout private copy (#4793-family declared-owner drift). + const located = findPhaseInternal(cwd, phaseNum, phaseIdConvention) as unknown as Record | null; + const dirMatch = located && located['found'] + ? path.posix.basename(String(located['directory'])) + : null; if (dirMatch) { - const fullDir = path.join(phasesDir, dirMatch); - const phaseDirRel = toPosixPath(path.relative(cwd, fullDir)); + const fullDir = path.join(cwd, String(located!['directory'])); + const phaseDirRel = String(located!['directory']); // #4014 (epic #3473 B4-unreadable): this whole block used to swallow // ANY readdirSync failure below into the bare `catch { /* empty */ }` // at the bottom — an unreadable phase directory reported the exact diff --git a/src/phase-locator.cts b/src/phase-locator.cts index 6cf0946eb..cea40ea35 100644 --- a/src/phase-locator.cts +++ b/src/phase-locator.cts @@ -204,13 +204,15 @@ function listArchiveVersionDirs(cwd: string, wsOverride?: string | null): Archiv return out; } -function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null { +function searchPhaseInDir(baseDir: string, relBase: string, normalized: string, convention?: string | null): PhaseSearchResult | null { try { const dirs = readSubdirectories(baseDir, true); // #2528: canonical two-pass selection (exact token match, then the // bare-integer leading-digit-run fallback) shared with the find-phase and // phase-plan-index scans — see phase-id.cts::matchPhaseDirs. - const { matches, usedBareFallback } = matchPhaseDirs(dirs, normalized); + // #4801: the convention is threaded (optional) so bracket-convention + // consumers get exact token matching here too. + const { matches, usedBareFallback } = matchPhaseDirs(dirs, normalized, convention); if (matches.length === 0) return null; // #2237: fail loud when multiple directories match the same bare phase @@ -351,14 +353,15 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): } } -function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null { +function findPhaseInternal(cwd: string, phase: unknown, convention?: string | null): PhaseSearchResult | null { if (!phase) return null; const phasesDir = path.join(planningDir(cwd), 'phases'); const normalized = normalizePhaseName(phase); const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir)); - const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized); + // #4801: convention threaded through to the matcher (see searchPhaseInDir). + const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized, convention); if (current) return current; // #2855: scope the archived-milestone fallback to the SAME workstream as the @@ -372,7 +375,7 @@ function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | nul // getArchivedPhaseDirs via listArchiveVersionDirs (see its doc comment). for (const { version, archivePath } of listArchiveVersionDirs(cwd)) { const relBase = toPosixPath(path.relative(cwd, archivePath)); - const result = searchPhaseInDir(archivePath, relBase, normalized); + const result = searchPhaseInDir(archivePath, relBase, normalized, convention); if (result) { result.archived = version; return result; diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index f2ae4264c..2bce4d229 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -5396,3 +5396,93 @@ describe('#4786: suffix-less hand-written plan lists are recognized, not duplica ); }); }); + +// ─── #4801: archived phase directories resolve in init.manager ────────────── + +describe('#4801: init.manager resolves archived phase directories', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-4801-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + function writeState4801() { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n'); + } + + function writeRoadmap4801() { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '', + '### Phase 3: shipped', + '', + 'Goal: shipped before archive', + '', + '### Phase 5: live', + '', + 'Goal: in progress', + '', + '### Phase 7: never started', + '', + 'Goal: not started', + '', + ].join('\n')); + } + + function seedArchivedPhase() { + // An archived, shipped phase: PLAN + SUMMARY + a NEWER passing VERIFICATION + // (mtime discipline per the #3057 fixture — the verification must postdate + // the summary for the completion projection to read it as fresh). + const arch = path.join(tmpDir, '.planning', 'milestones', 'v0.1-phases', '03-shipped'); + fs.mkdirSync(arch, { recursive: true }); + fs.writeFileSync(path.join(arch, '03-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(arch, '03-01-SUMMARY.md'), '# Summary\n'); + fs.writeFileSync(path.join(arch, '03-VERIFICATION.md'), '---\nstatus: passed\n---\n\n# Verification\n'); + const older = new Date('2026-01-01T00:00:00.000Z'); + const newer = new Date('2026-01-01T00:01:00.000Z'); + fs.utimesSync(path.join(arch, '03-01-SUMMARY.md'), older, older); + fs.utimesSync(path.join(arch, '03-VERIFICATION.md'), newer, newer); + } + + test('#4801: an archived phase directory resolves and reports complete', () => { + writeState4801(); + writeRoadmap4801(); + seedArchivedPhase(); + // A live in-progress phase and a never-started phase for contrast. + const live = path.join(tmpDir, '.planning', 'phases', '05-live'); + fs.mkdirSync(live, { recursive: true }); + fs.writeFileSync(path.join(live, '05-01-PLAN.md'), '# Plan\n'); + + const output = JSON.parse(runGsdTools(['query', 'init.manager'], tmpDir).output); + const rows = new Map(output.phases.map((p) => [String(p.number), p])); + + const archived = rows.get('3'); + assert.ok(archived, 'the archived phase must appear in the enumeration'); + assert.notStrictEqual(archived.disk_status, 'no_directory', + 'an archived phase directory must resolve — no_directory means never started'); + assert.strictEqual(archived.phase_complete, true, + 'an archived phase with a passed verification reports complete'); + const neverStarted = rows.get('7'); + assert.strictEqual(neverStarted.disk_status, 'no_directory', + 'a never-started phase still reports no_directory'); + }); + + test('#4801: a live in-progress phase is unchanged by the archived-resolution swap', () => { + writeState4801(); + writeRoadmap4801(); + seedArchivedPhase(); + const live = path.join(tmpDir, '.planning', 'phases', '05-live'); + fs.mkdirSync(live, { recursive: true }); + fs.writeFileSync(path.join(live, '05-01-PLAN.md'), '# Plan\n'); + + const output = JSON.parse(runGsdTools(['query', 'init.manager'], tmpDir).output); + const row = output.phases.find((p) => String(p.number) === '5'); + assert.strictEqual(row.phase_complete, false); + assert.ok(['planned', 'empty', 'no_directory'].includes(row.disk_status), + `live plan-less phase stays incomplete; got ${row.disk_status}`); + }); +});