diff --git a/.changeset/witty-wasps-hum.md b/.changeset/witty-wasps-hum.md new file mode 100644 index 000000000..8a0d4db16 --- /dev/null +++ b/.changeset/witty-wasps-hum.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3191 +--- +validate consistency, validate health, and find-phase now scan .planning/milestones/v*-phases/ dirs in addition to the flat .planning/phases/ layout. Projects using milestone-archive layout no longer receive spurious W006 warnings for every active phase. Fixes #3164. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9766aa24a..8b4b6c029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased](https://github.com/gsd-build/get-shit-done/compare/v1.39.1...HEAD) +### Fixed + +- **Milestone-archive layout support** — `validate consistency`, `validate health`, and `find-phase` now scan `.planning/milestones/v*-phases/` directories in addition to the flat `.planning/phases/` layout. Projects that have graduated to milestone-archive layout no longer receive spurious W006 "Phase N in ROADMAP.md but no directory on disk" warnings for every active phase. (#3164) + ### Feature - **Six namespace meta-skills with keyword-tag descriptions** — replace the flat 86-skill diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 3ad552c10..4f9177171 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -212,48 +212,64 @@ function cmdFindPhase(cwd, phase, raw) { error('phase identifier required'); } - const phasesDir = path.join(planningDir(cwd), 'phases'); + const planBase = planningDir(cwd); const normalized = normalizePhaseName(phase); + const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [], searched_directories: [] }; - const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] }; - + // Build candidate search dirs: flat layout first, then milestone-archive layout. + const searchDirs = []; + const flatPhasesDir = path.join(planBase, 'phases'); + if (fs.existsSync(flatPhasesDir)) searchDirs.push(flatPhasesDir); try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); - - const match = dirs.find(d => phaseTokenMatches(d, normalized)); - if (!match) { - output(notFound, raw, ''); - return; + const milestonesDir = path.join(planBase, 'milestones'); + const entries = fs.readdirSync(milestonesDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && /^v\d+.*-phases$/.test(e.name)) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + for (const e of entries) { + searchDirs.push(path.join(milestonesDir, e.name)); } + } catch { /* no milestones dir */ } - // Extract phase number — supports project-code-prefixed (CK-01-name), numeric (01-name), and custom IDs - const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) - || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); - const phaseNumber = dirMatch ? dirMatch[1] : normalized; - const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; + notFound.searched_directories = searchDirs.map((searchDir) => + toPosixPath(path.join(path.relative(cwd, planBase), path.relative(planBase, searchDir)))); - const phaseDir = path.join(phasesDir, match); - const phaseFiles = fs.readdirSync(phaseDir); - const plans = phaseFiles.filter(isCanonicalPlanFile).sort(); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); - // #2893 — same diagnostic as phase-plan-index for consistency. - const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans); + for (const searchDir of searchDirs) { + try { + const entries = fs.readdirSync(searchDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); - const result = { - found: true, - directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', match)), - phase_number: phaseNumber, - phase_name: phaseName, - plans, - summaries, - }; - if (planNamingWarning) result.warning = planNamingWarning; + const match = dirs.find(d => phaseTokenMatches(d, normalized)); + if (!match) continue; - output(result, raw, result.directory); - } catch { - output(notFound, raw, ''); + // Extract phase number — supports project-code-prefixed (CK-01-name), numeric (01-name), and custom IDs + const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) + || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); + const phaseNumber = dirMatch ? dirMatch[1] : normalized; + const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; + + const phaseDir = path.join(searchDir, match); + const phaseFiles = fs.readdirSync(phaseDir); + const plans = phaseFiles.filter(isCanonicalPlanFile).sort(); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); + // #2893 — same diagnostic as phase-plan-index for consistency. + const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans); + + const result = { + found: true, + directory: toPosixPath(path.join(path.relative(cwd, planBase), path.relative(planBase, searchDir), match)), + phase_number: phaseNumber, + phase_name: phaseName, + plans, + summaries, + }; + if (planNamingWarning) result.warning = planNamingWarning; + + output(result, raw, result.directory); + return; + } catch { continue; } } + + output(notFound, raw, ''); } function extractObjective(content) { diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 9de714c6a..d5d6585be 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -396,9 +396,76 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) { }, raw, verified === results.length ? 'valid' : 'invalid'); } +const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i; +const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i; + +function listMilestoneArchiveDirs(planBase) { + const milestonesDir = path.join(planBase, 'milestones'); + try { + return fs.readdirSync(milestonesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && MILESTONE_ARCHIVE_DIR_RE.test(e.name)) + .map((e) => path.join(milestonesDir, e.name)) + .sort((a, b) => path.basename(a).localeCompare(path.basename(b), undefined, { numeric: true })); + } catch { + return []; + } +} + +function getActiveMilestoneArchiveDir(planBase) { + const archiveDirs = listMilestoneArchiveDirs(planBase); + if (archiveDirs.length === 0) return null; + + // Prefer STATE.md milestone when it maps to an on-disk archive dir. + try { + const statePath = path.join(planBase, 'STATE.md'); + if (fs.existsSync(statePath)) { + const state = fs.readFileSync(statePath, 'utf-8'); + const m = state.match(/^\s*(?:\*\*)?milestone(?:\*\*)?:\s*([^\s\r\n#]+).*$/mi); + if (m && m[1]) { + const milestone = m[1].trim(); + const candidate = path.join(planBase, 'milestones', `${milestone}-phases`); + if (archiveDirs.includes(candidate)) return candidate; + } + } + } catch { /* intentionally empty */ } + + // Fallback when STATE.md is absent/stale: highest (most recent) archive by version-ish name. + return archiveDirs[archiveDirs.length - 1]; +} + +function collectPhaseRoots(planBase) { + const roots = []; + const flatPhasesDir = path.join(planBase, 'phases'); + if (fs.existsSync(flatPhasesDir)) roots.push(flatPhasesDir); + const activeArchive = getActiveMilestoneArchiveDir(planBase); + if (activeArchive) roots.push(activeArchive); + return roots; +} + +// Returns a Set of phase numbers found on disk across active phase roots. +function collectDiskPhases(planBase) { + const diskPhases = new Set(); + const phaseRoots = collectPhaseRoots(planBase); + const scanDir = (dir) => { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory()) { + const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE); + if (m) diskPhases.add(m[1]); + } + } + } catch { /* dir absent */ } + }; + + for (const root of phaseRoots) scanDir(root); + + return diskPhases; +} + function cmdValidateConsistency(cwd, raw) { - const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); - const phasesDir = path.join(planningDir(cwd), 'phases'); + const planBase = planningDir(cwd); + const roadmapPath = path.join(planBase, 'ROADMAP.md'); const errors = []; const warnings = []; @@ -420,16 +487,8 @@ function cmdValidateConsistency(cwd, raw) { roadmapPhases.add(m[1]); } - // Get phases on disk - const diskPhases = new Set(); - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - for (const dir of dirs) { - const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); - if (dm) diskPhases.add(dm[1]); - } - } catch { /* intentionally empty */ } + // Get phases on disk (flat layout + milestone-archive layout) + const diskPhases = collectDiskPhases(planBase); // Check: phases in ROADMAP but not on disk for (const p of roadmapPhases) { @@ -461,60 +520,53 @@ function cmdValidateConsistency(cwd, raw) { } } - // Check: plan numbering within phases - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + const phaseRoots = collectPhaseRoots(planBase); + for (const phaseRoot of phaseRoots) { + try { + const entries = fs.readdirSync(phaseRoot, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - for (const dir of dirs) { - const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort(); + for (const dir of dirs) { + const phasePath = path.join(phaseRoot, dir); + const phaseLabel = path.relative(planBase, phasePath).replace(/\\/g, '/'); + const phaseFiles = fs.readdirSync(phasePath); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort(); - // Extract plan numbers - const planNums = plans.map(p => { - const pm = p.match(/-(\d{2})-PLAN\.md$/); - return pm ? parseInt(pm[1], 10) : null; - }).filter(n => n !== null); + // Extract plan numbers + const planNums = plans.map(p => { + const pm = p.match(/-(\d{2})-PLAN\.md$/); + return pm ? parseInt(pm[1], 10) : null; + }).filter(n => n !== null); - for (let i = 1; i < planNums.length; i++) { - if (planNums[i] !== planNums[i - 1] + 1) { - warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} → ${planNums[i]}`); + for (let i = 1; i < planNums.length; i++) { + if (planNums[i] !== planNums[i - 1] + 1) { + warnings.push(`Gap in plan numbering in ${phaseLabel}: plan ${planNums[i - 1]} → ${planNums[i]}`); + } + } + + // Check: plans without summaries (completed plans) + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md')); + const planIds = new Set(plans.map(p => p.replace('-PLAN.md', ''))); + const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', ''))); + + // Summary without matching plan is suspicious + for (const sid of summaryIds) { + if (!planIds.has(sid)) { + warnings.push(`Summary ${sid}-SUMMARY.md in ${phaseLabel} has no matching PLAN.md`); + } + } + + // Check: frontmatter in plans has required fields + for (const plan of plans) { + const content = fs.readFileSync(path.join(phasePath, plan), 'utf-8'); + const fm = extractFrontmatter(content); + if (!fm.wave) { + warnings.push(`${phaseLabel}/${plan}: missing 'wave' in frontmatter`); + } } } - - // Check: plans without summaries (completed plans) - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md')); - const planIds = new Set(plans.map(p => p.replace('-PLAN.md', ''))); - const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', ''))); - - // Summary without matching plan is suspicious - for (const sid of summaryIds) { - if (!planIds.has(sid)) { - warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`); - } - } - } - } catch { /* intentionally empty */ } - - // Check: frontmatter in plans has required fields - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - - for (const dir of dirs) { - const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')); - - for (const plan of plans) { - const content = fs.readFileSync(path.join(phasesDir, dir, plan), 'utf-8'); - const fm = extractFrontmatter(content); - - if (!fm.wave) { - warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`); - } - } - } - } catch { /* intentionally empty */ } + } catch { /* intentionally empty */ } + } const passed = errors.length === 0; output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed'); @@ -598,16 +650,7 @@ function cmdValidateHealth(cwd, options, raw) { // (not yet materialized on disk) and shipped-milestone history phases // (archived / cleared off disk). Matching only against on-disk dirs // produces false W002 warnings in both cases. - const validPhases = new Set(); - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - for (const e of entries) { - if (e.isDirectory()) { - const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/); - if (m) validPhases.add(m[1]); - } - } - } catch { /* intentionally empty */ } + const validPhases = collectDiskPhases(planBase); // Union in every phase declared anywhere in ROADMAP.md (current + shipped + backlog). try { if (fs.existsSync(roadmapPath)) { @@ -765,11 +808,7 @@ function cmdValidateHealth(cwd, options, raw) { roadmapPhases.add(m[1]); } - const diskPhases = new Set(); - for (const e of phaseDirEntries) { - const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); - if (dm) diskPhases.add(dm[1]); - } + const diskPhases = collectDiskPhases(planBase); // Build a set of phases explicitly marked not-yet-started in the ROADMAP // summary list (- [ ] **Phase N:**). These phases are intentionally absent diff --git a/tests/bug-3164-milestone-archive-layout.test.cjs b/tests/bug-3164-milestone-archive-layout.test.cjs new file mode 100644 index 000000000..72563bd72 --- /dev/null +++ b/tests/bug-3164-milestone-archive-layout.test.cjs @@ -0,0 +1,262 @@ +'use strict'; + +/** + * #3164 — gsd-tools doesn't support .planning/milestones/v*-phases/ layout. + * + * Validators hardcode `phasesDir = .planning/phases/`. On projects that have + * graduated to milestone-archive layout (.planning/milestones/v*-phases/), + * the old path doesn't exist and diskPhases stays empty, triggering W006 + * "Phase N in ROADMAP.md but no directory on disk" for every active phase. + * + * Fix: resolve phasesDir to the active milestone's archive dir when + * .planning/phases/ does not exist. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +function setupMilestoneArchiveProject(tmpDir, options = {}) { + const { + milestone = 'v1.7', + phases = ['64-secondary-grader-fix'], + roadmapPhases = ['64'], + } = options; + + // Remove the default .planning/phases/ dir (milestone-archive layout has no flat phases/) + fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true }); + + // Create milestone-archive phase directories + const archiveDir = path.join(tmpDir, '.planning', 'milestones', `${milestone}-phases`); + for (const phase of phases) { + const phaseDir = path.join(archiveDir, phase); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), `# Plan\nPhase ${phase}\n`); + } + + // Write STATE.md with current milestone + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `milestone: ${milestone}\n# Session State\n\nPhase: ${roadmapPhases[0]}\n` + ); + + // Write PROJECT.md + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# Project\n\n## What This Is\nTest.\n## Core Value\nTest.\n## Requirements\nTest.\n' + ); + + // Write ROADMAP.md with phases in the milestone section + const phaseLines = roadmapPhases.map(n => `### Phase ${n}: Description\n\nGoal: implement it.\n`).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n## Roadmap ${milestone}: Current\n\n${phaseLines}\n` + ); + + // Write config.json + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2) + ); +} + +describe('#3164 — validate consistency: milestone-archive layout', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('no W006 warnings for phases that exist in .planning/milestones/v*-phases/', () => { + setupMilestoneArchiveProject(tmpDir, { + milestone: 'v1.7', + phases: ['64-secondary-grader-fix'], + roadmapPhases: ['64'], + }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `validate consistency should succeed: ${result.error}`); + + const out = JSON.parse(result.output); + const w006 = (out.warnings || []).filter(w => w.includes('Phase 64') && w.includes('no directory')); + assert.deepStrictEqual( + w006, [], + `Got spurious W006 for phase 64 in milestone-archive layout:\n ${w006.join('\n ')}` + ); + }); + + test('no W006 when multiple phases exist in milestone-archive layout', () => { + setupMilestoneArchiveProject(tmpDir, { + milestone: 'v1.7', + phases: ['48-feature-a', '51-feature-b', '64-feature-c'], + roadmapPhases: ['48', '51', '64'], + }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `validate consistency should succeed: ${result.error}`); + + const out = JSON.parse(result.output); + const w006 = (out.warnings || []).filter(w => w.includes('no directory')); + assert.deepStrictEqual( + w006, [], + `Got spurious W006 warnings in milestone-archive layout:\n ${w006.join('\n ')}` + ); + }); + + test('prefixed archive dir names (CK-64-...) are recognized as phase 64', () => { + setupMilestoneArchiveProject(tmpDir, { + milestone: 'v1.7', + phases: ['CK-64-secondary-grader-fix'], + roadmapPhases: ['64'], + }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `validate consistency should succeed: ${result.error}`); + + const out = JSON.parse(result.output); + const w006 = (out.warnings || []).filter(w => w.includes('Phase 64') && w.includes('no directory')); + assert.deepStrictEqual( + w006, [], + `Prefixed phase dir should count as phase 64, got W006:\n ${w006.join('\n ')}` + ); + }); + + test('consistency scans only active milestone archive and still validates plans/frontmatter', () => { + // Remove default flat phases dir; this project is archive-only. + fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true }); + + // Old archived milestone should NOT be treated as active on-disk phase roots. + const oldDir = path.join(tmpDir, '.planning', 'milestones', 'v1.6-phases', '64-legacy'); + fs.mkdirSync(oldDir, { recursive: true }); + fs.writeFileSync(path.join(oldDir, '64-01-PLAN.md'), '# legacy plan\n'); + + // Active milestone includes intentionally malformed plan numbering/frontmatter. + const activeDir = path.join(tmpDir, '.planning', 'milestones', 'v1.7-phases', '65-current'); + fs.mkdirSync(activeDir, { recursive: true }); + fs.writeFileSync(path.join(activeDir, '65-01-PLAN.md'), '# plan 1\n'); + fs.writeFileSync(path.join(activeDir, '65-03-PLAN.md'), '# plan 3\n'); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Session State\n\n**Milestone:** v1.7 Current Milestone\nPhase: 65\n' + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n## Roadmap v1.7: Current\n\n### Phase 65: Current work\n\nGoal: test.\n' + ); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `validate consistency should succeed: ${result.error}`); + + const out = JSON.parse(result.output); + const warnings = out.warnings || []; + const phase64Warnings = warnings.filter(w => w.includes('Phase 64 exists on disk but not in ROADMAP.md')); + assert.deepStrictEqual( + phase64Warnings, + [], + `Old archived milestone phase 64 should not be treated as active:\n ${phase64Warnings.join('\n ')}` + ); + assert.ok( + warnings.some(w => w.includes('Gap in plan numbering in milestones/v1.7-phases/65-current')), + `Expected plan numbering warning from active archive root, got:\n ${warnings.join('\n ')}` + ); + assert.ok( + warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-01-PLAN.md: missing 'wave'")) + || warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-03-PLAN.md: missing 'wave'")), + `Expected frontmatter warning from active archive plans, got:\n ${warnings.join('\n ')}` + ); + }); +}); + +describe('#3164 — validate health: milestone-archive layout', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('no W006 warnings for phases that exist in .planning/milestones/v*-phases/', () => { + setupMilestoneArchiveProject(tmpDir, { + milestone: 'v1.7', + phases: ['64-secondary-grader-fix'], + roadmapPhases: ['64'], + }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `validate health should succeed: ${result.error}`); + + const out = JSON.parse(result.output); + const w006 = (out.warnings || []).filter(w => { + const msg = typeof w === 'string' ? w : w.message; + return msg && msg.includes('Phase 64') && msg.includes('no directory'); + }); + assert.deepStrictEqual( + w006, [], + `Got spurious W006 for phase 64 in milestone-archive validate health:\n ${w006.map(w => typeof w === 'string' ? w : w.message).join('\n ')}` + ); + }); +}); + +describe('#3164 — find-phase: milestone-archive layout', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('find-phase 64 returns found:true for phase in .planning/milestones/v*-phases/', () => { + setupMilestoneArchiveProject(tmpDir, { + milestone: 'v1.7', + phases: ['64-secondary-grader-fix'], + roadmapPhases: ['64'], + }); + + const result = runGsdTools('find-phase 64', tmpDir); + assert.ok(result.success, `find-phase should succeed: ${result.error}`); + + const out = JSON.parse(result.output); + assert.strictEqual(out.found, true, `find-phase 64 should return found:true, got: ${JSON.stringify(out)}`); + }); + + test('find-phase searches milestone archives in deterministic sorted order', () => { + // Remove flat phases dir so search relies on milestone archives only. + fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true }); + + const milestonesDir = path.join(tmpDir, '.planning', 'milestones'); + const v110 = path.join(milestonesDir, 'v1.10-phases', '64-from-110'); + const v12 = path.join(milestonesDir, 'v1.2-phases', '64-from-12'); + fs.mkdirSync(v110, { recursive: true }); + fs.mkdirSync(v12, { recursive: true }); + fs.writeFileSync(path.join(v110, 'PLAN.md'), '# v1.10 plan\n'); + fs.writeFileSync(path.join(v12, 'PLAN.md'), '# v1.2 plan\n'); + + const result = runGsdTools('find-phase 64', tmpDir); + assert.ok(result.success, `find-phase should succeed: ${result.error}`); + + const out = JSON.parse(result.output); + assert.strictEqual(out.found, true, `find-phase 64 should return found:true, got: ${JSON.stringify(out)}`); + assert.strictEqual( + out.directory, + '.planning/milestones/v1.2-phases/64-from-12', + `Expected deterministic archive ordering (v1.2 before v1.10), got directory: ${out.directory}` + ); + }); + + test('find-phase not-found payload includes searched_directories', () => { + setupMilestoneArchiveProject(tmpDir, { + milestone: 'v1.7', + phases: ['64-secondary-grader-fix'], + roadmapPhases: ['64'], + }); + + const result = runGsdTools('find-phase 999', tmpDir); + assert.ok(result.success, `find-phase should succeed with found:false payload: ${result.error}`); + + const out = JSON.parse(result.output); + assert.strictEqual(out.found, false, `find-phase 999 should return found:false, got: ${JSON.stringify(out)}`); + assert.ok(Array.isArray(out.searched_directories), 'searched_directories should be an array on not-found payload'); + assert.ok( + out.searched_directories.includes('.planning/milestones/v1.7-phases'), + `searched_directories should include active archive dir, got: ${JSON.stringify(out.searched_directories)}` + ); + }); +});