Files
msd-core/get-shit-done/bin/lib/phase.cjs
Tom Boucher 6177e3a5f5 fix(4): retire cooperating-sibling for phase.*, introduce generator + I/O adapter, fix cmdPhaseComplete (#154)
* test(4): reproduce non-idempotent phase complete + unclamped percent in CJS CLI

RED regression tests for issue #4:
- T1: double invocation of cmdPhaseComplete double-increments **Completed Phases:**
  in STATE.md body (blind parseInt+1 instead of deriving from ROADMAP)
- T2: progress percent can exceed 100% when Completed Phases > Total Phases

The CJS path (bin/lib/phase.cjs:cmdPhaseComplete) has the bug; the SDK path
(phase-lifecycle.ts:phaseComplete, fixed in ~PR#3520) already derives
completed_phases from ROADMAP Complete-row count, making it idempotent.

References:
- Issue #4 (open-gsd/get-shit-done-redux)
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- /tmp/adr-3524-review-findings.md (architectural justification)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add sdk/scripts/gen-phase-lifecycle-policy.mjs generator + freshness check placeholder

Generates phase-lifecycle-policy.generated.cjs from sdk/src/query/phase-lifecycle-policy.ts.
All functions in phase-lifecycle-policy.ts are pure transforms (no I/O), directly
serializable via Function.prototype.toString(). The GSDError dependency is replaced
with a lightweight stub that throws plain Error objects — CJS callers that need
process.exit(1) behavior catch these and delegate to error().

This is the "I/O adapter pattern" from ADR-3524 Section 4 applied to pure helpers.

References:
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- /tmp/adr-3524-review-findings.md (architectural justification)
- Issue #4 (open-gsd/get-shit-done-redux)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add sdk/scripts/gen-phase.mjs generator

Generates phase.generated.cjs from sdk/src/query/phase.ts.
Only pure helpers (isCanonicalPlanFile, describeNonCanonicalPlans) are generated;
async query handlers (findPhase, phasePlanIndex) are I/O-bound and remain per-side
per ADR-3524 Section 4.

References:
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- /tmp/adr-3524-review-findings.md (architectural justification)
- Issue #4 (open-gsd/get-shit-done-redux)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add sdk/scripts/gen-phase-lifecycle.mjs + core idempotency fix logic

Generates phase-lifecycle.generated.cjs providing two pure functions that are the
root-cause fix for issue #4:

1. deriveProgressFromRoadmap(roadmapContent): counts Complete rows in ROADMAP
   progress table — makes completed_phases idempotent (derived from ground truth
   instead of blind +1). Direct transcription of the SDK's "Root cause 1 fix"
   block in phase-lifecycle.ts (~line 1644).

2. clampPercent(completed, total): percent capped at 100 — prevents >100% progress
   when Completed Phases exceeds Total Phases.

Design note: the full phase lifecycle mutations (add, insert, remove, complete) are
inherently async and I/O-bound. Per ADR-3524 Section 4 ("I/O stays per-side"), those
are NOT generated. Only the pure-computation kernel is extracted, following the
I/O adapter pattern: pure logic shared; each side (CJS sync, SDK async) supplies
its own I/O adapter.

The pure functions are defined in the generator as real JS functions and serialized
via Function.prototype.toString() — same technique as gen-project-root.mjs — rather
than embedded in template literals (which would require double-escaping all regex
backslashes).

References:
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- /tmp/adr-3524-review-findings.md (architectural justification)
- Issue #4 (open-gsd/get-shit-done-redux)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): emit phase.generated.cjs, phase-lifecycle.generated.cjs, phase-lifecycle-policy.generated.cjs

Three generated CJS artifacts from their respective generator scripts:

- phase.generated.cjs (1.7K): isCanonicalPlanFile + describeNonCanonicalPlans
  from sdk/src/query/phase.ts
- phase-lifecycle.generated.cjs (3.6K): deriveProgressFromRoadmap + clampPercent
  — the idempotency+clamp fix for issue #4
- phase-lifecycle-policy.generated.cjs (7.0K): 14 pure phase naming/directory
  helpers from sdk/src/query/phase-lifecycle-policy.ts

Run to regenerate:
  node sdk/scripts/gen-phase.mjs
  node sdk/scripts/gen-phase-lifecycle.mjs
  node sdk/scripts/gen-phase-lifecycle-policy.mjs

References:
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- Issue #4 (open-gsd/get-shit-done-redux)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(4): migrate bin/lib/phase.cjs cmdPhaseComplete to use generated helpers

Replace the blind-increment + unclamped percent bug in cmdPhaseComplete with
the idempotent ROADMAP-derived approach: read freshly-updated ROADMAP, call
deriveProgressFromRoadmap() from phase-lifecycle.generated.cjs, fall back to
existing value when ROADMAP is unavailable. clampPercent() prevents >100%.

Root cause fix for issue #4: the original parseInt(completedRaw) + 1 on every
call made phase complete non-idempotent; the missing Math.min(100, ...) clamp
allowed Progress to exceed 100%.

I/O adapter pattern (ADR-3524 §4): pure computation in generated module;
CJS supplies sync readFileSync; SDK supplies async readFile. Same logic, two adapters.

Closes: Tests in 4-phase-complete-cjs-regression.test.cjs go GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add freshness checks + npm scripts for phase generated artifacts (D4/D6)

Add check-phase-fresh.mjs, check-phase-lifecycle-fresh.mjs, and
check-phase-lifecycle-policy-fresh.mjs (same pattern as check-project-root-fresh.mjs:
import buildXCjs() from the generator, regenerate in-memory, byte-compare to committed
file, exit 1 if stale).

Add gen:phase, check:phase-fresh, gen:phase-lifecycle, check:phase-lifecycle-fresh,
gen:phase-lifecycle-policy, check:phase-lifecycle-policy-fresh to sdk/package.json.
Note: gen:phase-lifecycle / check:phase-lifecycle-fresh do not require 'npm run build'
because the generator defines pure functions directly rather than importing dist.

Update shared-module-handsync-allowlist.json: reclassify phase.cjs justification to
reflect that it now consumes phase-lifecycle.generated.cjs for cmdPhaseComplete. The
*.generated.cjs files are excluded by the lint scanner (excludes *.generated.cjs) so
no new allowlist entries are required for the generated artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(4): add freshness-check CI steps for phase, phase-lifecycle, phase-lifecycle-policy

Add three drift-check steps to .github/workflows/test.yml following the same
pattern as the existing freshness checks (ubuntu-latest + node 24 only):
  - SDK generated phase artifact drift check
  - SDK generated phase-lifecycle artifact drift check
  - SDK generated phase-lifecycle-policy artifact drift check

These guard against editors modifying the generated *.cjs files directly.
They run check-phase-fresh.mjs, check-phase-lifecycle-fresh.mjs, and
check-phase-lifecycle-policy-fresh.mjs respectively (added in D4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(4): amend ADR-3524 — phase * I/O adapter pattern for issue #4 (D8)

Append a 2026-05-23 amendment to docs/adr/3524-cjs-sdk-hard-seam.md documenting
the Phase * cooperating-sibling retirement: three new generator scripts extract
pure-computation helpers from phase.ts / phase-lifecycle.ts / phase-lifecycle-policy.ts,
cmdPhaseComplete migrates to deriveProgressFromRoadmap + clampPercent for idempotency,
freshness checks + CI steps added.

Clarifies what is NOT generated (async I/O-bound mutation handlers stay per-side per
Section 4) and notes open drift bugs #6 and #26 for traceability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add changeset fragment for cmdPhaseComplete fix

Refs #4

* fix(154): use canonical /gsd:plan-phase form in phase-lifecycle-policy.ts

Replaces the retired /gsd-plan-phase slash command reference with the
canonical colon-namespaced /gsd:plan-phase in the TS source template
string that feeds the generated CJS roadmap entry helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(154): regenerate phase-lifecycle-policy.generated.cjs after slash-namespace fix

Regenerated via node sdk/scripts/gen-phase-lifecycle-policy.mjs after
fixing /gsd-plan-phase → /gsd:plan-phase in the TS source. Generated
file now contains the canonical colon form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): cross-platform frontmatter regex anchor in 4-phase-complete-cjs-regression.test.cjs

Replaces /^---\n/ with /^---\r?\n/ so the frontmatter extraction helper
in the regression test tolerates Windows CRLF line endings (autocrlf=true
checkout leaves \r before \n, causing /^---\n/ to never match).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(154): add new generated CJS modules to INVENTORY.md and regenerate manifest

Adds three missing rows to the CLI Modules table:
  - phase-lifecycle-policy.generated.cjs
  - phase-lifecycle.generated.cjs
  - phase.generated.cjs

Bumps the headline count from 74 to 77 to match the filesystem.
Also regenerates docs/INVENTORY-MANIFEST.json via
node scripts/gen-inventory-manifest.cjs --write.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): regenerate phase-lifecycle-policy.generated.cjs with hyphen form

Root cause: commit 6cd701f4 regenerated the CJS artifact but at that point
sdk/dist/query/phase-lifecycle-policy.js already had the correct /gsd-plan-phase
(hyphen) form while sdk/src/query/phase-lifecycle-policy.ts still had /gsd:plan-phase
(colon). The generator uses Function.prototype.toString() on the compiled dist, so
the CJS picked up the wrong string from the stale TS source that was compiled into
dist at some earlier point.

Fix: correct the TS source to /gsd-plan-phase and re-run gen-phase-lifecycle-policy.mjs
so that buildPhaseRoadmapEntry in the CJS emits the hyphen form, satisfying the
bug-3584-runtime-slash-emitters.test.cjs assertion at line 179.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): restore /gsd:plan-phase canonical form in phase-lifecycle-policy

