fix(#4801): init.manager resolves archived phase directories through findPhaseInternal (#4893)

* 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 <sim@local>
This commit is contained in:
Tom Boucher
2026-09-20 06:45:38 -04:00
committed by GitHub
parent 88b5775dc8
commit a8394713d1
5 changed files with 120 additions and 25 deletions

View File

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

View File

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

View File

@@ -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<string, boolean>();
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<string, unknown> | 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

View File

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

View File

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