Files
msd-core/src/roadmap.cts
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

1777 lines
92 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Roadmap — Roadmap parsing and update operations
*
* ADR-457 build-at-publish: the hand-written bin/lib/roadmap.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
import { realClock } from './clock.cjs';
import { escapeRegex } from './pattern.cjs';
import { splitLines, detectEol, joinLines } from './text-lines.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output, error, formatDiagnosticToken, declineNoOp } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase, bracketQualifiedKey, foldBracketId } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserModule = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone, listMilestoneHeadings, scanMilestonePhaseIds, collectTablePhaseRows } = roadmapParserModule;
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import { updateTableCell } from './markdown-table.cjs';
import { clampPercent } from './phase-lifecycle.cjs';
import { platformWriteSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningPaths, withPlanningLock, findContextMdIn, resolvePhaseIdConvention } = planningWorkspace;
// #3641: milestone-scope's convention resolution reads the project config
// (no cycle — config-loader does not import this module).
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderForScope = require('./config-loader.cjs');
const { loadConfig: loadConfigForScope } = configLoaderForScope;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import scanPhasePlans = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtils = require('./core-utils.cjs');
const { countMatchedSummaries, findUnsummarizedPlans } = coreUtils;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter, parseMustHavesBlock } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import verificationMod = require('./verification.cjs');
const { isPhaseComplete } = verificationMod;
// #4906 Phase 2 (#4917/ADR-4910): the PlanningDoc parse -> mutate -> serialize
// seam, mirroring phase.cts's already-migrated `writePlansField` site.
import { parsePlanningDoc, findField, readNode, setFieldValue, serialize } from './planning-document.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface PhasePlansAndSummaries {
planCount: number;
summaryCount: number;
hasContext: boolean;
hasResearch: boolean;
/**
* #3885 (ADR-3473 §8.5): null when the phase directory's readdirSync
* succeeded OR was genuinely absent (ENOENT — a real "not built yet"
* answer, not an error). A message naming the phase directory when
* readdirSync failed for any other reason (EACCES/EIO/...), so an
* unreadable directory is never silently reported the same as one that was
* successfully read and genuinely has no CONTEXT.md.
*
* #4014 (epic #3473 B4): kept — `AnalyzePhase.context_read_error` (the
* shipped, tested `roadmap analyze` JSON field this feeds) is an existing
* consumer, so this field stays additive rather than being retired. `scope`
* below is the new, typed sibling signal; this field is now derived from
* it rather than owning its own readdirSync.
*/
contextReadError: string | null;
/** #4014 (epic #3473 B4): the `SCOPE` this phase dir's listing resolved to
* — `SCOPE.UNREADABLE` distinguishes a real read failure from a
* genuinely empty/absent phase dir (`SCOPE.COMPLETE`), which
* `contextReadError`/`hasContext` alone cannot. */
scope: Scope;
}
interface PhaseSearchResult {
found: boolean;
phase_number: string;
phase_name: string;
goal?: string | null;
mode?: string | null;
success_criteria?: string[];
section?: string;
error?: string;
message?: string;
}
interface TruthValue {
count: number;
text: string;
}
// ─── coerceTruthToString ──────────────────────────────────────────────────────
/**
* Coerce an arbitrary YAML scalar/object into a string for cross-cutting
* truth aggregation. Handles:
* - strings (passthrough)
* - numbers / booleans (String() coercion — issue #2770: bare YAML ints
* like `- 3` must be surfaced, not silently skipped)
* - kv-shaped objects from parseMustHavesBlock continuation kv (issue
* #2757) — extract the first meaningful string field
*
* Returns the empty string when no usable text can be derived; callers should
* skip empty results.
*/
function coerceTruthToString(t: unknown): string {
if (t === null || t === undefined) return '';
if (typeof t === 'string') return t;
if (typeof t === 'number' || typeof t === 'boolean' || typeof t === 'bigint') {
return String(t);
}
if (typeof t === 'object') {
// Prefer common title-bearing keys produced by parseMustHavesBlock. `statement` is the canonical
// truth/prohibition payload field — and the carrier of #1154's object-form backstop truth
// `{ statement, verification: backstop }`, so it leads (a non-inferable truth must be coerced by
// its statement, never dropped — the Hyrum backward-compat guard for the new marker).
for (const k of ['statement', 'title', 'text', 'name', 'rule', 'path', 'provides']) {
const v = (t as Record<string, unknown>)[k];
if (typeof v === 'string' && v.trim()) return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
}
}
return '';
}
// ─── countPhasePlansAndSummaries ──────────────────────────────────────────────
function countPhasePlansAndSummaries(phaseDir: string, convention?: string | null): PhasePlansAndSummaries {
const { planCount, summaryCount } = scanPhasePlans(phaseDir);
// hasContext and hasResearch are not plan-scan concerns — read the directory
// once and share the listing for all non-plan metadata that cmdRoadmapAnalyze needs.
//
// #4014 (epic #3473 B4): the listing + unreadable-vs-empty discrimination
// is now owned by findContextMdIn's directory-string form, retiring this
// function's own readdirSync try/catch (mirrors core-utils.cts's
// getPhaseFileStats / phase-locator.cts's listMilestonePhaseDirs
// SCOPE.UNREADABLE discriminator).
const { files: phaseFiles, scope } = findContextMdIn(phaseDir);
// #3885 (ADR-3473 §8.5): `contextReadError` stays additive for the shipped
// `AnalyzePhase.context_read_error` JSON field — derived from `scope`
// rather than from its own caught error, since findContextMdIn's
// directory-string form reports SCOPE, not the raw errno message.
const contextReadError = scope === SCOPE.UNREADABLE
? `Could not read phase directory ${formatDiagnosticToken(phaseDir)}`
: null;
// #3511: scope the raw listing to this phase dir before the
// phase-numbered-artifact predicates (hasContext/hasResearch) — planCount/
// summaryCount above stay on scanPhasePlans's own unscoped listing since a
// PLAN/SUMMARY leading number is a plan sequence number, not a phase
// number. Mirrors core-utils.cts's getPhaseFileStats.
// #612: `convention` threaded from the one caller (which already threads it
// into matchPhaseDirs) so a bracket dir scopes by its real token.
const scopedFiles = scopeToPhase(phaseFiles, path.basename(phaseDir), convention);
return {
planCount,
summaryCount,
hasContext: findContextMdIn(scopedFiles) !== null,
hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
contextReadError,
scope,
};
}
// `phaseMarkdownRegexSource` lives in phase-id.cjs (#3537) and is imported above.
// ─── searchPhaseInContent ─────────────────────────────────────────────────────
/**
* Build the phase-heading regex used by `searchPhaseInContent` for a given
* pre-escaped phase source. Extracted (#3412) so tests can assert against the
* exact production pattern instead of hand-duplicating it.
* #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
*/
function buildPhaseHeadingRegex(escapedPhase: string, convention?: string | null): RegExp {
return new RegExp(
`^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
'i'
);
}
/**
* Search for a phase header (and its section) within the given content string.
* Returns a result object if found (either a full match or a malformed_roadmap
* checklist-only match), or null if the phase is not present at all.
*/
function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string, convention?: string | null): PhaseSearchResult | null {
const headingPattern = buildPhaseHeadingRegex(escapedPhase, convention);
const headings = tokenizeHeadings(content);
const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
const headerMatch = headingIndex === -1 ? null : headings[headingIndex].text.match(headingPattern);
if (!headerMatch) {
// Fallback: check if phase exists in summary list but missing detail section
// A BARE `Phase\s+` at base — takes the label-only baseline, so a bracket
// repo gains the bracket-ID form and nothing else.
const checklistPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`,
'i'
);
const checklistMatch = content.match(checklistPattern);
if (checklistMatch) {
return {
found: false,
phase_number: phaseNum,
phase_name: checklistMatch[1].trim(),
error: 'malformed_roadmap',
message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`
};
}
return null;
}
const phaseName = headerMatch[1].trim();
const headerIndex = headings[headingIndex].offset;
const currentHeading = headings[headingIndex];
const nextHeading = headings
.slice(headingIndex + 1)
.find((candidate) => candidate.level <= currentHeading.level);
const sectionEnd = nextHeading ? nextHeading.offset : content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
// Extract goal if present (supports both **Goal:** and **Goal**: formats).
// #4731: multiline-aware — hard-wrapped Goals read past the line break.
const goal = roadmapParserModule.extractPhaseFieldMultiline(section, 'Goal');
// Mode: vertical-MVP slice mode flag. Lowercased + trimmed for canonical
// comparison; unrecognized values are preserved verbatim for forward-compat.
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
const mode = modeMatch ? modeMatch[1].trim().toLowerCase() : null;
// Extract success criteria as structured array. A criterion may wrap onto extra
// indented lines (no `N.` prefix); those continuations must fold INTO their
// criterion, not end the run (#2522 — the old `(?:\s*\d+\.\s*[^\n]+)+` broke on a
// wrapped line, truncating it and silently dropping every criterion below it).
// `\n*` before each numbered line keeps blank-line-separated criteria working.
const criteriaMatch = section.match(
/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\n*[ \t]*\d+\.[^\n]*\n?(?:[ \t]+(?!\d+\.)[^\n]*\n?)*)+)/i);
const success_criteria = criteriaMatch
? criteriaMatch[1].trim().split(/\n+(?=[ \t]*\d+\.)/)
.map(entry => entry.replace(/^\s*\d+\.\s*/, '').replace(/\s*\n\s*/g, ' ').trim())
.filter(Boolean)
: [];
return {
found: true,
phase_number: phaseNum,
phase_name: phaseName,
goal,
mode,
success_criteria,
section,
};
}
// ─── getRoadmapPhaseWithFallback ──────────────────────────────────────────────
/**
* Two-pass phase lookup that mirrors cmdRoadmapGetPhase's resolution strategy.
*
* Pass 1: current-milestone slice (extractCurrentMilestone).
* Pass 2: full roadmap content (stripShippedMilestones) — covers cross-milestone
* and older frontend phases that are no longer in the current milestone slice.
*
* Returns the phase section string if found, null if ROADMAP.md is missing,
* or throws if ROADMAP.md read fails.
*
* Used by check-command-router (computeUiPlanGate) so ui-plan-gate uses the SAME
* phase resolution as `roadmap.get-phase` — not a milestone-only subset.
*/
function getRoadmapPhaseWithFallback(cwd: string, phaseNum: string): string | null {
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
if (isSentinelPhaseId(stripProjectCodePrefix(phaseNum))) return null;
const roadmapPath = planningPaths(cwd).roadmap;
// Read directly rather than gating on fs.existsSync: existsSync returns false
// on EACCES/EIO too, which would mask an UNREADABLE roadmap as "missing" and
// let a blocking gate certify empty scope (#2365 review). Honor the documented
// contract — null only when genuinely absent (ENOENT), otherwise throw.
let rawContent: string;
try {
rawContent = fs.readFileSync(roadmapPath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return null;
throw err;
}
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
const fullContent = stripShippedMilestones(rawContent);
// #2121/#2114: iterate the shared lookup-source list (exact → numeric →
// prefix-tolerant) so this resolver matches getRoadmapPhaseInternal and a
// bare-number query resolves a drifted project-code-prefixed heading.
const convention = resolvePhaseIdConvention(cwd);
for (const source of roadmapPhaseLookupSources(phaseNum)) {
const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum, convention);
if (milestoneResult && !milestoneResult.error) return milestoneResult.section ?? null;
const fullResult = searchPhaseInContent(fullContent, source, phaseNum, convention);
if (fullResult && !fullResult.error) return fullResult.section ?? null;
}
return null;
}
// ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void {
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
if (isSentinelPhaseId(stripProjectCodePrefix(phaseNum))) {
output({ found: false, phase_number: phaseNum }, raw, '');
return;
}
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
return;
}
try {
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
const fullContent = stripShippedMilestones(rawContent);
const convention = resolvePhaseIdConvention(cwd);
// #2121/#2114: iterate the shared lookup-source list (exact → numeric →
// prefix-tolerant) so all three roadmap resolvers share one contract and a
// bare-number query resolves a drifted `### Phase AB-29:` heading. This
// preserves the #3599 exact-prefix-first and #3537 padding-tolerant behavior
// (both now encoded in roadmapPhaseLookupSources' ordering). A clean match
// (milestone or full, any source) wins immediately; a malformed_roadmap
// (checklist-only) candidate is surfaced only if no source finds a real
// heading — so a milestone checklist never blocks a full-roadmap header.
let malformed: PhaseSearchResult | null = null;
for (const source of roadmapPhaseLookupSources(phaseNum)) {
const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum, convention);
if (milestoneResult && !milestoneResult.error) {
output(milestoneResult, raw, milestoneResult.section);
return;
}
const fullResult = searchPhaseInContent(fullContent, source, phaseNum, convention);
if (fullResult && !fullResult.error) {
output(fullResult, raw, fullResult.section);
return;
}
if (!malformed) malformed = (milestoneResult?.error ? milestoneResult : (fullResult?.error ? fullResult : null));
}
// #3577: no heading or checklist entry matched — fall back to a
// markdown-table row declaration (the same last-resort tier
// getRoadmapPhaseInternal gained). Zero-pad-tolerant id compare (#3572
// lesson: the declared form may be padded).
const stripPad = (s: string) => s.replace(/^0+(?=.)/, '');
const tableHit = collectTablePhaseRows(milestoneContent).find((tr) => stripPad(tr.id) === stripPad(phaseNum))
?? collectTablePhaseRows(fullContent).find((tr) => stripPad(tr.id) === stripPad(phaseNum));
if (tableHit) {
output(
{ found: true, phase_number: phaseNum, phase_name: tableHit.name ?? `Phase ${tableHit.id}`, goal: null, section: tableHit.row.trim() },
raw,
tableHit.row.trim(),
);
return;
}
if (malformed) {
output(malformed, raw, '');
return;
}
output({ found: false, phase_number: phaseNum }, raw, '');
} catch (e) {
error('Failed to read ROADMAP.md: ' + (e as Error).message);
}
}
// ─── cmdRoadmapAnalyze ────────────────────────────────────────────────────────
/**
* #3165: a single phase-detail heading enriched with its on-disk status, as
* `cmdRoadmapAnalyze` reports it. Extracted so the SAME enrichment runs on both
* the scoped milestone window and, when that window is suspect (non-COMPLETE
* scope, zero phases, phase dirs on disk), the shipped-milestone-stripped
* fallback document.
*/
type AnalyzePhase = {
number: string;
name: string;
goal: string | null;
mode: string | null;
depends_on: string | null;
plan_count: number;
summary_count: number;
has_context: boolean;
has_research: boolean;
disk_status: string;
roadmap_complete: boolean;
/** #3885 (ADR-3473 §8.5): see PhasePlansAndSummaries.contextReadError. */
context_read_error: string | null;
/** #4014 (epic #3473 B4): see PhasePlansAndSummaries.scope. Additive
* sibling of context_read_error — SCOPE.UNREADABLE for the same read
* failure context_read_error names, SCOPE.COMPLETE otherwise (including a
* genuinely absent/no_directory phase). */
context_scope: Scope;
};
type AnalyzePhaseCollection = {
phases: AnalyzePhase[];
detailKeys: Set<string>;
};
// #612 composes the convention-qualified sentinel reading with upstream's
// canonical legacy sentinel owner. A reserved bracket milestone OR a reserved
// phase token excludes the occurrence.
const isSentinelPhase = (num: string, bracketId?: string): boolean => {
if (bracketId && isSentinelPhaseId(`${bracketId}-${num}`, 'bracket')) return true;
return isSentinelPhaseId(num);
};
// #2761 M1: missing-detail identity is milestone-qualified under bracket.
// Prefer the canonical qualified-key owner, which case-folds accepted ids, so
// `[msd.02] 01` and `[MSD.02] 01` are one occurrence. It is intentionally not
// padding-tolerant: the milestone grammar has one canonical spelling (pad2
// below 100, no leading zero above), so `[MSD.2]` is malformed rather than an
// alternate spelling of `[MSD.02]`. Hyphenated tokens and other shapes the
// qualified-key owner refuses retain a folded composite, keeping distinct
// bracket/token pairs from collapsing onto one missing-detail verdict.
const occurrenceKey = (num: string, bracketId?: string): string => {
if (!bracketId) return num;
const qualified = num.includes('-')
? null
: bracketQualifiedKey(`${bracketId}-${num}`, 'bracket');
return qualified ?? `${foldBracketId(bracketId)}|${num}`;
};
/**
* #3165: scan `content` for phase-detail headings (`##/###/#### Phase N: Name`)
* and enrich each with its on-disk plan/summary/completion status and ROADMAP
* checkbox. Pure extraction over `content` + the pre-built `phaseDirNames`
* lookup index — no milestone windowing of its own; the caller chooses the
* content (scoped window or fallback). Extracted verbatim from
* `cmdRoadmapAnalyze`'s former inline loop so the fallback re-runs the EXACT
* same enrichment, not a second derivation.
*/
function collectAnalyzePhases(
content: string,
phasesDir: string,
phaseDirNames: string[],
convention?: string | null,
): AnalyzePhaseCollection {
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
// #612: CAPTURING intro under the bracket convention — group 1 is the
// `[CODE.MM]` bracket id (undefined otherwise), group 2 the token, group 3 the
// name. The bracket id is what the sentinel filter needs: READING-B puts the
// sentinel milestone in the bracket, not in the token.
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
// #3036: widen the id capture to accept non-numeric-leading ids (e.g. B7, P0.3-2)
// that get-phase/execute-phase already resolve. An optional leading letter prefix
// ([A-Za-z]?) covers letter-prefixed ids without breaking numeric-leading ones.
// #4478: line-anchored (`^ {0,3}`, `/m`) — unanchored, `#{2,4}` matched a
// `### Phase N:`-shaped mention ANYWHERE `exec()`'s scan reached: mid-sentence
// prose, inside a blockquote, inside an inline code span (backtick-quoted on
// the same line, not a fenced block `tokenizeHeadings` would exclude). Any
// such line minted a phantom phase entry, inflating phase_count and able to
// collide on a phase NUMBER with a real heading nearby. `{0,3}` leading
// spaces mirrors `tokenizeHeadings`'s own CommonMark ATX-heading tolerance
// (src/markdown-sectionizer.cts:453) so a legitimately-indented heading that
// matched before this fix still matches after it.
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
const phasePattern = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gim');
// The capturing intro inserts the bracket id at group 1 only under the
// bracket convention; the token and name shift by the same offset.
const G = convention === 'bracket' ? 1 : 0;
const phases: AnalyzePhase[] = [];
let match: RegExpExecArray | null;
// The caller needs the exact occurrence identities from the same scan that
// built `phases`; returning them together also keeps fallback rescans atomic.
const detailKeys = new Set<string>();
while ((match = phasePattern.exec(content)) !== null) {
const bracketId = G ? match[1] : undefined;
const phaseNum = match[1 + G];
if (isSentinelPhase(phaseNum, bracketId)) continue;
detailKeys.add(occurrenceKey(phaseNum, bracketId));
const phaseName = match[2 + G].replace(/\(INSERTED\)/i, '').trim();
// Extract goal from the section
const sectionStart = match.index;
const restOfContent = content.slice(sectionStart);
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
// recognised as section boundaries. #3036: `[A-Za-z]?\d` so non-numeric-leading ids
// (e.g. B7) are also recognised.
// #4478 follow-up (independent code review on this same fix): ` {0,3}` after
// the literal `\n` mirrors phasePattern's own new leading-space tolerance
// above -- without it, a legitimately-indented (1-3 space) NEXT phase
// heading was invisible to this boundary lookup, letting the PRIOR phase's
// goal/mode/depends_on extraction bleed across the section boundary into
// the next phase's own body.
const nextHeader = restOfContent.match(new RegExp(`\\n {0,3}#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}[A-Za-z]?\\d[\\d.-]*`, 'i'));
const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : content.length;
const section = content.slice(sectionStart, sectionEnd);
const goal = roadmapParserModule.extractPhaseFieldMultiline(section, 'Goal');
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
const mode = modeMatch ? modeMatch[1].trim().toLowerCase() : null;
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
// Check completion on disk
const normalized = normalizePhaseName(phaseNum);
let diskStatus = 'no_directory';
let planCount = 0;
let summaryCount = 0;
let hasContext = false;
let hasResearch = false;
// #3885 (ADR-3473 §8.5): null unless dirMatch resolves and its readdirSync
// hit a non-ENOENT error — no directory at all is `disk_status:
// 'no_directory'`, a real (if uninteresting) answer, not a read error.
let contextReadError: string | null = null;
// #4014 (epic #3473 B4): additive sibling — SCOPE.COMPLETE by default
// (no directory at all is a genuine, not-unreadable answer), overwritten
// below only when dirMatch resolves.
let contextScope: Scope = SCOPE.COMPLETE;
// DEAD catch removed (#2245 audit): matchPhaseDirs(...) is a pure
// array lookup on an already-resolved string array, and
// countPhasePlansAndSummaries is itself fully defensive (its own
// readdirSync is self-guarded, and it delegates to scanPhasePlans, which
// never throws) — nothing in this block can throw, so the try/catch could
// never be triggered.
// #612: the DIRECTORY read is selected by the same `convention` the four
// heading/checklist patterns above already thread. Left two-argument, this
// one call reported EVERY canonical `{CODE}.{MM}-{PP}-slug` directory as
// `disk_status: "no_directory"` with `plan_count`/`summary_count` 0 —
// `extractPhaseToken('MSD.02-01-one')` with no convention returns the whole
// dir name — while the same build resolved those same directories correctly
// in three other places on the same repo (W006/W007 through their shared
// directory matcher, `state json` via the milestone filter, and the W026
// milestone-complete read through the same convention-aware owner). It
// failed ONLY for the directory shape the convention exists to name: a
// mid-migration bracket repo carrying legacy `01-one` dirs resolved fine.
// That is verbatim the asymmetry the note above the W026 rule says this PR
// closed — the directory read widens with the heading read, or every bracket
// phase resolves to nothing.
// Upstream centralized this choice in `matchPhaseDirs`; thread the same
// convention into that owner rather than reviving the primitive `.find()`.
const dirMatch = matchPhaseDirs(phaseDirNames, normalized, convention).matches[0];
if (dirMatch) {
const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatch), convention);
planCount = counts.planCount;
summaryCount = counts.summaryCount;
hasContext = counts.hasContext;
hasResearch = counts.hasResearch;
contextReadError = counts.contextReadError;
contextScope = counts.scope;
// ADR-3180 §7.4 (issue #3186, disk-strict, #3168 fix): route "is this
// phase complete" through the canonical owner (`isPhaseComplete`),
// which calls readVerificationStatus UNCONDITIONALLY — plan count is
// NOT a precondition, so a zero-plan phase with a passing
// `*-VERIFICATION.md` reports complete here too, not just via
// `phase.complete`.
// #612: `convention` (a parameter of this function, same thread as
// matchPhaseDirs above) rides into completion so a bracket phase dir
// resolves and scopes its verification report like its legacy twin.
const completionResult = isPhaseComplete(path.join(phasesDir, dirMatch), { convention });
if (completionResult.value.complete) diskStatus = 'complete';
else if (summaryCount > 0) diskStatus = 'partial';
else if (planCount > 0) diskStatus = 'planned';
else if (hasResearch) diskStatus = 'researched';
else if (hasContext) diskStatus = 'discussed';
else diskStatus = 'empty';
}
// Check ROADMAP checkbox status. #3537: padding-tolerant fragment — the
// heading discovered above may use a different padding than the
// summary-bullet checkbox below it (mixed padding inside one ROADMAP is
// legal and seen in real projects).
//
// ADR-3180 §7.4 (disk-strict, #2957, maintainer decision 2026-08-08):
// `roadmapComplete` is reported below as metadata ONLY — it carries NO
// machine authority over `diskStatus`. The override that used to trust a
// ticked checkbox over disk file structure is DELETED, not generalized
// (#2957: "a ticked ROADMAP checkbox is a human annotation with no
// machine authority"). A phase marked complete solely by a ticked
// checkbox — no passing `*-VERIFICATION.md`, plans outstanding — now
// reports incomplete; this is the deliberate Tier-2 break (ADR-3180 §7.4
// Decision 3).
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
const checkboxMatch = content.match(checkboxPattern);
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
phases.push({
number: phaseNum,
name: phaseName,
goal,
mode,
depends_on,
plan_count: planCount,
summary_count: summaryCount,
has_context: hasContext,
has_research: hasResearch,
disk_status: diskStatus,
roadmap_complete: roadmapComplete,
context_read_error: contextReadError,
context_scope: contextScope,
});
}
// #3577: markdown-table row declarations join the enumeration — same
// enrichment contract as headings (disk counts when the directory exists),
// zero-pad-tolerant duplicate guard so an id declared in BOTH a heading and
// a table counts once.
const stripPadA = (s: string) => s.replace(/^0+(?=.)/, '');
const seen = new Set(phases.map((ph) => stripPadA(ph.number)));
for (const tr of collectTablePhaseRows(content)) {
// #3577 table declarations were part of the pre-existing detail set.
// Preserve that behavior while heading occurrences gain bracket identity.
detailKeys.add(occurrenceKey(tr.id));
if (seen.has(stripPadA(tr.id))) continue;
const dirMatchA = matchPhaseDirs(phaseDirNames, normalizePhaseName(tr.id)).matches[0];
let tPlanCount = 0;
let tSummaryCount = 0;
let tHasContext = false;
let tHasResearch = false;
let tContextReadError: string | null = null;
// #4014 (epic #3473 B4): additive sibling, same default rule as the
// heading-declared branch above.
let tContextScope: Scope = SCOPE.COMPLETE;
if (dirMatchA) {
const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatchA));
tPlanCount = counts.planCount;
tSummaryCount = counts.summaryCount;
tHasContext = fs.existsSync(path.join(phasesDir, dirMatchA, 'CONTEXT.md'));
tHasResearch = fs.existsSync(path.join(phasesDir, dirMatchA, 'RESEARCH.md'));
// #3885 (ADR-3473 §8.5): reuse the SAME countPhasePlansAndSummaries call's
// discriminator — this row's hasContext/hasResearch are read via a direct
// existsSync (which cannot itself distinguish EACCES from absent), but
// an unreadable phase directory is still surfaced via the sibling call.
tContextReadError = counts.contextReadError;
tContextScope = counts.scope;
}
phases.push({
number: tr.id,
name: tr.name ?? `Phase ${tr.id}`,
goal: null,
mode: null,
depends_on: null,
plan_count: tPlanCount,
summary_count: tSummaryCount,
has_context: tHasContext,
has_research: tHasResearch,
disk_status: dirMatchA ? 'ok' : 'no_directory',
roadmap_complete: false,
context_read_error: tContextReadError,
context_scope: tContextScope,
});
}
return { phases, detailKeys };
}
function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw, undefined);
return;
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
// #3184/#3165: use the scoped variant so a truncated window is a
// distinguishable signal in the output instead of a silent `phase_count: 0`
// indistinguishable from a genuinely empty milestone.
const { value: content, scope } = extractCurrentMilestoneScoped(rawContent, cwd);
const phasesDir = planningPaths(cwd).phases;
// #612: resolve once per command and thread the same reading through both
// the scoped scan and any fallback scan.
const convention = resolvePhaseIdConvention(cwd);
const G = convention === 'bracket' ? 1 : 0;
// Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
// #3185 exemption reason (ADR-3180 Decision 4a): this is a heading->directory
// LOOKUP INDEX, not a milestone enumeration. It must see the PHYSICAL set so
// a heading already scoped by extractCurrentMilestoneScoped above can find
// its directory; filtering it through listMilestonePhaseDirs would scope
// the same set twice. #3882 (ADR-3473 §8.2): routed through the named
// "physical set, sentinels included" axis instead of a hand-rolled
// readdirSync — every heading matched below already excludes sentinel
// phase numbers via isSentinelPhaseId before it ever consults this list
// (collectAnalyzePhases), so a sentinel directory's presence here is
// output-invariant; this only removes the re-derivation, not the reason.
const _phaseDirNames = listAllPhaseDirs(phasesDir, { includeSentinels: true }).value;
// Scan the scoped milestone window for phase-detail headings and enrich each
// with its on-disk status. Extracted into `collectAnalyzePhases` (#3165) so
// the SAME enrichment re-runs on the fallback below — not a second copy.
let collected = collectAnalyzePhases(content, phasesDir, _phaseDirNames, convention);
let phases = collected.phases;
let detailKeys = collected.detailKeys;
// `effectiveContent` is what the downstream checklist scan (missing_details)
// iterates. Defaults to the scoped window; switched to the fallback document
// when the recovery path below fires, so a phase found via fallback is not
// falsely reported as "in checklist but missing a detail section."
let effectiveContent = content;
// #3165: recover phase_count when the scoped window came back empty. A
// CLOSED milestone heading sitting between the active milestone heading and
// its own phase-detail sections closes `extractCurrentMilestoneScoped`'s
// window over prose only — `phases` is empty, and the consuming resume gate
// (`workflows/next.md` Route 0) iterates `.phases[]` so a safety invariant
// silently never runs. When the window is suspect (non-COMPLETE scope), the
// scoped scan found nothing, AND phase directories exist on disk (real
// evidence phases exist), re-scan the shipped-milestone-stripped document so
// the phase list reflects the real phases instead of a silent zero. The
// `scope` field retains its non-COMPLETE value downstream so consumers can
// still tell this is a best-effort count, not a cleanly scoped one. Position
// alone cannot attribute phases to the active vs the intervening closed
// milestone, so this never claims COMPLETE — it converts silence into a
// populated, flagged result.
if (phases.length === 0 && scope !== SCOPE.COMPLETE && _phaseDirNames.length > 0) {
const fallbackContent = stripShippedMilestones(rawContent);
const fallbackCollection = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames, convention);
if (fallbackCollection.phases.length > 0) {
collected = fallbackCollection;
phases = collected.phases;
detailKeys = collected.detailKeys;
effectiveContent = fallbackContent;
}
}
// Extract milestone info. #3216: routed through the canonical
// `listMilestoneHeadings` owner (deleted the inline `##…` regex, which
// truncated names at a parenthetical and had no phase-heading exclusion)
// rather than re-deriving the enumeration here.
const milestones: Array<{ heading: string; version: string }> = listMilestoneHeadings(content).map((m) => ({
heading: m.heading,
version: m.version,
}));
// Find current and next phase
const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null;
// Aggregated stats
const totalPlans = phases.reduce((sum, p) => sum + p.plan_count, 0);
const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
// Detect phases in summary list without detail sections (malformed ROADMAP).
// The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
// IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
// at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
// #612: CAPTURING label-only intro — the bracket id rides along so the
// sentinel filter below is not blind to `- [ ] **[MSD.999] 01: Icebox**`.
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
// #3036: widen to accept non-numeric-leading ids (same widening as the detail-heading pattern above).
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)`, 'gi');
// #2761 M1: an OCCURRENCE list keyed by `occurrenceKey`, not a token->bracket
// map. The map was first-wins on the bare token, so of two checklist entries
// sharing a token across brackets the FIRST one's bracket id classified BOTH:
// `- [ ] **[MSD.999] 01: Icebox**` written above `- [ ] **[MSD.02] 01: …**`
// made the real phase inherit the icebox's sentinel verdict and vanish from
// `missing_phase_details`; written below it, the same document reported it.
// Dedupe still happens — it is now per PHASE rather than per token, which is
// what makes the classification order-independent.
const checklistOccurrences: Array<{ token: string; bracketId?: string }> = [];
const seenChecklistKeys = new Set<string>();
let checklistMatch: RegExpExecArray | null;
while ((checklistMatch = checklistPattern.exec(effectiveContent)) !== null) {
const token = checklistMatch[1 + G];
const bracketId = G ? checklistMatch[1] : undefined;
const key = occurrenceKey(token, bracketId);
if (seenChecklistKeys.has(key)) continue;
seenChecklistKeys.add(key);
checklistOccurrences.push({ token, bracketId });
}
// The EMITTED value stays the bare token, unchanged: `phases[].number` is a
// token under every convention, and `missing_phase_details` is read against
// it. Only the classification moved to the qualified key — so two different
// brackets' `01` both missing report `01` once, rather than one of them
// silently covering for the other.
const missingDetails = [...new Set(
checklistOccurrences
.filter(o => !detailKeys.has(occurrenceKey(o.token, o.bracketId))
&& !isSentinelPhase(o.token, o.bracketId))
.map(o => o.token),
)];
// #3217 (ADR-3180 §7.6 rules 3-4): `progress_percent` used to accumulate
// `totalPlans`/`totalSummaries` above — a heading-matched enumeration
// (`phasePattern` over the milestone-windowed `content`) paired against
// `_phaseDirNames`, a DELIBERATELY unscoped physical directory listing
// (see its own comment above: it is a heading->directory lookup index,
// not a milestone enumeration). That set is not the same set
// `listMilestonePhaseDirs` scopes for `query progress` / `stats` (#3185
// Phase 3), so `progress_percent` could silently diverge from both siblings
// on the same project (rule 3). Route `progress_percent`'s own
// numerator/denominator through the single scoped owner instead — mirrors
// cmdProgressRender/cmdStats's own aggregation — and withhold the
// percentage entirely when THAT scope is not COMPLETE (rule 4), never
// returning `0` for "could not compute". This does not touch `total_plans`
// / `total_summaries` / `phases` / `completed_phases` above — those stay
// the heading-matched detail view; only `progress_percent`'s own inputs
// move onto the scoped owner.
let scopedTotalPlans = 0;
let scopedTotalSummaries = 0;
let progressScope: Scope = SCOPE.UNREADABLE;
try {
const { value: progressDirs, scope: scopedResult } = listMilestonePhaseDirs(phasesDir, { cwd });
progressScope = scopedResult;
for (const dir of progressDirs) {
const scan = scanPhasePlans(path.join(phasesDir, dir));
scopedTotalPlans += scan.planCount;
scopedTotalSummaries += scan.summaryCount;
}
} catch { /* progressScope stays the pessimistic SCOPE.UNREADABLE default */ }
const progressPercent = progressScope === SCOPE.COMPLETE
? clampPercent(scopedTotalSummaries, scopedTotalPlans)
: null;
const result = {
milestones,
phases,
phase_count: phases.length,
completed_phases: completedPhases,
total_plans: totalPlans,
total_summaries: totalSummaries,
progress_percent: progressPercent,
// #3217 finding 2: `progress_percent` is gated by a SECOND, independently
// computed `listMilestonePhaseDirs` scope (`progressScope` above) — not
// by the top-level `scope` field, which describes the heading-windowing
// identity `phases`/`total_plans`/`total_summaries`/`completed_phases`
// were built from. Those two scopes can legitimately disagree (e.g.
// `scope: "complete"` alongside a genuinely unreadable phases directory),
// and per the documented contract "scope tells you whether the counts
// are trustworthy", a consumer seeing `progress_percent: null` needs a
// field to tell WHY without reading source. Exposing `progress_scope`
// (rather than reconciling the two scopes into one, or re-deriving
// `total_plans`/`phases`/etc. from the scoped set) preserves the
// deliberate, already-documented choice a few lines up: `phases`/
// `total_plans`/`total_summaries`/`completed_phases` stay the
// heading-matched detail view (`_phaseDirNames` is a lookup index, not a
// milestone enumeration — see its comment); only `progress_percent`'s own
// inputs move onto the scoped owner.
progress_scope: progressScope,
current_phase: currentPhase ? currentPhase.number : null,
next_phase: nextPhase ? nextPhase.number : null,
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
// #3184/#3165: distinguishes a genuinely empty milestone (`scope:
// "complete"`, `phase_count: 0`) from a window that could not be fully
// resolved (`"truncated"` / `"unscoped"` / `"unreadable"`) — those cases
// were previously output-identical.
scope,
};
output(result, raw, undefined);
}
// ─── cmdRoadmapMilestoneScope ────────────────────────────────────────────────
/**
* #3262 (write-time milestone-scope guard): read-only probe emitting the
* current milestone window's IDENTITY — its scope classification and the
* phase ids it declares — so the edit-phase workflow can capture it before
* its in-place section write, re-derive it after, and roll back on any
* change. This is the milestone-scope sibling of the workflow's existing
* `depends_on` gate, expressed as a command because the workflow's write is
* assistant-driven free-text surgery, not a code path.
*
* Deliberately NOT `cmdRoadmapAnalyze`: analyze's #3165 recovery re-populates
* `phases` from the shipped-milestone-stripped document when the scoped
* window is suspect, which is right for a human-facing progress report and
* wrong for a before/after equality probe — the refill would mask exactly
* the narrowing this guard exists to detect. This probe reports the RAW
* window (`extractCurrentMilestoneScoped` + `scanMilestonePhaseIds`), no
* fallback, so a narrowed window is always visible as a changed phase set.
*/
function cmdRoadmapMilestoneScope(cwd: string, raw: boolean): void {
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
output({ error: 'ROADMAP.md not found', scope: SCOPE.UNREADABLE, phases: [], phase_count: 0 }, raw, undefined);
return;
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
// #3641: resolve phase_id_convention and thread it into the scope axis, so
// this probe and `roadmap validate`'s V005 answer the SAME question the
// SAME way for a bracket-convention project — a window the classifier
// calls TRUNCATED in validate must never read COMPLETE here (the #3262
// capture/compare guard consumes this scope). Resolution mirrors the
// validate router's: .planning/config.json first, ROADMAP.md frontmatter
// as fallback.
let phaseIdConvention: string | undefined | null;
try {
const cfg = loadConfigForScope(cwd);
phaseIdConvention = cfg['phase_id_convention'] as string | undefined | null;
} catch {
phaseIdConvention = undefined;
}
if (phaseIdConvention === undefined || phaseIdConvention === null) {
// Bounded per local/no-unbounded-quantifier (#2128): frontmatter is a
// short header block — 4KB is orders of magnitude beyond any real one.
const fmMatch = rawContent.match(/^---\r?\n([\s\S]{0,4000}?)\r?\n---/);
if (fmMatch) {
const kvMatch = fmMatch[1].match(/^phase_id_convention:\s*(.*)$/m);
if (kvMatch) {
const val = kvMatch[1].trim();
if (val !== 'null' && val !== '') {
phaseIdConvention = val.replace(/^["']|["']$/g, '');
}
}
}
}
const { value: window, scope } = extractCurrentMilestoneScoped(rawContent, cwd, undefined, phaseIdConvention);
// Document order (Set insertion order) — deterministic for a given document.
const phases = [...scanMilestonePhaseIds(window, phaseIdConvention)];
output({ scope, phases, phase_count: phases.length }, raw, undefined);
}
// ─── cmdRoadmapUpdatePlanProgress ─────────────────────────────────────────────
/**
* Scope a ROADMAP.md content string down to its "Progress table" writable
* slice, run `edit` against just that slice, then splice the result back into
* the original content (ADR-2143 §7). Layered scoping:
* 1. Milestone scope — everything after the LAST `</details>` close tag
* (mirrors `replaceInCurrentMilestone`), so a same-numbered phase row in
* an archived milestone is never touched.
* 2. Heading scope — within that milestone slice, the `## Progress` heading
* section (up to the next `#`/`##` heading) when present, else the whole
* milestone slice (mirrors phase-lifecycle.cjs's `deriveProgressFromRoadmap`
* read-side scoping, #2012 decoy avoidance — a differently-headed table
* sharing the same column names must not be picked up instead).
* `edit` always returns a string and never fails — a no-op edit (table/row not
* found within the scoped slice) simply returns its input unchanged, mirroring
* the prior regex `.replace()`'s no-match-is-a-no-op semantics.
*/
function editProgressTableSlice(content: string, edit: (scoped: string) => string): string {
const lastDetailsClose = content.lastIndexOf('</details>');
const milestoneOffset = lastDetailsClose === -1 ? 0 : lastDetailsClose + '</details>'.length;
const before = content.slice(0, milestoneOffset);
const milestoneSlice = content.slice(milestoneOffset);
const progressMatch = milestoneSlice.match(/^##[ \t]+Progress\b/im);
if (!progressMatch || progressMatch.index === undefined) {
return before + edit(milestoneSlice);
}
const headingOffset = progressMatch.index;
const beforeHeading = milestoneSlice.slice(0, headingOffset);
const fromHeading = milestoneSlice.slice(headingOffset);
const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
return before + beforeHeading + edit(scoped) + after;
}
function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | undefined, raw: boolean): void {
if (!phaseNum) {
error('phase number required for roadmap update-plan-progress');
}
const roadmapPath = planningPaths(cwd).roadmap;
const phaseInfo = findPhaseInternal(cwd, phaseNum);
if (!phaseInfo) {
error(`Phase ${phaseNum} not found`);
}
const planCount = phaseInfo!.plans.length;
// Count only summaries that pair with a real plan (#1988): stray non-plan
// summaries (30-FIX-CR02-SUMMARY.md, 30-GAPCLOSURE-SUMMARY.md, …) must not
// inflate summary_count and silently flip the phase to Complete.
const summaryCount = countMatchedSummaries(phaseInfo!.plans, phaseInfo!.summaries);
if (planCount === 0) {
declineNoOp(
raw,
'updated',
'No plans found',
'roadmap update-plan-progress skipped — no plans found for this phase. ROADMAP.md was left unchanged.',
{ plan_count: 0, summary_count: 0 },
);
return;
}
// Verification gate (#2022): do NOT check the phase checkbox or stamp a
// completion date until the phase's verification status is 'passed', matching
// cmdPhaseComplete's gate (phase.cts:1436). Previously the checkbox fired the
// moment the last plan summary landed — before msd-verifier had verified.
//
// ADR-3180 §7.4 (issue #3186, disk-strict): routed through the canonical
// owner (`isPhaseComplete`) instead of hand-rolling `summaryCount >=
// planCount && verificationPassed` locally — the owner calls
// readVerificationStatus UNCONDITIONALLY, so `isComplete` here always
// agrees with `roadmap analyze` / `init manager` / `phase complete` for
// the same phase (ADR-3180 §7.4's headline: one predicate for the read
// path and the write path).
const phaseDir = path.join(cwd, phaseInfo!.directory);
// ADR-3180 §7.4 read/write-path symmetry with the threaded site at ~583:
// thread convention here too, so this write path's completion reading
// agrees with the read path's under the bracket convention.
const convention = resolvePhaseIdConvention(cwd);
const completionResult = isPhaseComplete(phaseDir, { convention });
const verificationResult = completionResult.value.verification;
// #2648 precedent, applied at this write site (ADR-3180 §7.4 / #3186):
// `isPhaseComplete` deliberately carries NO plan-count precondition — the
// owner's `complete` is exactly `verification.status === 'passed'`, and
// that must stay true (disk-strict: a zero-plan phase with a passing
// `*-VERIFICATION.md` IS complete, #3168). But `readVerificationStatus`'s
// staleness check only compares SUMMARY mtimes against the verification
// file — it has no idea a NEW plan was added after the file was written,
// so a still-fresh `passed` verification says nothing about a plan added
// afterward. This command WRITES a checkbox and a completion date into
// ROADMAP.md, a materially stronger claim than "verification passed" —
// mirroring cmdPhaseComplete's own fail-closed plan-coverage gate
// (phase.cts:~1995, #2648: "a coverage gate that passes when it cannot
// read the plans is no gate at all"), composed explicitly here rather than
// folded into the predicate: complete AND all plans executed.
const coverageScan = scanPhasePlans(phaseDir);
const unsummarizedPlans = findUnsummarizedPlans(coverageScan.planFiles, coverageScan.summaryFiles);
const isComplete = completionResult.value.complete && unsummarizedPlans.length === 0;
// #3057 B3: routing above is unchanged (an indeterminate staleness check
// still routes as if nothing were stale) — this only makes the fact visible
// to whatever reads this command's JSON output.
const verificationStaleCheckIndeterminate = verificationResult.staleCheckIndeterminate === true;
const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
const today = realClock.localToday();
if (!fs.existsSync(roadmapPath)) {
declineNoOp(
raw,
'updated',
'ROADMAP.md not found',
'roadmap update-plan-progress skipped — ROADMAP.md not found.',
{ plan_count: planCount, summary_count: summaryCount },
);
return;
}
// Wrap entire read-modify-write in lock to prevent concurrent corruption
let updated = false;
// #4247: the refusal flag. The write/report decision below must be keyed to
// "a writable roadmap representation of THIS phase was found", never to "any
// byte moved". On a checklist-form ROADMAP (`- [ ] **Phase N: …**`, the
// roadmapper's own summary-checklist form) every phase-targeted grammar
// below requires an ATX `#{2,4} Phase N` heading and therefore finds
// nothing; the Progress-table row is the only other writable target, and
// when its Phase cell does not match `phaseCellRe` (e.g. a word-prefixed
// `Phase 68` cell — deliberately unrecognized on the read side too,
// `deriveProgressFromRoadmap`'s `/^\d/` data-row filter) the command used
// to fall through to unrelated byte deltas (an UN-scoped plan-checkbox mark
// anywhere in the document) and report `updated: true` while the phase's
// own row stayed untouched — with the file-global write then letting the
// platform write seam's markdown normalization inject blank lines around
// other phases' bullets, splitting hand-wrapped sentences mid-entry. The
// refusal below declines with the analyzer's own `missing_phase_details`
// vocabulary and leaves ROADMAP.md byte-identical.
let missingPhaseDetails = false;
withPlanningLock(cwd, () => {
// #3957 (B9.4): captured BEFORE any transform runs, so the write/report
// decision below reflects whether the transforms actually changed
// anything — not just that they ran. Every transform below still runs
// unconditionally exactly as before; only the final write-and-report
// step becomes conditional on `roadmapContent !== originalContent`.
const originalContent = fs.readFileSync(roadmapPath, 'utf-8');
let roadmapContent = originalContent;
const phasePattern = phaseMarkdownRegexSource(phaseNum);
// #4247: ONE local source for the ATX phase-heading anchor that every
// section-scoped writer below (`planSectionPattern`,
// `insertRowsPatternA|B`) starts with — extracted so the target-detection
// gate below reads the SAME grammar the writers anchor on, and a future
// edit to one cannot drift from the other three copies.
const phaseHeadingAnchor = `#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`;
// #4247: target detection runs against the ORIGINAL content's active
// (post-</details>) region — the same milestone scoping every writer
// below applies — so the gate asks "does the file carry a writable phase
// representation" rather than "did some regex fire mid-transform".
const gateDetailsClose = originalContent.lastIndexOf('</details>');
const gateActiveRegion = gateDetailsClose === -1
? originalContent
: originalContent.slice(gateDetailsClose + '</details>'.length);
// Heading target: the exact grammar `planSectionPattern` /
// `insertRowsPatternA|B` anchor on (an ATX phase heading for this phase).
const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(gateActiveRegion);
// Checklist target: when the phase is complete, its own checklist bullet
// (`- [ ] **Phase N: …**`) IS a writable phase row — the completion
// checkbox stamp below updates it. Same grammar as that writer, widened
// one notch to `[ x]` so an ALREADY-checked bullet still counts as a
// found target: an idempotent re-run then takes the honest
// "no changes were needed" decline instead of this refusal.
const checklistTargetFound = isComplete && new RegExp(
`-\\s*\\[[ x]\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`,
'i',
).test(gateActiveRegion);
// Table-row target: set by the row-scoped cell updates below.
let tableRowFound = false;
// Progress table row: update Plans Complete/Status/Completed columns BY
// COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of
// Milestone-column presence) via the markdown-table seam (ADR-2143 §7) —
// supersedes the prior ordinal cells[]-index regex. Scoped to the current
// milestone's `## Progress` table (editProgressTableSlice above).
// #2245 Blocker 4: optional dot must be followed by whitespace-or-end, not
// dot-OR-whitespace-OR-end as alternatives — the prior form let a bare "."
// satisfy the whole lookahead, so completing phase "2" over-matched a
// decimal sub-phase row like "2.5 Extra". Matches "2", "2.", "2 Alpha";
// rejects "2.5 Extra" (replicates OLD's `\.?\s` intent on the now-TRIMMED
// cell value, where end-of-string is the trimmed equivalent of "no more
// characters after the optional dot").
const phaseCellRe = new RegExp(`^${phasePattern}\\.?(?:\\s|$)`, 'i');
const rowMatch = (row: Record<string, string>): boolean => phaseCellRe.test((row['Phase'] ?? '').trim());
const dateShape = /^\d{4}-\d{2}-\d{2}$/;
roadmapContent = editProgressTableSlice(roadmapContent, (scoped) => {
let text = scoped;
const plansResult = updateTableCell(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
if (plansResult.ok) { text = plansResult.value; tableRowFound = true; }
const statusResult = updateTableCell(text, rowMatch, 'Status', ` ${status.padEnd(11)}`);
if (statusResult.ok) { text = statusResult.value; tableRowFound = true; }
// Preserve only a valid ISO date (#1161: idempotent; self-heal garbage).
// Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via
// a no-op updateTableCell write (its own tolerant row scan) rather than
// findTableWithColumns (which requires the WHOLE table to parse — a
// ragged SIBLING row elsewhere used to silently no-op this row's date
// stamp/clear too). The decision (write vs no-op) is folded into the
// newValue callback so a single updateTableCell call both reads and
// writes.
const completedResult = updateTableCell(text, rowMatch, 'Completed', (current) => {
if (isComplete) {
return dateShape.test(current.trim()) ? current : ` ${today} `;
}
return ' ';
});
if (completedResult.ok) { text = completedResult.value; tableRowFound = true; }
return text;
});
// Update plan count in phase detail section.
// Three recognised forms (all tolerated; canonical template uses the first):
// `**Plans**: N plans` — bold word + outer colon (msd-core/templates/roadmap.md)
// `**Plans:** N plans` — bold "Plans:" (colon inside bold)
// `Plans: N plans` — plain text header
//
// #2853 / #3584: the verb owns the count token ONLY — it must not destroy
// hand-written prose a human placed on the line. Group $1 = phase header →
// `Plans:` label + trailing whitespace (unchanged). Group $2 = the existing
// count token to replace: matches `N/N plans complete`, `N/N plans executed`,
// or the bare template `N plan(s)` form — singular is part of the tool's OWN
// grammar (msd-core/templates/roadmap.md:62 ships `**Plans**: 1 plan` as the
// documented one-plan-phase shape), so the `s` is optional there (bug #3584
// Finding B; pre-fix a bare `1 plan` fell into the drop-everything path and
// was accidentally overwritten with the correct count — post-fix it must be
// recognised as a token in its own right or it freezes stale forever). Group
// $3 = whatever else is on the line (`[^\r\n]*`, so a CRLF `\r` is never part
// of the match and rides along untouched in the unmatched remainder of the
// string — never stranded, never duplicated).
//
// Three arms, in order:
// 1. $2 present (a real count token) → rewrite the token, preserve $3
// verbatim (an annotation a human wrote after a real count; #2853).
// 2. $2 absent AND $3, trimmed, is the fresh-template PLACEHOLDER shipped
// by msd-core/templates/roadmap.md — either
// `[Number of plans, e.g., "3 plans" or "TBD"]` (line 37) or
// `[Number of plans]` (lines 51/75/88) → replace it with the computed
// count. Detected POSITIVELY on the distinctive `Number of plans`
// wording (anchored, case-insensitive), NEVER on "wholly bracketed" —
// a bracketed HUMAN annotation such as `[Deferred pending re-scope]`
// is structurally identical but must be arm-3 preserved (bug #3584
// Finding A).
// 3. Anything else (freeform prose, `TBD` / `TBD — annotation`, a
// bracketed human note, the first line of a wrapped sentence, an
// empty value) → leave the whole matched line untouched by returning
// `_match` unchanged. An untouched first line cannot orphan its own
// continuation on the next line, since the pattern never spans past
// `\n` in the first place.
// #4906 Phase 2 (#4917/ADR-4910): migrated off the one-capture-group
// regex that replaced to end of line onto the PlanningDoc `boldField`
// write seam. `planSectionPattern` scopes the match to phase N's OWN
// detail section (heading + body up to the next heading) — the same
// window `phaseHeadingAnchor`'s siblings anchor on — and the callback
// below runs parse -> classify -> (maybe) mutate -> serialize entirely
// within that section text, mirroring phase.cts's `writePlansField`.
const planSectionPattern = new RegExp(
`${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*`,
'i',
);
const planCountText = isComplete
? `${summaryCount}/${planCount} plans complete`
: `${summaryCount}/${planCount} plans executed`;
// Positive detector for the fresh-template placeholder ONLY (bug #3584
// Finding A). Anchored to the distinctive `Number of plans` wording that
// msd-core/templates/roadmap.md actually ships, not to "anything in
// brackets" — a bracketed human annotation like `[Deferred pending
// re-scope]` is structurally bracketed too but carries none of this
// wording, so it correctly falls through to arm 3 untouched. Kept as a
// caller-side classifier (NOT seam grammar) reused below against the
// `boldField` node's own parsed `value`.
const isTemplatePlaceholder = (value: string): boolean => {
const trimmed = value.trim();
return /^\[\s*Number of plans\b[\s\S]*\]$/i.test(trimmed);
};
// Positive detectors for the two "real count token" shapes (#2853 /
// #3584 Finding B): a fraction count (`N/M plans complete|executed`) or a
// bare singular/plural count (`N plan`/`N plans`, the fresh single-plan
// template shape at msd-core/templates/roadmap.md:62). Either one is an
// existing count token to overwrite (arm 1), never template placeholder
// (arm 2) or freeform prose (arm 3).
// #4906 regression fix: PREFIX match (not full-string) — a real count
// token may have a glued-on annotation with no ` — ` separator (e.g.
// `0/1 plans executed (11-16 are gap closure from VERIFICATION)`), which
// `parseBoldFieldLine`'s em-dash-only trailing-content split leaves
// entirely inside `value` (TRAILING_SEPARATOR_RE in planning-document.cts
// is unchanged and correct — this is a caller-side classification fix,
// not a seam fix). Returns the matched prefix length, or -1 if no match.
const fractionCountPrefixLength = (value: string): number => {
const m = value.match(/^\d+\s*\/\s*\d+\s+plans(?:\s+(?:complete|executed))?/i);
return m ? m[0].length : -1;
};
const bareCountPrefixLength = (value: string): number => {
const m = value.match(/^\d+\s+plans?/i);
return m ? m[0].length : -1;
};
roadmapContent = replaceInCurrentMilestone(roadmapContent, planSectionPattern, (sectionText: string): string => {
const parsed = parsePlanningDoc(sectionText, 'ROADMAP.md');
if (!parsed.ok) {
// Unreadable section (e.g. an unterminated frontmatter fence) —
// leave it byte-identical rather than throwing.
return sectionText;
}
const fieldId = findField(parsed.value, 'Plans');
if (!fieldId) {
// #4906 regression (#1163, caught by msd-test): a hand-edited or
// pre-template ROADMAP.md may carry a PLAIN (non-bold) `Plans:` line
// rather than the canonical `**Plans**:`/`**Plans:**` bold field.
// BOLD_FIELD_RE is deliberately bold-only (widening it to any bare
// `Label:` would register ordinary prose like "Note: see below" as a
// spurious field seam-wide) — this is domain knowledge about ONE
// field's legacy tolerated shape, the same class of thing
// `isTemplatePlaceholder` above already keeps caller-side rather
// than seam grammar, so the fallback lives here, not in
// planning-document.cts.
const plainMatch = sectionText.match(/^([ \t]*)Plans:([ \t]*)([^\r\n]*)$/m);
if (!plainMatch) {
// No `**Plans:**`/`**Plans**:`/plain `Plans:` line in this phase's
// own section — nothing to write; not a failure (mirrors the old
// regex's silent no-match no-op).
return sectionText;
}
const [whole, indent, spacing, plainValue] = plainMatch;
const plainFractionLen = fractionCountPrefixLength(plainValue);
const plainBareLen = bareCountPrefixLength(plainValue);
const plainCountPrefixLen = plainFractionLen >= 0 ? plainFractionLen : plainBareLen;
if (plainCountPrefixLen < 0 && !isTemplatePlaceholder(plainValue)) {
// Arm 3: freeform prose, TBD, a bracketed human annotation, or an
// empty value — leave the section exactly as it was.
return sectionText;
}
const plainSuffix = plainCountPrefixLen >= 0 ? plainValue.slice(plainCountPrefixLen) : '';
const newPlainLine = `${indent}Plans:${spacing}${planCountText}${plainSuffix}`;
const start = plainMatch.index ?? sectionText.indexOf(whole);
return sectionText.slice(0, start) + newPlainLine + sectionText.slice(start + whole.length);
}
const current = readNode(parsed.value, fieldId);
if (!current.ok) {
return sectionText;
}
const currentValue = current.value;
const fractionPrefixLen = fractionCountPrefixLength(currentValue);
const barePrefixLen = bareCountPrefixLength(currentValue);
const countPrefixLen = fractionPrefixLen >= 0 ? fractionPrefixLen : barePrefixLen;
if (countPrefixLen < 0 && !isTemplatePlaceholder(currentValue)) {
// Arm 3: freeform prose, TBD, a bracketed human annotation, or an
// empty value — leave the section exactly as it was.
return sectionText;
}
// Arm 1 (real count token, possibly with a glued-on no-separator
// annotation re-attached verbatim as `suffix`) or arm 2 (fresh-template
// placeholder, whole value replaced): write the computed count.
// `setFieldValue`'s valueSpan/trailingSpan split additionally preserves
// any EM-DASH-separated trailing annotation (#2853) automatically — no
// separate "preserve trailing" branch needed for that shape.
const suffix = countPrefixLen >= 0 ? currentValue.slice(countPrefixLen) : '';
const newValueToWrite = planCountText + suffix;
const staged = setFieldValue(parsed.value, fieldId, newValueToWrite);
if (!staged.ok) {
return sectionText;
}
const out = serialize(staged.value);
if (!out.ok) {
// `hasUnreadableNodes` refusal (ADR-4910 amendment) — a ragged
// SIBLING node elsewhere in this same section refuses the whole
// splice. Never throw; leave the section unchanged.
return sectionText;
}
return out.value;
});
// If complete: check checkbox
if (isComplete) {
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
}
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md", "- [ ] 50-01:", or "- [ ] **50-01**")
// #4741: tick only plans the phase's own COUNT still counts. `plans` is the
// superseded-filtered set (#2349 via scanPhasePlans) while `summaries` is
// the raw *-SUMMARY.md listing — a superseded plan can carry a SUMMARY
// (e.g. `status: halted`), and ticking it read as "executed" right under a
// count line that excludes it. The prefix match mirrors the checkbox regex
// below (rows match by planId prefix, which the PLAN-01.md naming shape
// relies on), so non-superseded plans tick exactly as before.
const tickableSummaries = phaseInfo!.summaries
.map((summaryFile) => ({ summaryFile, planId: summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '') }))
.filter(({ planId }) => planId !== '' && phaseInfo!.plans.some((planFile) => planFile.startsWith(planId)));
for (const { planId } of tickableSummaries) {
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
'i'
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
// Compute the active (post-</details>) region offset ONCE. Both the
// missing-plan DETECTION and the row INSERTION must use the same active
// region string so that a plan row that exists only in an archived <details>
// block is not counted as "already present" in the active milestone section.
// (Finding 1 code-review: detection was previously running against the full
// roadmapContent, causing archived rows to suppress active-section inserts.)
const lastDetailsClose = roadmapContent.lastIndexOf('</details>');
const activeRegion = lastDetailsClose === -1
? roadmapContent
: roadmapContent.slice(lastDetailsClose + '</details>'.length);
// Compute which plan files are MISSING a checkbox row in the ACTIVE region.
// This handles three cases:
// (a) Fresh template — no rows at all: all plans are missing.
// (b) Partial gap — some rows exist, others don't: only the absent ones.
// (c) All rows present — nothing to insert (idempotent).
//
// Detection is scoped to the active region so a plan that appears in an
// archived <details> block is still correctly detected as missing from the
// active milestone section.
//
// #4786: a plan row may be written WITH the `-PLAN.md` suffix (canonical
// template form) or WITHOUT it (hand-written form: `- [x] 659-01 — desc`).
// The tick loop above keys on the bare planId stem with a PREFIX match
// (pre-existing, out of scope here), while detection required the full
// `-PLAN.md` filename — so every suffix-less row was counted missing and
// the insertion fired BESIDE the recognized list — 32 checkbox lines for
// 16 plans, exit 0. The stem arm below accepts the bare id only up to a
// boundary (whitespace / `:` / dashes / `-PLAN.md` / `.md` / `**` / `)`),
// so `5-011` never satisfies `5-01`. Deliberately NOT in the boundary set:
// `.` — a dotted sub-id (`5-01.5`) is a real distinct plan, and counting
// its row as 5-01's presence would suppress a genuine insertion.
const missingPlans = phaseInfo!.plans.filter((planFile) => {
const planEscaped = escapeRegex(planFile);
if (new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(activeRegion)) return false;
const stem = planFile.replace(/-PLAN\.md$/i, '');
const stemEscaped = escapeRegex(stem);
const stemPresent = new RegExp(
`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${stemEscaped}(?=$|\\s|:|—|–|-PLAN\\.md|\\.md|\\*\\*|\\))`,
'i'
).test(activeRegion);
return !stemPresent;
});
if (missingPlans.length > 0) {
// Insert missing plan checklist rows (#1163). We prefer to anchor to the
// bare `Plans:` checklist header (canonical template form) and fall back to
// the bold `**Plans**:`/`**Plans:**` summary line only when no bare header
// is present. Using two separate patterns avoids the lazy-quantifier trap
// where a single alternation would stop at the first matching alternative
// (the bold summary) before reaching the checklist header.
//
// Canonical template (msd-core/templates/roadmap.md) uses BOTH lines:
// **Plans**: N plans ← summary (colon outside bold)
// Plans: ← checklist header (PREFERRED insertion anchor)
// Rows must land after `Plans:`, not between the summary and the header.
//
// Pattern A: anchor to bare `Plans:` header (preferred).
// Pattern B: fallback to bold summary when no bare header exists.
const insertRowsPatternA = new RegExp(
`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
'i'
);
const insertRowsPatternB = new RegExp(
`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
'i'
);
const sortedMissing = [...missingPlans].sort();
const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
const inserter = (match: string) => `${match}\n${newRows}`;
// Scope insertion to the active (post-</details>) milestone region to
// prevent duplicate phase headings in archived sections from receiving rows.
// replaceInCurrentMilestone only accepts a string replacement, so we
// perform the scoped replace manually here (same strategy as that helper).
// Note: lastDetailsClose was computed above (shared with detection).
const scopedReplace = (src: string, pat: RegExp) => src.replace(pat, inserter);
let withRows: string;
if (lastDetailsClose === -1) {
// activeRegion === roadmapContent when there are no </details> blocks.
const regionA = scopedReplace(activeRegion, insertRowsPatternA);
withRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
} else {
const beforeDetails = roadmapContent.slice(0, lastDetailsClose + '</details>'.length);
const regionA = scopedReplace(activeRegion, insertRowsPatternA);
const afterWithRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
withRows = beforeDetails + afterWithRows;
}
if (withRows !== roadmapContent) {
roadmapContent = withRows;
// Mark any newly-inserted rows that already have summaries as complete
// (#4741: same superseded-filtered tick list as the loop above — a
// pre-existing superseded row must stay unchecked on this path too).
for (const { planId } of tickableSummaries) {
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
'i'
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
}
}
// #3957 (B9.4): write and report an update only when the transforms
// above actually produced different bytes — mirroring the sibling
// `cmdRoadmapAnnotateDependencies`'s existing `nextContent !== content`
// gate. Previously this wrote and reported `updated: true`
// unconditionally, even on an idempotent re-run that changed nothing.
//
// #4247: ...but ONLY when a writable representation of THIS phase was
// found. Without the target gate, a byte delta from an unrelated
// transform (the un-scoped plan-checkbox mark) satisfied the #3957 gate
// and produced a success-shaped `updated: true` while the phase's own
// row stayed untouched — and the file-global write let the platform
// write seam's markdown normalization reflow unrelated entries. When no
// target exists the command refuses: no write at all, so ROADMAP.md is
// left byte-identical, and the caller gets a typed
// `missing_phase_details` decline instead of a false green.
const phaseRepresentationFound = tableRowFound || headingTargetFound || checklistTargetFound;
if (phaseRepresentationFound && roadmapContent !== originalContent) {
platformWriteSync(roadmapPath, roadmapContent);
updated = true;
}
if (!phaseRepresentationFound) {
missingPhaseDetails = true;
}
});
const computed = {
phase: phaseNum,
plan_count: planCount,
summary_count: summaryCount,
status,
complete: isComplete,
verification_stale_check_indeterminate: verificationStaleCheckIndeterminate,
};
if (updated) {
output({ updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`);
} else if (missingPhaseDetails) {
// #4247: honest refusal — the reason names the real condition (the
// analyzer's `missing_phase_details` vocabulary), never "already
// reflects", which was false: the ROADMAP was never able to record this
// phase's progress in the first place.
declineNoOp(
raw,
'updated',
'missing_phase_details',
`roadmap update-plan-progress skipped — ROADMAP.md has no writable entry for phase ${formatDiagnosticToken(String(phaseNum))} (no matching Progress-table row, no phase detail section, and no checklist entry this command can update). ROADMAP.md was left unchanged.`,
computed,
);
} else {
declineNoOp(
raw,
'updated',
"no changes were needed — ROADMAP.md already reflects this phase's plan/summary counts and status",
"roadmap update-plan-progress skipped — no changes were needed; ROADMAP.md already reflects this phase's plan/summary counts and status.",
computed,
);
}
}
// ─── cmdRoadmapAnnotateDependencies ───────────────────────────────────────────
/**
* Annotate the ROADMAP.md plan list for a phase with wave dependency notes
* and a cross-cutting constraints subsection derived from PLAN frontmatter.
*
* Wave dependency notes: "Wave 2 — blocked on Wave 1 completion" inserted as
* bold headers before each wave group in the plan checklist.
*
* Cross-cutting constraints: must_haves.truths strings that appear in 2+ plans
* are surfaced in a "Cross-cutting constraints" subsection below the plan list.
*
* The operation is idempotent: if wave headers already exist in the section
* the function returns without modifying the file.
*/
function cmdRoadmapAnnotateDependencies(cwd: string, phaseNum: string | null | undefined, raw: boolean): void {
if (!phaseNum) {
error('phase number required for roadmap annotate-dependencies');
}
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
declineNoOp(raw, 'updated', 'ROADMAP.md not found', 'roadmap annotate-dependencies skipped — ROADMAP.md not found.');
return;
}
const phaseInfo = findPhaseInternal(cwd, phaseNum);
// #3957 (B9.1): distinguish "phase does not resolve at all" from "phase
// resolves but has zero plans" — previously both collapsed into the same
// 'no plans found for phase' reason, which is simply false for the first
// case (there IS no such phase to have plans).
if (!phaseInfo) {
declineNoOp(
raw,
'updated',
`phase ${phaseNum} not found`,
`roadmap annotate-dependencies skipped — phase ${formatDiagnosticToken(String(phaseNum))} not found.`,
{ phase: phaseNum },
);
return;
}
if (phaseInfo.plans.length === 0) {
declineNoOp(
raw,
'updated',
`phase ${phaseNum} has no plans`,
`roadmap annotate-dependencies skipped — phase ${formatDiagnosticToken(String(phaseNum))} has no plans.`,
{ phase: phaseNum },
);
return;
}
// Read each PLAN.md and extract wave + must_haves.truths
const planData: Array<{ planFile: string; planId: string; wave: number; truths: unknown[] }> = [];
for (const planFile of phaseInfo.plans) {
const planPath = path.join(path.resolve(cwd, phaseInfo.directory), planFile);
try {
const content = fs.readFileSync(planPath, 'utf-8');
const fm = extractFrontmatter(content, planPath);
const wave = parseInt(fm.wave as string, 10) || 1;
const planId = planFile.replace(/-PLAN\.md$/i, '').replace(/PLAN\.md$/i, '');
const truths = parseMustHavesBlock(content, 'truths') || [];
planData.push({ planFile, planId, wave, truths });
} catch { /* skip unreadable plans */ }
}
if (planData.length === 0) {
declineNoOp(
raw,
'updated',
'could not read plan frontmatter',
'roadmap annotate-dependencies skipped — could not read plan frontmatter for any plan in this phase.',
);
return;
}
// Group plans by wave (sorted)
const waveGroups = new Map<number, typeof planData>();
for (const p of planData) {
if (!waveGroups.has(p.wave)) waveGroups.set(p.wave, []);
waveGroups.get(p.wave)!.push(p);
}
const waves = [...waveGroups.keys()].sort((a, b) => a - b);
// Find cross-cutting truths: appear in 2+ plans (de-duplicated, case-insensitive).
//
// Issue #2770: must **coerce, not skip**. A previous guard
// `if (typeof t !== 'string') continue` silently dropped numeric scalars
// (YAML ints like `- 3`) and kv-shaped truths (`- title: X`), so the
// cross-cutting analysis lost real constraints rather than crashing on
// `t.trim()`. We coerce primitives via `String(t)` and extract a sensible
// string field from object-shaped items produced by parseMustHavesBlock's
// continuation-kv path (issue #2757 produces those shapes for nested keys).
const truthCounts = new Map<string, TruthValue>();
for (const { truths } of planData) {
const seen = new Set<string>();
for (const t of truths) {
const text = coerceTruthToString(t);
if (!text) continue;
const trimmed = text.trim();
const key = trimmed.toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
if (!truthCounts.has(key)) truthCounts.set(key, { count: 0, text: trimmed });
truthCounts.get(key)!.count++;
}
}
const crossCuttingTruths = [...truthCounts.values()]
.filter(v => v.count >= 2)
.map(v => v.text);
// Patch ROADMAP.md
let updated = false;
withPlanningLock(cwd, () => {
const content = fs.readFileSync(roadmapPath, 'utf-8');
// #3413: preserve the file's own EOL style when the checklist block below
// is rebuilt and spliced back in — splitLines() cleans each captured line
// of any dangling \r, so rejoining with a bare '\n' would silently
// downgrade a CRLF ROADMAP.md's rewritten block to LF only.
const eol = detectEol(content);
// Find the phase section.
// #3537: padding-tolerant fragment so the caller's resolved padded id
// matches un-padded ROADMAP headings.
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*)`, 'i');
const phaseMatch = content.match(phaseHeaderPattern);
if (!phaseMatch) return;
const phaseStart = phaseMatch.index!;
const restAfterHeader = content.slice(phaseStart);
const nextPhaseOffset = restAfterHeader.slice(1).search(/\n#{2,4}\s+Phase\s+\d/i);
const phaseEnd = nextPhaseOffset >= 0 ? phaseStart + 1 + nextPhaseOffset : content.length;
const phaseSection = content.slice(phaseStart, phaseEnd);
// Idempotency: skip if annotation markers already present
if (
/\*\*Wave\s+\d+/i.test(phaseSection) ||
/\*\*Cross-cutting constraints:\*\*/i.test(phaseSection)
) return;
// Find the Plans: section within the phase section.
// #3691 Bug 1: `Plans:\s*\n` required no text after the colon, missing variants like
// `Plans: 3 plans across 2 waves\n` or `**Plans:** 3 plans\n` (bold-wrapped).
// `\*{0,2}Plans\*{0,2}:[^\n]*\n` accepts any text (or none) after the colon
// and tolerates optional `**` markdown bold wrappers on either side.
// The checklist group uses `+` (not `*`) so that a bold `**Plans:**` description
// line with no immediately-following checklist items (e.g. a summary line above a
// separate bare `Plans:` block) does not consume the match and prevent the actual
// list from being found.
// Review fix (F2): `(?:^|\n)` anchors the match to start-of-line so mid-line
// occurrences like `***Plans:***` embedded in a sentence or `OpenPlans: foo`
// do not trigger a false match. Groups 1 and 2 retain the same semantics.
// #3415: empirically verified linear-time to 10.9MB / 320,000 lines of adversarial
// checklist input (0.31ms@1000 lines -> 8.4ms@320,000 lines). The outer `+` group has
// no trailing constraint after it in the pattern, so a successful greedy pass never
// needs to explore alternate `\r?\n?` boundary partitions to satisfy something later —
// it accepts the first complete parse and stops, which rules out the #2128-class
// ambiguous-boundary blowup despite the nested-quantifier shape. Non-global match on
// already phase-sliced content, not the whole file.
// eslint-disable-next-line local/no-unbounded-quantifier -- outer `+` has no trailing constraint to force re-partitioning; measured linear to 10.9MB
const plansBlockMatch = phaseSection.match(/(?:^|\r?\n)(\*{0,2}Plans\*{0,2}:[^\r\n]*\r?\n)((?:\s*-\s*\[[ x]\][^\r\n]*\r?\n?)+)/i);
if (!plansBlockMatch) return;
const plansHeader = plansBlockMatch[1];
const existingList = plansBlockMatch[2];
const listLines = splitLines(existingList).filter(l => /^\s*-\s*\[/.test(l));
if (listLines.length === 0) return;
// #314 perf: build a first-wins Map so per-line lookup is O(1) instead of O(plans).
// First-wins mirrors .find() semantics: if the same planId appears more than once
// in planData, the earlier entry wins — identical to what .find() returned before.
const planById = new Map<string, typeof planData[number]>();
for (const p of planData) {
if (!planById.has(p.planId)) planById.set(p.planId, p);
}
// Build wave-annotated plan list
const linesByWave = new Map<number, string[]>();
for (const line of listLines) {
// Match plan ID from line: "- [ ] 01-01-PLAN.md — ..." or "- [ ] 01-01: ..."
// #3691 Bug 3: `[\w-]+?` excluded `.`, so decimal IDs like `02.3-01` were captured
// as `02` only and never matched planData entries. `[\w.-]+?` preserves the
// terminating alternation (`-PLAN.md|.md|:|\s—`) as the boundary anchor.
const idMatch = line.match(/\[\s*[x ]\s*\]\s*([\w.-]+?)(?:-PLAN\.md|\.md|:|\s—)/i);
const planId = idMatch ? idMatch[1] : null;
// Review fix (F3): reject malformed IDs that start with `.`, contain consecutive
// dots, or otherwise violate the `^\w[\w.-]*$` contract. A leading-dot ID
// (e.g. `.invalid-PLAN.md`) would silently default to wave 1 — defensively
// skip the line instead so corrupted ROADMAP entries don't corrupt wave layout.
if (planId && !/^\w[\w.-]*$/.test(planId)) continue;
const planEntry = planId ? (planById.get(planId) || null) : null;
const wave = planEntry ? planEntry.wave : 1;
if (!linesByWave.has(wave)) linesByWave.set(wave, []);
linesByWave.get(wave)!.push(line);
}
const annotatedLines: string[] = [];
const sortedWaves = [...linesByWave.keys()].sort((a, b) => a - b);
for (let i = 0; i < sortedWaves.length; i++) {
const w = sortedWaves[i];
const waveLines = linesByWave.get(w)!;
if (sortedWaves.length > 1) {
const dep = i > 0 ? ` *(blocked on Wave ${sortedWaves[i - 1]} completion)*` : '';
annotatedLines.push(`**Wave ${w}**${dep}`);
}
annotatedLines.push(...waveLines);
if (i < sortedWaves.length - 1) annotatedLines.push('');
}
// Append cross-cutting constraints subsection if any found
if (crossCuttingTruths.length > 0) {
annotatedLines.push('');
annotatedLines.push('**Cross-cutting constraints:**');
for (const t of crossCuttingTruths) {
annotatedLines.push(`- ${t}`);
}
}
const newListBlock = joinLines(annotatedLines, eol) + eol;
// #1103: when `(?:^|\r?\n)` consumed a leading terminator (mid-string
// match), re-emit it verbatim so the line preceding the Plans: header is
// not fused onto it. #3413: the widened `(?:^|\r?\n)` can now consume a
// 2-char `\r\n` — re-emit whatever was actually captured (`''`, `'\n'`,
// or `'\r\n'`), not a hardcoded `'\n'`, or a CRLF file loses its `\r`.
const leadingMatch = /^\r?\n/.exec(plansBlockMatch[0]);
const leadingNewline = leadingMatch ? leadingMatch[0] : '';
// Review fix (#3413 security): use the FUNCTION-replacement form. The
// string-replacement form expands String#replace's special patterns
// (`$&`, `` $` ``, `$'`, `$$`, `$1`-`$9`) inside the replacement — and
// newListBlock is built from author-controlled truths/plan-file content,
// so a line containing a literal `` $` `` (etc.) would splice unrelated
// surrounding phaseSection text into the result. A function replacer is
// never pattern-interpreted.
const newPhaseSection = phaseSection.replace(
plansBlockMatch[0],
() => leadingNewline + plansHeader + newListBlock
);
const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
if (nextContent === content) return;
platformWriteSync(roadmapPath, nextContent);
updated = true;
});
output({
updated,
phase: phaseNum,
waves: waves.length,
cross_cutting_constraints: crossCuttingTruths.length,
}, raw, updated ? `annotated ${waves.length} wave(s), ${crossCuttingTruths.length} constraint(s)` : 'skipped (already annotated or no plan list)');
}
export = {
cmdRoadmapGetPhase,
getRoadmapPhaseWithFallback,
cmdRoadmapAnalyze,
cmdRoadmapMilestoneScope,
cmdRoadmapUpdatePlanProgress,
cmdRoadmapAnnotateDependencies,
buildPhaseHeadingRegex,
};