Commit 0c3a9c75 incorrectly reverted the slash-namespace fix by misreading
sdk/dist/ (a build artifact in hyphen form for non-Claude runtimes) as the
authoritative source. The canonical form for Claude-facing source is
/gsd:plan-phase (colon-namespaced).

Fix: revert TS source back to /gsd:plan-phase, rebuild dist, regenerate
phase-lifecycle-policy.generated.cjs.

Fixes bug-2543-gsd-slash-namespace test failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): update INVENTORY.md CLI Modules count to 79 after rebase onto main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): use hyphen form /gsd-plan-phase in persisted phase section template

- sdk/src/query/phase-lifecycle-policy.ts: use /gsd-plan-phase (routable
  hyphen form) in the phase scaffold template that gets persisted to
  ROADMAP.md; bug-3584 requires persisted artifacts use the hyphen form
- docs/INVENTORY.md: add missing runtime-name-policy.cjs row in CLI
  Modules table
- tests/4-phase-complete-cjs-regression.test.cjs: add maxRetries/retryDelay
  to rmSync calls to satisfy Windows parity ratchet (baseline was 95)
- Regenerate phase-lifecycle-policy.generated.cjs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:47:11 -04:00

1559 lines
65 KiB
JavaScript

/**
* Phase — Phase CRUD, query, and lifecycle operations
*
* Re-export shim note (issue #4 / ADR-3524):
* The phase lifecycle pure-computation helpers live in phase-lifecycle.generated.cjs
* (generated from sdk/src/query/phase-lifecycle.ts). cmdPhaseComplete uses
* deriveProgressFromRoadmap + clampPercent from that module to fix the
* non-idempotent Completed Phases blind-increment bug.
*
* The async mutation handlers (phaseAdd, phaseInsert, phaseRemove, phaseComplete)
* in phase-lifecycle.ts are I/O-bound and remain per-side per ADR-3524 Section 4.
* This file provides the CJS (sync) implementations of those handlers.
*/
const fs = require('fs');
const path = require('path');
const { escapeRegex, loadConfig, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error, readSubdirectories, phaseTokenMatches, ERROR_REASON } = require('./core.cjs');
const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
const { planningDir, withPlanningLock } = require('./planning-workspace.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { writeStateMd, readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, updatePerformanceMetricsSection } = require('./state.cjs');
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
// Generated pure-computation helpers for cmdPhaseComplete (issue #4 fix).
// Source: sdk/src/query/phase-lifecycle.ts. Regenerate: node sdk/scripts/gen-phase-lifecycle.mjs
const { deriveProgressFromRoadmap, clampPercent } = require('./phase-lifecycle.generated.cjs');
// #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
// Documented in agents/gsd-planner.md (write_phase_prompt step). The wider
// "looks like a plan but isn't canonical" probe below is used to surface a
// loud warning instead of silently returning zero plans.
const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
// Any .md file with PLAN anywhere in the basename — the diagnostic net for
// catching agent deviations like `01-PLAN-01-foundation.md` (#2893).
// Excludes derivative files (`-PLAN-OUTLINE.md`, `*.pre-bounce.md`, etc.) that
// the planner legitimately produces alongside canonical plans.
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
const looksLikePlanFile = (f) =>
/\.md$/i.test(f)
&& /PLAN/i.test(f)
&& !PLAN_OUTLINE_RE.test(f)
&& !PLAN_PRE_BOUNCE_RE.test(f);
/**
* Detect plan-shaped files that the canonical filter would reject. Returns
* a warning string when offenders exist, else null. Centralised so every
* read site (phase-plan-index, phases list --type plans, find-phase) emits
* the same message.
*
* @param {string[]} dirFiles — readdirSync output for one phase directory
* @param {string[]} matchedFiles — what the canonical filter accepted
* @returns {string|null}
*/
function describeNonCanonicalPlans(dirFiles, matchedFiles) {
const matched = new Set(matchedFiles);
const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
if (offenders.length === 0) return null;
return (
`Found ${offenders.length} plan-shaped file(s) in this phase that don't match the canonical ` +
`naming convention "{padded_phase}-{NN}-PLAN.md" (or bare "PLAN.md") and were skipped: ` +
offenders.map((f) => `"${f}"`).join(', ') +
`. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` +
`See agents/gsd-planner.md write_phase_prompt step for the full contract.`
);
}
function extractCanonicalPlanId(filename) {
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
const parts = base.split('-').filter(Boolean);
const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
const phaseIdx = parts.findIndex(p => tokenRe.test(p));
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
}
return base;
}
function cmdPhasesList(cwd, options, raw) {
const phasesDir = path.join(planningDir(cwd), 'phases');
const { type, phase, includeArchived } = options;
// If no phases directory, return empty
if (!fs.existsSync(phasesDir)) {
if (type) {
output({ files: [], count: 0 }, raw, '');
} else {
output({ directories: [], count: 0 }, raw, '');
}
return;
}
try {
// Get all phase directories
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
let dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
// Include archived phases if requested
if (includeArchived) {
const archived = getArchivedPhaseDirs(cwd);
for (const a of archived) {
dirs.push(`${a.name} [${a.milestone}]`);
}
}
// Sort numerically (handles integers, decimals, letter-suffix, hybrids)
dirs.sort((a, b) => comparePhaseNum(a, b));
// If filtering by phase number
if (phase) {
const normalized = normalizePhaseName(phase);
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) {
output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
return;
}
dirs = [match];
}
// If listing files of a specific type
if (type) {
const files = [];
const warnings = [];
for (const dir of dirs) {
const dirPath = path.join(phasesDir, dir);
const dirFiles = fs.readdirSync(dirPath);
let filtered;
if (type === 'plans') {
filtered = dirFiles.filter(isCanonicalPlanFile);
// #2893 — surface plan-shaped files the canonical filter rejected
// so callers (executor init, etc.) don't silently see zero plans.
const w = describeNonCanonicalPlans(dirFiles, filtered);
if (w) warnings.push(`${dir}: ${w}`);
} else if (type === 'summaries') {
filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
} else {
filtered = dirFiles;
}
files.push(...filtered.sort());
}
const result = {
files,
count: files.length,
phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null,
};
if (warnings.length) result.warning = warnings.join(' | ');
output(result, raw, files.join('\n'));
return;
}
// Default: list directories
output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
} catch (e) {
error('Failed to list phases: ' + e.message);
}
}
function cmdPhaseNextDecimal(cwd, basePhase, raw) {
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalized = normalizePhaseName(basePhase);
try {
let baseExists = false;
const decimalSet = new Set();
// Scan directory names for existing decimal phases
if (fs.existsSync(phasesDir)) {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
baseExists = dirs.some(d => phaseTokenMatches(d, normalized));
const dirPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`);
for (const dir of dirs) {
const match = dir.match(dirPattern);
if (match) decimalSet.add(parseInt(match[1], 10));
}
}
// Also scan ROADMAP.md for phase entries that may not have directories yet
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
if (fs.existsSync(roadmapPath)) {
try {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
// #3537: padding-tolerant on both sides — `0*${escapeRegex(...)}`
// tolerated extra padding but not missing.
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)\\s*:`, 'gi'
);
let pm;
while ((pm = phasePattern.exec(roadmapContent)) !== null) {
decimalSet.add(parseInt(pm[1], 10));
}
} catch { /* ROADMAP.md read failure is non-fatal */ }
}
// Build sorted list of existing decimals
const existingDecimals = Array.from(decimalSet)
.sort((a, b) => a - b)
.map(n => `${normalized}.${n}`);
// Calculate next decimal
let nextDecimal;
if (decimalSet.size === 0) {
nextDecimal = `${normalized}.1`;
} else {
nextDecimal = `${normalized}.${Math.max(...decimalSet) + 1}`;
}
output(
{
found: baseExists,
base_phase: normalized,
next: nextDecimal,
existing: existingDecimals,
},
raw,
nextDecimal
);
} catch (e) {
error('Failed to calculate next decimal phase: ' + e.message);
}
}
function getRoadmapModeForPhase(cwd, phaseNum) {
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) return null;
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
const fullContent = stripShippedMilestones(rawContent);
const escapedPhase = phaseMarkdownRegexSource(phaseNum);
const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}\\s*:`, 'i');
for (const content of [milestoneContent, fullContent]) {
const headerMatch = content.match(phaseHeader);
if (!headerMatch || headerMatch.index === undefined) continue;
const sectionStart = headerMatch.index;
const rest = content.slice(sectionStart);
const nextHeader = rest.slice(headerMatch[0].length).match(/\n#{2,4}\s+Phase\s+\S/i);
const sectionEnd = nextHeader ? sectionStart + headerMatch[0].length + nextHeader.index : content.length;
const section = content.slice(sectionStart, sectionEnd);
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
if (modeMatch) return modeMatch[1].trim().toLowerCase();
}
return null;
}
function cmdPhaseMvpMode(cwd, args, raw) {
const phaseNum = args[0];
if (!phaseNum) {
error('Usage: phase.mvp-mode <phase-number> [--cli-flag]', ERROR_REASON.USAGE);
}
const cliFlagPresent = args.includes('--cli-flag');
const roadmapMode = getRoadmapModeForPhase(cwd, phaseNum);
const config = loadConfig(cwd);
const configMvpMode = Boolean(config.mvp_mode);
let active = false;
let source = 'none';
if (cliFlagPresent) {
active = true;
source = 'cli_flag';
} else if (roadmapMode === 'mvp') {
active = true;
source = 'roadmap';
} else if (configMvpMode) {
active = true;
source = 'config';
}
output({
active,
source,
roadmap_mode: roadmapMode,
config_mvp_mode: configMvpMode,
cli_flag_present: cliFlagPresent,
}, raw);
}
function cmdFindPhase(cwd, phase, raw) {
if (!phase) {
error('phase identifier required');
}
const planBase = planningDir(cwd);
const normalized = normalizePhaseName(phase);
const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [], searched_directories: [] };
// 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 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 */ }
notFound.searched_directories = searchDirs.map((searchDir) =>
toPosixPath(path.join(path.relative(cwd, planBase), path.relative(planBase, searchDir))));
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 match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) continue;
// 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) {
const m = content.match(/<objective>\s*\n?\s*(.+)/);
return m ? m[1].trim() : null;
}
function cmdPhasePlanIndex(cwd, phase, raw) {
if (!phase) {
error('phase required for phase-plan-index');
}
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalized = normalizePhaseName(phase);
// Find phase directory
let phaseDir = null;
let phaseDirName = null;
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) {
phaseDir = path.join(phasesDir, match);
phaseDirName = match;
}
} catch {
// phases dir doesn't exist
}
if (!phaseDir) {
output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
return;
}
// Get all files in phase directory
const phaseFiles = fs.readdirSync(phaseDir);
const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort();
const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
// #2893 — surface plan-shaped files the canonical filter rejected so a
// misnamed plan never silently produces plan_count: 0 at executor init.
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles);
// Build set of plan IDs with summaries
const completedPlanIds = new Set(
summaryFiles.flatMap(s => {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
// ── Pass 1: parse each plan file ─────────────────────────────────────────
const rawPlans = [];
for (const planFile of planFiles) {
const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
const planPath = path.join(phaseDir, planFile);
const content = fs.readFileSync(planPath, 'utf-8');
const fm = extractFrontmatter(content);
// Count tasks: XML <task> tags (canonical) or ## Task N markdown (legacy)
const xmlTasks = content.match(/<task[\s>]/gi) || [];
const mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
const taskCount = xmlTasks.length || mdTasks.length;
// Parse wave as integer — use nullish handling so wave: 0 is preserved.
// parseInt returns NaN for missing/non-numeric values; fall back to null
// (meaning "no declared wave") so downstream can apply the topo default.
const parsedWave = parseInt(fm.wave, 10);
const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
// Parse depends_on — normalise to string[]
let dependsOn = [];
const fmDeps = fm['depends_on'];
if (Array.isArray(fmDeps)) {
dependsOn = fmDeps.map(String);
} else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
dependsOn = [fmDeps];
}
// Parse autonomous (default true if not specified)
let autonomous = true;
if (fm.autonomous !== undefined) {
autonomous = fm.autonomous === 'true' || fm.autonomous === true;
}
// Parse files_modified (underscore is canonical; also accept hyphenated for compat)
let filesModified = [];
const fmFiles = fm['files_modified'] || fm['files-modified'];
if (fmFiles) {
filesModified = Array.isArray(fmFiles) ? fmFiles : [fmFiles];
}
const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
rawPlans.push({
id: planId,
declaredWave,
dependsOn,
autonomous,
objective: extractObjective(content) || fm.objective || null,
filesModified,
taskCount,
hasSummary,
});
}
// ── Pass 2: topological level assignment via depends_on DAG ──────────────
// Guard: detect case-insensitive key collisions before building dependency
// maps. Two plan IDs that differ only by case would silently overwrite each
// other in planMap, routing depends_on edges to whichever plan survived last.
// This is a configuration error — fail fast with the conflicting IDs. (#3785)
//
// This guard catches case-fold collisions on full plan IDs.
// Shared-numeric-prefix collisions (e.g. '20-01-Auth' and '20-01' both
// producing canonical '20-01') are resolved by first-write-wins ordering
// from sorted planFiles — not explicitly guarded here.
// seenLower is intentionally separate from planMap — it exists only to detect
// collisions before planMap is built, so the error fires before any Map
// entry silently overwrites another.
const seenLower = new Map(); // lowercase key → original id
for (const p of rawPlans) {
// ASCII plan IDs only — toLowerCase() is correct and locale-safe here.
const lower = p.id.toLowerCase();
const existing = seenLower.get(lower);
if (existing !== undefined) {
error(`depends_on index collision in phase ${normalized}: plan IDs '${existing}' and '${p.id}' are identical when case-folded. Rename one file to avoid ambiguous dependency resolution.`);
return;
}
seenLower.set(lower, p.id);
}
// Build a map from plan ID → raw plan for fast lookup.
// Deps that reference plans outside this phase are treated as external and ignored.
// Keys are lowercased so that depends_on refs with different casing still
// resolve to the correct plan (#3785: case-insensitive identifier resolution).
const planMap = new Map(rawPlans.map(p => [p.id.toLowerCase(), p]));
// Secondary index: canonical prefix → full plan ID, so depends_on: ['03-01'] resolves
// to '03-01-auth-hardening-PLAN.md'-derived ID '03-01-auth-hardening' (k015).
// Keyed lowercase for the same case-insensitive reason (#3785).
const canonicalToId = new Map(rawPlans.map(p => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
// KNOWN GAP: CJS resolver has only two tiers (planMap + canonicalToId);
// the SDK has an additional shortFormToId for same-phase short-form refs
// like '01' or '01A'. Adding the third tier here is tracked as a parity
// gap and is out of scope for #3785 / PR #3798.
// Kahn's algorithm — compute in-degree and adjacency for in-phase deps only.
const level = new Map();
const inDeg = new Map();
const adj = new Map();
for (const p of rawPlans) {
if (!inDeg.has(p.id)) inDeg.set(p.id, 0);
if (!adj.has(p.id)) adj.set(p.id, []);
for (const dep of p.dependsOn) {
// Accept both full-stem ('03-01-auth-hardening') and canonical-prefix ('03-01') forms.
// All lookups are lowercased so mixed-case depends_on refs resolve correctly (#3785).
const depLower = dep.toLowerCase();
const resolvedDep = planMap.has(depLower) ? planMap.get(depLower).id : canonicalToId.get(depLower);
if (!resolvedDep) continue; // external dep — ignore
if (!adj.has(resolvedDep)) adj.set(resolvedDep, []);
adj.get(resolvedDep).push(p.id);
inDeg.set(p.id, (inDeg.get(p.id) ?? 0) + 1);
}
}
// Start with nodes that have no in-phase dependencies.
const queue = [];
for (const p of rawPlans) {
if ((inDeg.get(p.id) ?? 0) === 0) {
queue.push(p.id);
level.set(p.id, 0);
}
}
let visited = 0;
while (queue.length > 0) {
const cur = queue.shift();
visited++;
const curLevel = level.get(cur);
for (const dep of (adj.get(cur) ?? [])) {
const newLevel = curLevel + 1;
if (newLevel > (level.get(dep) ?? -1)) {
level.set(dep, newLevel);
}
inDeg.set(dep, inDeg.get(dep) - 1);
if (inDeg.get(dep) === 0) {
queue.push(dep);
}
}
}
// Cycle detection — any node not visited has a cycle.
if (visited < rawPlans.length) {
const cycleNodes = rawPlans.filter(p => !level.has(p.id)).map(p => p.id);
error(`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`);
return;
}
// ── Pass 3: determine lowest bucket key and build output ─────────────────
// If any plan has declared wave: 0, the lowest level maps to "0"; otherwise "1".
const anyWaveZero = rawPlans.some(p => p.declaredWave === 0);
const levelOffset = anyWaveZero ? 0 : 1;
const plans = [];
const waves = {};
const incomplete = [];
let hasCheckpoints = false;
const warnings = [];
for (const raw of rawPlans) {
if (!raw.autonomous) {
hasCheckpoints = true;
}
if (!raw.hasSummary) {
incomplete.push(raw.id);
}
// Computed wave = topological level + offset (so lowest level → 0 or 1).
const computedWave = (level.get(raw.id) ?? 0) + levelOffset;
// The effective wave used for bucketing is always the computed topo level.
// If the plan declared a wave that disagrees, emit a non-fatal warning.
const effectiveWave = computedWave;
if (raw.declaredWave !== null && raw.declaredWave !== computedWave) {
warnings.push(
`Plan ${raw.id}: declared wave: ${raw.declaredWave} but depends_on DAG places it in wave ${computedWave}`,
);
}
const plan = {
id: raw.id,
wave: effectiveWave,
// Resolve each user-typed dep to its canonical plan ID (preserving on-disk casing)
// so the output never reflects the user's case typo. Unresolved deps (external
// phase refs) are kept as-is since planMap only contains plans in this phase.
depends_on: raw.dependsOn.map(dep => {
const lower = String(dep).toLowerCase();
return planMap.has(lower) ? planMap.get(lower).id : dep;
}),
autonomous: raw.autonomous,
objective: raw.objective,
files_modified: raw.filesModified,
task_count: raw.taskCount,
has_summary: raw.hasSummary,
};
plans.push(plan);
const waveKey = String(effectiveWave);
if (!waves[waveKey]) {
waves[waveKey] = [];
}
waves[waveKey].push(raw.id);
}
const result = {
phase: normalized,
plans,
waves,
incomplete,
has_checkpoints: hasCheckpoints,
};
if (planNamingWarning) result.warning = planNamingWarning;
if (warnings.length > 0) result.warnings = warnings;
output(result, raw);
}
function cmdPhaseAdd(cwd, description, raw, customId) {
if (!description) {
error('description required for phase add');
}
const config = loadConfig(cwd);
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) {
error('ROADMAP.md not found');
}
const slug = generateSlugInternal(description);
// Wrap entire read-modify-write in lock to prevent concurrent corruption
const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
// Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
const projectCode = config.project_code || '';
const prefix = projectCode ? `${projectCode}-` : '';
let _newPhaseId;
let _dirName;
if (customId || config.phase_naming === 'custom') {
// Custom phase naming: use provided ID or generate from description
_newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
if (!_newPhaseId) error('--id required when phase_naming is "custom"');
_dirName = `${prefix}${_newPhaseId}-${slug}`;
} else {
// Sequential mode: find highest integer phase number from two sources:
// 1. ROADMAP.md (current milestone only)
// 2. .planning/phases/ on disk (orphan directories not tracked in roadmap)
// Skip 999.x backlog phases — they live outside the active sequence
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let maxPhase = 0;
let m;
while ((m = phasePattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
if (num === 999) continue; // backlog phases use 999.x numbering
if (num > maxPhase) maxPhase = num;
}
// Also scan .planning/phases/ for orphan directories not tracked in ROADMAP.
// Directory names follow: [PREFIX-]NN-slug (e.g. 03-api or CK-05-old-feature).
// Strip the optional project_code prefix before extracting the leading integer.
const phasesOnDisk = path.join(planningDir(cwd), 'phases');
if (fs.existsSync(phasesOnDisk)) {
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
for (const entry of fs.readdirSync(phasesOnDisk)) {
const match = entry.match(dirNumPattern);
if (!match) continue;
const num = parseInt(match[1], 10);
if (num === 999) continue; // skip backlog orphans
if (num > maxPhase) maxPhase = num;
}
}
_newPhaseId = maxPhase + 1;
const paddedNum = String(_newPhaseId).padStart(2, '0');
_dirName = `${prefix}${paddedNum}-${slug}`;
}
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
// Create directory with .gitkeep so git tracks empty folders
platformEnsureDir(dirPath);
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
// Build phase entry
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd))} ${_newPhaseId} to break down)\n`;
// Find insertion point: before last "---" or at end
let updatedContent;
const lastSeparator = rawContent.lastIndexOf('\n---');
if (lastSeparator > 0) {
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
} else {
updatedContent = rawContent + phaseEntry;
}
platformWriteSync(roadmapPath, updatedContent);
return { newPhaseId: _newPhaseId, dirName: _dirName };
});
const result = {
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
name: description,
slug,
directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)),
naming_mode: config.phase_naming,
};
output(result, raw, result.padded);
}
function cmdPhaseAddBatch(cwd, descriptions, raw) {
if (!Array.isArray(descriptions) || descriptions.length === 0) {
error('descriptions array required for phase add-batch');
}
const config = loadConfig(cwd);
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) { error('ROADMAP.md not found'); }
const projectCode = config.project_code || '';
const prefix = projectCode ? `${projectCode}-` : '';
const results = withPlanningLock(cwd, () => {
let rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
let maxPhase = 0;
if (config.phase_naming !== 'custom') {
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let m;
while ((m = phasePattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
if (num === 999) continue;
if (num > maxPhase) maxPhase = num;
}
const phasesOnDisk = path.join(planningDir(cwd), 'phases');
if (fs.existsSync(phasesOnDisk)) {
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
for (const entry of fs.readdirSync(phasesOnDisk)) {
const match = entry.match(dirNumPattern);
if (!match) continue;
const num = parseInt(match[1], 10);
if (num === 999) continue;
if (num > maxPhase) maxPhase = num;
}
}
}
const added = [];
for (const description of descriptions) {
const slug = generateSlugInternal(description);
let newPhaseId, dirName;
if (config.phase_naming === 'custom') {
newPhaseId = slug.toUpperCase().replace(/-/g, '-');
dirName = `${prefix}${newPhaseId}-${slug}`;
} else {
maxPhase += 1;
newPhaseId = maxPhase;
dirName = `${prefix}${String(newPhaseId).padStart(2, '0')}-${slug}`;
}
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
platformEnsureDir(dirPath);
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd))} ${newPhaseId} to break down)\n`;
const lastSeparator = rawContent.lastIndexOf('\n---');
rawContent = lastSeparator > 0
? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
: rawContent + phaseEntry;
added.push({
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
name: description,
slug,
directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)),
naming_mode: config.phase_naming,
});
}
platformWriteSync(roadmapPath, rawContent);
return added;
});
output({ phases: results, count: results.length }, raw);
}
function cmdPhaseInsert(cwd, afterPhase, description, raw) {
if (!afterPhase || !description) {
error('after-phase and description required for phase insert');
}
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) {
error('ROADMAP.md not found');
}
const slug = generateSlugInternal(description);
// Wrap entire read-modify-write in lock to prevent concurrent corruption
const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
// Normalize input then route through canonical padding-tolerant fragment
// (#3537). The prior hand-rolled `0*${unpadded}` worked for the integer
// base but duplicated logic — funnel it through the shared helper.
const normalizedAfter = normalizePhaseName(afterPhase);
const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter);
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:`, 'i');
const headingMatch = targetPattern.test(content);
// #3815: also recognise the checked-bullet phase format used by projects
// that list phases as `- [ ] **Phase N: name**` or `- [ ] Phase N: name`
// (both bold and plain variants). Mirrors phaseRemove / phaseComplete.
//
// Bullet-style only activates when there are NO heading-style phases in the
// milestone content. A bullet entry in a hybrid (headings + bullets) ROADMAP
// means the detail section is missing — that is the #3098 case and must keep
// producing the "missing a detail section" error.
const bulletPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`,
'i',
);
const anyHeadingPattern = /#{2,4}\s*Phase\s+\d/i;
const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
const isBulletStyle = !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
if (!headingMatch && !isBulletStyle) {
// Bug #3098 parity: when the ROADMAP uses heading-style phases and only
// the summary checklist exists for this phase (no `### Phase N:` detail
// section), point the user at the missing detail section.
const checklistPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`,
'i',
);
if (checklistPattern.test(content)) {
error(`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`);
}
error(`Phase ${afterPhase} not found in ROADMAP.md`);
}
// Calculate next decimal by scanning both directories AND ROADMAP.md entries
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalizedBase = normalizePhaseName(afterPhase);
const decimalSet = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`);
for (const dir of dirs) {
const dm = dir.match(decimalPattern);
if (dm) decimalSet.add(parseInt(dm[1], 10));
}
} catch { /* intentionally empty */ }
// Also scan ROADMAP.md content (already loaded) for decimal entries.
// #3537: padding-tolerant fragment so un-padded `Phase 2.7:` is found
// when caller passes the padded base `02`.
const rmPhasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)\\s*:`, 'gi'
);
let rmMatch;
while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
decimalSet.add(parseInt(rmMatch[1], 10));
}
const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
// Optional project code prefix
const insertConfig = loadConfig(cwd);
const projectCode = insertConfig.project_code || '';
const pfx = projectCode ? `${projectCode}-` : '';
const _dirName = `${pfx}${_decimalPhase}-${slug}`;
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
// Create directory with .gitkeep so git tracks empty folders
platformEnsureDir(dirPath);
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
let updatedContent;
if (isBulletStyle) {
// #3815: Insert in checked-bullet format, mirroring the style of the
// surrounding entries. Detect whether the matched bullet uses bold
// (`**Phase N: …**`) to preserve file-internal format consistency.
const boldBulletPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}:`,
'i',
);
const useBold = boldBulletPattern.test(content);
const phaseLabel = useBold
? `**Phase ${_decimalPhase}: ${description}**`
: `Phase ${_decimalPhase}: ${description}`;
const bulletEntry = `\n- [ ] ${phaseLabel}`;
// Locate the target bullet line in the raw content
const targetBulletPattern = new RegExp(
`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s][^\\n]*)`,
'i',
);
const bulletMatchResult = rawContent.match(targetBulletPattern);
if (!bulletMatchResult) {
error(`Could not find Phase ${afterPhase} bullet line`);
}
const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
const afterBullet = rawContent.slice(bulletLineEnd);
const nextBulletMatch = afterBullet.match(/\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
let insertIdx;
if (nextBulletMatch) {
insertIdx = bulletLineEnd + nextBulletMatch.index;
} else {
insertIdx = bulletLineEnd;
}
updatedContent = rawContent.slice(0, insertIdx) + bulletEntry + rawContent.slice(insertIdx);
} else {
// Heading-style insert (original path)
// Build phase entry
const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd))} ${_decimalPhase} to break down)\n`;
// Insert after the target phase section
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
const headerMatch = rawContent.match(headerPattern);
if (!headerMatch) {
error(`Could not find Phase ${afterPhase} header`);
}
const headerIdx = rawContent.indexOf(headerMatch[0]);
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
// recognised as section boundaries.
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
let insertIdx;
if (nextPhaseMatch) {
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
} else {
insertIdx = rawContent.length;
}
updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
}
platformWriteSync(roadmapPath, updatedContent);
return { decimalPhase: _decimalPhase, dirName: _dirName };
});
const result = {
phase_number: decimalPhase,
after_phase: afterPhase,
name: description,
slug,
directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)),
};
output(result, raw, decimalPhase);
}
/**
* Renumber sibling decimal phases after a decimal phase is removed.
* e.g. removing 06.2 → 06.3 becomes 06.2, 06.4 becomes 06.3, etc.
* Returns { renamedDirs, renamedFiles }.
*/
function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
const renamedDirs = [], renamedFiles = [];
// Capture the zero-padded prefix (e.g. "06" from "06.3-slug") so the renamed
// directory preserves the original padding format.
const decPattern = new RegExp(`^(0*${baseInt})\\.(\\d+)-(.+)$`);
const dirs = readSubdirectories(phasesDir, true);
const toRename = dirs
.map(dir => { const m = dir.match(decPattern); return m ? { dir, prefix: m[1], oldDecimal: parseInt(m[2], 10), slug: m[3] } : null; })
.filter(item => item && item.oldDecimal > removedDecimal)
.sort((a, b) => b.oldDecimal - a.oldDecimal); // descending to avoid conflicts
for (const item of toRename) {
const newDecimal = item.oldDecimal - 1;
const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
const newPhaseId = `${baseInt}.${newDecimal}`;
const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
if (f.includes(oldPhaseId)) {
const newFileName = f.replace(oldPhaseId, newPhaseId);
fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName));
renamedFiles.push({ from: f, to: newFileName });
}
}
}
return { renamedDirs, renamedFiles };
}
/**
* Renumber all integer phases after removedInt.
* e.g. removing phase 5 → phase 6 becomes 5, phase 7 becomes 6, etc.
* Returns { renamedDirs, renamedFiles }.
*/
function renameIntegerPhases(phasesDir, removedInt) {
const renamedDirs = [], renamedFiles = [];
const dirs = readSubdirectories(phasesDir, true);
const toRename = dirs
.map(dir => {
const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
if (!m) return null;
const dirInt = parseInt(m[1], 10);
return (dirInt > removedInt && dirInt < 999) ? { dir, oldInt: dirInt, letter: m[2] ? m[2].toUpperCase() : '', decimal: m[3] ? parseInt(m[3], 10) : null, slug: m[4] } : null;
})
.filter(Boolean)
.sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
for (const item of toRename) {
const newInt = item.oldInt - 1;
const newPadded = String(newInt).padStart(2, '0');
const oldPadded = String(item.oldInt).padStart(2, '0');
const letterSuffix = item.letter || '';
const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
const newDirName = `${newPrefix}-${item.slug}`;
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
if (f.startsWith(oldPrefix)) {
const newFileName = newPrefix + f.slice(oldPrefix.length);
fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName));
renamedFiles.push({ from: f, to: newFileName });
}
}
}
return { renamedDirs, renamedFiles };
}
function decrementRoadmapPhaseNumber(raw, removedInt) {
const num = parseInt(raw, 10);
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
return String(num - 1);
}
function decrementRoadmapPhaseToken(raw, removedInt) {
const match = String(raw).match(/^(\d+)(\.\d+)?$/);
if (!match) return raw;
const num = parseInt(match[1], 10);
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
return `${num - 1}${match[2] || ''}`;
}
function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
const num = parseInt(raw, 10);
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
return String(num - 1).padStart(raw.length, '0');
}
/**
* Remove a phase section from ROADMAP.md and renumber all subsequent integer phases.
*/
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
// Wrap entire read-modify-write in lock to prevent concurrent corruption
withPlanningLock(cwd, () => {
let content = fs.readFileSync(roadmapPath, 'utf-8');
const escaped = escapeRegex(targetPhase);
// #3601: the end-of-section lookahead is depth-aware. It captures the
// hash count of the header being removed and stops only at a subsequent
// header of the SAME depth, whether integer or decimal. This preserves
// two existing contracts:
//
// (#3601 case) Remove `### Phase 2:` and stop at `### Phase 2.1:` —
// `Phase 2.1` is a peer-level decimal phase (depth 3) and must be
// preserved.
//
// (#3355 case) Remove `### Phase 27:` and continue past
// `#### Phase 27.1:` (depth 4 — a child of Phase 27) until the next
// depth-3 header. The child decimal is part of the integer phase
// being removed.
//
// The `(?!#)` negative lookahead after the backreference prevents the
// depth-3 match from being satisfied by a depth-4+ header that starts
// with the same three hashes.
content = content.replace(new RegExp(`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, 'i'), '');
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
if (!isDecimal) {
content = content.replace(
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi,
(_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`
);
content = content.replace(
/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi,
(_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`
);
content = content.replace(
/(\|\s*)(\d+)(\.\s)/g,
(_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`
);
// #3602: extend the suffix lookahead so slugged plan filenames like
// `07-01-cherry-pick-foundation-PLAN.md` match too. The previous
// pattern only allowed a compact `-(PLAN|SUMMARY).md` immediately
// after the plan number (or no suffix at all); a slug between the
// number and the `-PLAN.md` / `-SUMMARY.md` suffix made the
// lookahead fail and left the stale `07-01-` prefix in ROADMAP
// text while the on-disk file was already renamed to `06-01-…`.
// The slug segment `(?:-[A-Za-z][A-Za-z0-9-]*)*` allows any number
// of kebab-case tokens before the canonical PLAN/SUMMARY suffix.
content = content.replace(
/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)*-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g,
(_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`
);
content = content.replace(
/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
(_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`
);
content = content.replace(
/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
(_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`
);
}
platformWriteSync(roadmapPath, content);
});
}
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
if (!targetPhase) error('phase number required for phase remove');
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const phasesDir = path.join(planningDir(cwd), 'phases');
if (!fs.existsSync(roadmapPath)) error('ROADMAP.md not found');
const normalized = normalizePhaseName(targetPhase);
const isDecimal = targetPhase.includes('.');
const force = options.force || false;
// Find target directory
const targetDir = readSubdirectories(phasesDir, true)
.find(d => phaseTokenMatches(d, normalized)) || null;
// Guard against removing executed work
if (targetDir && !force) {
const files = fs.readdirSync(path.join(phasesDir, targetDir));
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
if (summaries.length > 0) {
error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
}
}
if (targetDir) fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
// Renumber subsequent phases on disk
let renamedDirs = [], renamedFiles = [];
try {
const renamed = isDecimal
? renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10))
: renameIntegerPhases(phasesDir, parseInt(normalized, 10));
renamedDirs = renamed.renamedDirs;
renamedFiles = renamed.renamedFiles;
} catch { /* intentionally empty */ }
// Update ROADMAP.md
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
// Update STATE.md phase count atomically (#P4.4)
const statePath = path.join(planningDir(cwd), 'STATE.md');
if (fs.existsSync(statePath)) {
readModifyWriteStateMd(statePath, (stateContent) => {
const totalRaw = stateExtractField(stateContent, 'Total Phases');
if (totalRaw) {
stateContent = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) || stateContent;
}
const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
if (ofMatch) {
stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
}
return stateContent;
}, cwd);
}
output({
removed: targetPhase,
directory_deleted: targetDir,
renamed_directories: renamedDirs,
renamed_files: renamedFiles,
roadmap_updated: true,
state_updated: fs.existsSync(statePath),
}, raw);
}
function cmdPhaseComplete(cwd, phaseNum, raw) {
if (!phaseNum) {
error('phase number required for phase complete');
}
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const statePath = path.join(planningDir(cwd), 'STATE.md');
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalized = normalizePhaseName(phaseNum);
const today = new Date().toISOString().split('T')[0];
// Verify phase info
const phaseInfo = findPhaseInternal(cwd, phaseNum);
if (!phaseInfo) {
error(`Phase ${phaseNum} not found`);
}
const planCount = phaseInfo.plans.length;
const summaryCount = phaseInfo.summaries.length;
let requirementsUpdated = false;
// Check for unresolved verification debt (non-blocking warnings)
const warnings = [];
try {
const phaseFullDir = path.join(cwd, phaseInfo.directory);
const phaseFiles = fs.readdirSync(phaseFullDir);
for (const file of phaseFiles.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
const content = fs.readFileSync(path.join(phaseFullDir, file), 'utf-8');
if (/result: pending/.test(content)) warnings.push(`${file}: has pending tests`);
if (/result: blocked/.test(content)) warnings.push(`${file}: has blocked tests`);
if (/status: partial/.test(content)) warnings.push(`${file}: testing incomplete (partial)`);
if (/status: diagnosed/.test(content)) warnings.push(`${file}: has diagnosed gaps`);
}
for (const file of phaseFiles.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
const content = fs.readFileSync(path.join(phaseFullDir, file), 'utf-8');
if (/status: human_needed/.test(content)) warnings.push(`${file}: needs human verification`);
if (/status: gaps_found/.test(content)) warnings.push(`${file}: has unresolved gaps`);
}
} catch {}
// Update ROADMAP.md and REQUIREMENTS.md atomically under lock
if (fs.existsSync(roadmapPath)) {
withPlanningLock(cwd, () => {
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
// #3537: padding-tolerant fragment so the caller-resolved padded id
// matches un-padded ROADMAP prose.
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
// Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
const tableRowPattern = new RegExp(
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
'im'
);
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
const cells = fullRow.split('|').slice(1, -1);
if (cells.length === 5) {
// 5-col: Phase | Milestone | Plans | Status | Completed
cells[2] = ` ${summaryCount}/${planCount} `;
cells[3] = ' Complete ';
cells[4] = ` ${today} `;
} else if (cells.length === 4) {
// 4-col: Phase | Plans | Status | Completed
cells[1] = ` ${summaryCount}/${planCount} `;
cells[2] = ' Complete ';
cells[3] = ` ${today} `;
}
return '|' + cells.join('|') + '|';
});
// Update plan count in phase section.
// Use direct .replace() rather than replaceInCurrentMilestone() so this
// works when the current milestone section is itself inside a <details>
// block (the standard /gsd:new-project layout). replaceInCurrentMilestone
// scopes to content after the last </details>, which misses content inside
// the current milestone's own <details> wrapper (#2005).
// The phase-scoped heading pattern is specific enough to avoid matching
// archived phases (which belong to different milestones).
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i'
);
roadmapContent = roadmapContent.replace(
planCountPattern,
`$1${summaryCount}/${planCount} plans complete`
);
// Mark completed plan checkboxes (safety net for missed per-plan updates)
// Handles both plain IDs ("- [ ] 01-01-PLAN.md") and bold-wrapped IDs ("- [ ] **01-01**")
for (const summaryFile of phaseInfo.summaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
'i'
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
platformWriteSync(roadmapPath, roadmapContent);
// Update REQUIREMENTS.md traceability for this phase's requirements
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
if (fs.existsSync(reqPath)) {
// Extract the current phase section from roadmap (scoped to avoid cross-phase matching).
// #3537: padding-tolerant fragment so an un-padded `Phase 2.7:` heading
// is found when caller resolved to padded `02.7`.
const phaseEsc = phaseMarkdownRegexSource(phaseNum);
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
const phaseSectionMatch = currentMilestoneRoadmap.match(
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
);
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
// Accept all bold/colon variants (#2769) — the previous pattern only
// matched **Requirements:** (colon inside bold) and silently skipped
// **Requirements**: (colon outside), preventing the matching REQ-IDs
// from being ticked off in REQUIREMENTS.md on phase completion.
const reqMatch = sectionText.match(/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i);
let reqContent = fs.readFileSync(reqPath, 'utf-8');
if (reqMatch) {
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
for (const reqId of reqIds) {
const reqEscaped = escapeRegex(reqId);
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
reqContent = reqContent.replace(
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
'$1x$2'
);
// Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
reqContent = reqContent.replace(
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
'$1 Complete $2'
);
}
}
// Scan body for all **REQ-ID** patterns, warn about any missing from the Traceability table.
// Always runs regardless of whether the roadmap has a Requirements: line.
const bodyReqIds = [];
const bodyReqPattern = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
let bodyMatch;
while ((bodyMatch = bodyReqPattern.exec(reqContent)) !== null) {
const id = bodyMatch[1];
if (!bodyReqIds.includes(id)) bodyReqIds.push(id);
}
// Collect REQ-IDs present in the Traceability section only, to avoid
// picking up IDs from other tables in the document.
const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
const traceabilitySection = traceabilityHeadingMatch
? reqContent.slice(traceabilityHeadingMatch.index)
: '';
const tableReqIds = new Set();
const tableRowPattern = /^\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/gm;
let tableMatch;
while ((tableMatch = tableRowPattern.exec(traceabilitySection)) !== null) {
tableReqIds.add(tableMatch[1]);
}
const unregistered = bodyReqIds.filter(id => !tableReqIds.has(id));
if (unregistered.length > 0) {
warnings.push(
`REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`
);
}
platformWriteSync(reqPath, reqContent);
requirementsUpdated = true;
}
});
}
// Find next phase — check both filesystem AND roadmap
// Phases may be defined in ROADMAP.md but not yet scaffolded to disk,
// so a filesystem-only scan would incorrectly report is_last_phase:true
let nextPhaseNum = null;
let nextPhaseName = null;
let isLastPhase = true;
try {
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
.filter(isDirInMilestone)
.sort((a, b) => comparePhaseNum(a, b));
// Find the next phase directory after current
// Skip backlog phases (999.x) — they are parked ideas, not sequential work (#2129)
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
if (dm) {
if (/^999(?:\.|$)/.test(dm[1])) continue;
if (comparePhaseNum(dm[1], phaseNum) > 0) {
nextPhaseNum = dm[1];
nextPhaseName = dm[2] || null;
isLastPhase = false;
break;
}
}
}
} catch { /* intentionally empty */ }
// Fallback: if filesystem found no next phase, check ROADMAP.md
// for phases that are defined but not yet planned (no directory on disk)
if (isLastPhase && fs.existsSync(roadmapPath)) {
try {
const roadmapForPhases = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
let pm;
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
if (comparePhaseNum(pm[1], phaseNum) > 0) {
nextPhaseNum = pm[1];
nextPhaseName = pm[2].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-');
isLastPhase = false;
break;
}
}
} catch { /* intentionally empty */ }
}
// Update STATE.md atomically — hold lock across read-modify-write (#P4.4).
// Previously read outside the lock; a crash between the ROADMAP update
// (locked above) and this write left ROADMAP/STATE inconsistent.
if (fs.existsSync(statePath)) {
readModifyWriteStateMd(statePath, (stateContent) => {
// Update Current Phase — preserve "X of Y (Name)" compound format
const phaseValue = nextPhaseNum || phaseNum;
const existingPhaseField = stateExtractField(stateContent, 'Current Phase')
|| stateExtractField(stateContent, 'Phase');
let newPhaseValue = String(phaseValue);
if (existingPhaseField) {
const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
if (totalMatch) {
const total = totalMatch[1];
const nameStr = nextPhaseName ? ` (${nextPhaseName.replace(/-/g, ' ')})` : (nameMatch ? ` (${nameMatch[1]})` : '');
newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
}
}
stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase', 'Phase', newPhaseValue);
// Update Current Phase Name
if (nextPhaseName) {
stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase Name', null, nextPhaseName.replace(/-/g, ' '));
}
// Update Status
stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null,
isLastPhase ? 'Milestone complete' : 'Ready to plan');
// Update Current Plan
stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Plan', 'Plan', 'Not started');
// Update Last Activity
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
// Update Last Activity Description
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
`Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`);
// Update Completed Phases counter — derive from ROADMAP instead of blind +1.
// Fix for issue #4: the original code did parseInt(completedRaw, 10) + 1 on every
// call, making phase complete non-idempotent (double-call = double-increment).
// Now we read the freshly-updated ROADMAP to count Complete rows, then use
// deriveProgressFromRoadmap() from phase-lifecycle.generated.cjs (generated from
// sdk/src/query/phase-lifecycle.ts "Root cause 1 fix" block).
// References: issue #4, ADR-3524, gen-phase-lifecycle.mjs.
const completedRaw = stateExtractField(stateContent, 'Completed Phases');
if (completedRaw !== null) {
// Derive from ROADMAP if available (idempotent); fall back to existing value.
let newCompleted = parseInt(completedRaw, 10);
let derivedTotalPhases = null;
if (fs.existsSync(roadmapPath)) {
try {
const freshRoadmap = fs.readFileSync(roadmapPath, 'utf-8');
const derived = deriveProgressFromRoadmap(freshRoadmap);
if (derived.completedPhases !== null) newCompleted = derived.completedPhases;
if (derived.totalPhases !== null) derivedTotalPhases = derived.totalPhases;
} catch { /* fall through to existing value */ }
}
stateContent = stateReplaceField(stateContent, 'Completed Phases', String(newCompleted)) || stateContent;
// Recalculate percent — use clampPercent to prevent >100% (#4 unclamped bug).
const totalRaw = stateExtractField(stateContent, 'Total Phases');
const totalPhases = derivedTotalPhases
|| (totalRaw ? parseInt(totalRaw, 10) : null);
if (totalPhases && totalPhases > 0) {
const newPercent = clampPercent(newCompleted, totalPhases);
stateContent = stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
stateContent = stateContent.replace(
/(percent:\s*)\d+/,
`$1${newPercent}`
);
}
}
// Gate 4: Update Performance Metrics section (#1627)
stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
return stateContent;
}, cwd);
}
// Auto-prune STATE.md on phase boundary when configured (#2087)
let autoPruned = false;
try {
const configPath = path.join(planningDir(cwd), 'config.json');
if (fs.existsSync(configPath)) {
const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const autoPruneEnabled = rawConfig.workflow && rawConfig.workflow.auto_prune_state === true;
if (autoPruneEnabled && fs.existsSync(statePath)) {
const { cmdStatePrune } = require('./state.cjs');
cmdStatePrune(cwd, { keepRecent: '3', dryRun: false, silent: true }, true);
autoPruned = true;
}
}
} catch { /* intentionally empty — auto-prune is best-effort */ }
const result = {
completed_phase: phaseNum,
phase_name: phaseInfo.phase_name,
plans_executed: `${summaryCount}/${planCount}`,
next_phase: nextPhaseNum,
next_phase_name: nextPhaseName,
is_last_phase: isLastPhase,
date: today,
roadmap_updated: fs.existsSync(roadmapPath),
state_updated: fs.existsSync(statePath),
requirements_updated: requirementsUpdated,
auto_pruned: autoPruned,
warnings,
has_warnings: warnings.length > 0,
};
output(result, raw);
}
module.exports = {
cmdPhasesList,
cmdPhaseNextDecimal,
cmdFindPhase,
cmdPhasePlanIndex,
cmdPhaseAdd,
cmdPhaseAddBatch,
cmdPhaseMvpMode,
cmdPhaseInsert,
cmdPhaseRemove,
cmdPhaseComplete,
};