* test(#3118): failing-first coverage for the dead injectables and the shell projection Adds the counter-tests Wave 4 closes against, before any fix: - antigravityWatermark had zero test references. The four existing tests that look like watermark coverage hand the fallback a literal mark and never call the producer, so nothing pinned whether a real run's mark is correct. Covers all six branches plus the non-object cache classes. - Pins the fail-open: a transcript read that throws reports lines:0, indistinguishable from a genuinely empty transcript, and the consumer then replays a previous run's review as this run's. - Pins the export-line escaping across the repair, persist and win32 bash lanes, including the parity assertion that they must not diverge. - sliceCurrentPositionSection: empty-vs-absent, fenced heading, second occurrence, H3, CRLF. - Proves deps.progressProvider is inert by supplying a throwing stub to all ten transition intents. Verification through the remote runner only. Refs #3118 * fix(#3118): distinguish an unreadable transcript from an empty one antigravityWatermark's final read can throw on a transcript that indisputably exists. It returned lines:0, which is the same value a genuinely empty transcript produces, so the caller could not tell the two apart. antigravityTranscriptFallback derives its skip from that count. A mark of {convId:'c1', lines:0} for a conversation that pre-dates the run makes it skip nothing and return the last PLANNER_RESPONSE in a transcript written before this run started — a previous review presented as this one's, which is exactly what the function's own 'never stale' docstring promises cannot happen. The unreadable case now sets unreadable:true and the fallback declines for a same-conv-id unreadable mark. An absent or empty transcript is untouched: those genuinely have zero prior lines. * fix(#3118): escape the export line for the file it lands in, not the echo Three lanes emit export PATH="<dir>:$PATH". repair escaped it with escapePosixDoubleQuoted; persist and the win32 Git Bash lane escaped it with escapeSingleQuotedShellLiteral instead. The single-quoting is correct for the echo, so nothing runs when the user pastes the command. But the bytes appended to ~/.bashrc are the export line itself, and inside double quotes in an rc file a $(...) or a backtick in the directory name is command substitution that runs on every new shell. Those characters are legal in a path on both POSIX and Windows, so the path was reachable. projectPathExportLine is now the single source of that line and escapes for its final rc-file context; each lane still applies its own transport escaping on top. fish keeps the single-quote escaper — its value really does stay single-quoted. The cmd.exe lane interpolated into a cmd double-quoted string with no cmd-level escaping, so a quote closed the region and &cmd& ran. A quote is reserved on Windows and cannot appear in a real path, so there is no correct command to suggest: the win32 lanes now fail closed for one. Metacharacter-free paths render byte-identically on every lane. * fix(#3118): drop a stray carriage return and a deps field nobody reads locateCurrentPosition subtracted a fixed one byte to exclude the newline before the next heading, which assumes LF. On a CRLF document the slice kept an unpaired trailing carriage return. It now walks back over the newline and over a preceding carriage return if there is one. StateTransitionDeps also required a progressProvider that 33 sites supplied and no site ever called. A required field nothing reads widens the module's interface without changing its implementation, which is the shape epic #3051 cites as its reason for refusing blanket injection. Removed along with the ProgressRecord alias that existed only as its return type; state-document.cts's unrelated interface of the same name is untouched. * fix(#3118): stop an empty span duplicating bytes, and name the empty results Three findings from the isolated review pass. locateCurrentPosition could return end < start when the section was empty and the next heading followed with no blank line between. Every mutator splices with slice(0,start) + body + slice(end), so an inverted span duplicated the region between them — a blank line silently inserted into STATE.md on every transition, two bytes on CRLF. The span is now clamped, and an empty section is a zero-length span, which is what it always meant. The win32 fail-closed path left the installer printing 'Add it with one of:' with nothing under it. An empty shellActions folded two different facts together, so projectPathActionProjection now carries a frozen PATH_ACTION_REASON and the installer branches on it. Two empty results with different causes staying distinguishable is the subject of the epic this belongs to. fish_add_path parses a leading dash as an option, so a directory named -v printed 'No paths to add' instead of being added. Verified against fish 4.8.1: the end-of-options separator fixes it. Replaces the console-prose test the second fix first arrived with — a regex over captured stdout is what CONTRIBUTING prohibits, and the typed reason is the surface it asks for instead. * fix(#3118): escape TOML control characters, and stop a test name overstating Five findings from the two review axes. escapeTomlDoubleQuotedString escaped only backslash and quote. TOML basic strings also require U+0000-U+0008, U+000A-U+001F and U+007F to be escaped, so a value carrying a raw newline or NUL wrote a config.toml no parser accepts — rejecting the whole file, not just that value. Four of its call sites write real config. Tab stays raw; the grammar exempts it. The byte-identity test claimed every lane was unchanged for an ordinary path, which is false: fish now takes the end-of-options separator on every path, not only hostile ones. Renamed, and the one intended delta now has its own named test instead of hiding inside a claim that read as broader than it was. Also: exact-equality assertions in place of substring checks that could pass on a subtly wrong escape, newline and null-byte cases for all five quoting primitives, and a temp dir registered with t.after so it is removed when an assertion fails. * docs(#3118): add the changeset fragments * fix(#3118): degrade instead of throwing on a null conversation cache A cache file whose whole content is the literal null — what a truncated or zeroed write leaves behind — made both antigravityWatermark and antigravityTranscriptFallback throw. JSON.parse('null') succeeds, so the try/catch wrapping the parse never fired, and resolveConvId then called hasOwnProperty on null. Both functions advertise the opposite; the existing test next to them is named 'a missing cache or transcript degrades to empty, never throws'. Parsing successfully is not the same fact as the payload being usable, and a guard that only wraps the parse cannot tell them apart. resolveConvId is now total for any non-object input, so one guard covers both callers. Caught by the null case in this wave's own cache matrix. * test(#3118): correct a stale fish expectation and a parity comparison The pre-existing 'POSIX persist mode escapes single quotes' test pinned fish_add_path without the end-of-options separator this wave adds, so it asserted behavior that is no longer correct. A repo-wide scan found one such hardcoded expectation; every other site derives its expectation from the projection. The new parity test compared the token from a POSIX path against the win32 lane, which posix-normalizes its input first — two different inputs, so the tokens differed for a reason that had nothing to do with the parity it claims to check. It now derives the win32 expectation from the same input the lane receives. * docs(#3118): reword a comment the injection scanner reads as an instruction The scanner pattern act\s+as\s+(?:a|an|the)\s+ carries no word boundary, so 'the same fact as the payload' matched on the tail of 'fact'. Reworded per the documented remedy for this collision. The missing boundary is a scanner defect rather than a prose problem — any contributor writing 'fact as the' trips it — but the pattern is gate plumbing, which the sibling epic owns, so it is surfaced rather than changed here. * chore(#3118): backfill changeset pr number to 3124 * chore(#3118): backfill changeset pr number to 3124 * fix(#2784): make the negation scan single-pass and index it correctly Three defects in the negation suppression added by #3127, all in one block, none of which had a test. The pair scan was verbs.some(nouns.some(...)) with a slice and a split per pair, so it grew cubically with clause length: 1.1ms before that PR and 8462ms after, on 800 verb+noun pairs in one clause. api-coverage's property test generates documents large enough to reach the runner's 600s file cap, which is why it hangs as 'fail 0, cancelled 1' rather than failing an assertion. Every (verb, noun) window is a subset of the single widest one, so one scan of that window answers the same question in a linear pass. Verified equivalent against the old predicate over 20,000 generated clauses. Both checks also subtracted clause.start from offsets that collectTerm- Matches already returns clause-local. The first clause on a line has start 0 so it worked there and nowhere else: later clauses went negative, and slice reads a negative index from the end, so suppression silently examined unrelated text. The comment claimed 'without any API integration' was suppressed. It is not — the qualifier sits outside the two-word lookback and the noun precedes the verb. Widening the window would trade a false positive that costs one declaration line for a false negative that slips a real integration past a blocking gate, so the behavior stands and the comment now says so. Pinned by a test. The qualifier sets were also rebuilt for every line of every document.
2857 lines
129 KiB
TypeScript
2857 lines
129 KiB
TypeScript
/**
|
|
* Phase — Phase CRUD, query, and lifecycle operations
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/phase.cjs collapsed to
|
|
* a TypeScript source of truth, compiled by tsc to a gitignored .cjs at the
|
|
* same require() path. Behaviour preserved byte-for-behaviour; only types are added.
|
|
*
|
|
* Re-export shim note (issue #4 / ADR-3524):
|
|
* The phase lifecycle pure-computation helpers live in phase-lifecycle.cjs.
|
|
* 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.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
|
|
import ioMod = require('./io.cjs');
|
|
const { output, error, ERROR_REASON } = ioMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
|
|
import configLoaderMod = require('./config-loader.cjs');
|
|
const { loadConfig } = configLoaderMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
|
import coreUtilsMod = require('./core-utils.cjs');
|
|
const { toPosixPath, generateSlugInternal, readSubdirectories, findUnsummarizedPlans } = coreUtilsMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
import phaseIdMod = require('./phase-id.cjs');
|
|
const {
|
|
escapeRegex,
|
|
normalizePhaseName,
|
|
phaseMarkdownRegexSource,
|
|
comparePhaseNum,
|
|
phaseTokenMatches,
|
|
isSentinelPhaseId,
|
|
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
|
OPTIONAL_PHASE_TAG_SOURCE,
|
|
PHASE_NUMBER_TOKEN_SOURCE,
|
|
} = phaseIdMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
|
import phaseLocatorMod = require('./phase-locator.cjs');
|
|
const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
|
|
import roadmapParserMod = require('./roadmap-parser.cjs');
|
|
const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter, currentMilestoneRawRanges, withPhaseSection } = roadmapParserMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
import frontmatterMod = require('./frontmatter.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
|
|
import stateMod = require('./state.cjs');
|
|
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs';
|
|
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
|
import { realClock } from './clock.cjs';
|
|
import { transitionCore } from './state-transition.cjs';
|
|
import { updateTableCell, deleteTableRow, escapeCell } from './markdown-table.cjs';
|
|
import { deleteSection, updateBullet } from './markdown-sectionizer.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
|
|
import uatPredicate = require('./uat-predicate.cjs');
|
|
const { evaluateUatPassed } = uatPredicate;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
|
|
import verificationMod = require('./verification.cjs');
|
|
// #2572: the artifact↔disk core behind the `verify-summary` verb. `verify.cts`
|
|
// has no transitive import path back to `phase.cts`, so this edge introduces no
|
|
// cycle (the reverse edge, `state.cts → verify.cjs`, would).
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verify.cjs is an export= CommonJS module
|
|
import verifyMod = require('./verify.cjs');
|
|
const { readVerificationStatus } = verificationMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
|
|
import planDependencyGraphMod = require('./plan-dependency-graph.cjs');
|
|
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphMod;
|
|
|
|
const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } =
|
|
planningWorkspace;
|
|
const { extractFrontmatter } = frontmatterMod;
|
|
const {
|
|
readModifyWriteStateMd,
|
|
stateExtractField,
|
|
stateReplaceField,
|
|
syncStateFrontmatter,
|
|
withStateLock,
|
|
updatePerformanceMetricsSection,
|
|
} = stateMod;
|
|
|
|
// #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
|
|
const isCanonicalPlanFile = (f: string): boolean => f.endsWith('-PLAN.md') || f === 'PLAN.md';
|
|
|
|
// Any .md file with PLAN anywhere in the basename — diagnostic net
|
|
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
|
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
|
const looksLikePlanFile = (f: string): boolean =>
|
|
/\.md$/i.test(f) &&
|
|
/PLAN/i.test(f) &&
|
|
!PLAN_OUTLINE_RE.test(f) &&
|
|
!PLAN_PRE_BOUNCE_RE.test(f);
|
|
|
|
/**
|
|
* Scope an `updateTableCell` call to the `## Traceability` (or
|
|
* `## Traceability Status`) heading's own section — up to the next H1/H2
|
|
* heading — instead of handing it the WHOLE REQUIREMENTS.md content.
|
|
*
|
|
* F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
|
|
* found in whatever text it is given. The shipped requirements template
|
|
* (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
|
|
* (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
|
|
* an unscoped whole-file call targets the Out-of-Scope table instead, fails
|
|
* with `{ok:false, reason:'unknown column: Status'}`, and the real
|
|
* Traceability row is never flipped, while the checkbox surface still flips
|
|
* and the command reports success (the #2140 silent-divergence class one
|
|
* level deeper). Mirrors `editProgressHeadingSlice` below, which scopes
|
|
* `## Progress` writes to that heading's own slice for the same reason.
|
|
*
|
|
* Falls back to running `updateTableCell` against the whole `text` when no
|
|
* `## Traceability` heading exists — matching the previous (unscoped)
|
|
* behaviour for a REQUIREMENTS.md whose traceability table sits under some
|
|
* other heading, or with no heading at all (never worse than before this fix).
|
|
*/
|
|
function updateTraceabilityCell(
|
|
text: string,
|
|
match: (row: Record<string, string>, index: number) => boolean,
|
|
column: string,
|
|
newValue: string | ((current: string) => string),
|
|
): ReturnType<typeof updateTableCell> {
|
|
const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
|
|
if (!headingMatch || headingMatch.index === undefined) {
|
|
return updateTableCell(text, match, column, newValue);
|
|
}
|
|
const headingOffset = headingMatch.index;
|
|
const before = text.slice(0, headingOffset);
|
|
const fromHeading = text.slice(headingOffset);
|
|
const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
|
|
const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
|
|
|
|
const result = updateTableCell(scoped, match, column, newValue);
|
|
if (!result.ok) return result;
|
|
return { ok: true, value: before + result.value + after };
|
|
}
|
|
|
|
/**
|
|
* Extract the MAJOR version segment from a version-ish string: "v1", "v1.3",
|
|
* "V1.0", and "1.0" all yield "1"; "v2" yields "2". Used (#2334 BLOCKER fix)
|
|
* to compare a `## v<N> ...` REQUIREMENTS.md heading against the current
|
|
* milestone's version at MAJOR-version granularity only — "v1" heading vs
|
|
* milestone "v1.3" is the SAME major version and must not be treated as a
|
|
* version mismatch. Returns null when `raw` has no leading digit run (not a
|
|
* version-shaped string), which the caller treats as "cannot resolve".
|
|
*/
|
|
function extractMajorVersion(raw: string): string | null {
|
|
const m = raw.trim().match(/^v?(\d+)/i);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
function describeNonCanonicalPlans(dirFiles: string[], matchedFiles: string[]): string | null {
|
|
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: string): string {
|
|
const base = filename
|
|
.replace(/-PLAN\.md$/i, '')
|
|
.replace(/-SUMMARY\.md$/i, '')
|
|
.replace(/\.md$/i, '');
|
|
const parts = base.split('-').filter(Boolean);
|
|
// #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
|
|
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
|
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
|
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
|
// #2232: the PAIRED plan component is a zero-padded continuation segment
|
|
// (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
|
|
// bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
|
|
// \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
|
|
const planTokenRe = new RegExp(
|
|
`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`,
|
|
'i',
|
|
);
|
|
const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
|
|
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
|
|
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
interface PhaseListOptions {
|
|
type?: string;
|
|
phase?: string;
|
|
includeArchived?: boolean;
|
|
}
|
|
|
|
function cmdPhasesList(cwd: string, options: PhaseListOptions, raw: boolean): void {
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
const { type, phase, includeArchived } = options;
|
|
|
|
if (!fs.existsSync(phasesDir)) {
|
|
if (type) {
|
|
output({ files: [], count: 0 }, raw, '');
|
|
} else {
|
|
output({ directories: [], count: 0 }, raw, '');
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
let dirs: string[] = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
|
|
if (includeArchived) {
|
|
const archived = getArchivedPhaseDirs(cwd);
|
|
for (const a of archived) {
|
|
dirs.push(`${a.name} [${a.milestone}]`);
|
|
}
|
|
}
|
|
|
|
dirs.sort((a, b) => comparePhaseNum(a, b));
|
|
|
|
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 (type) {
|
|
const files: string[] = [];
|
|
const warnings: string[] = [];
|
|
for (const dir of dirs) {
|
|
const dirPath = path.join(phasesDir, dir);
|
|
const dirFiles = fs.readdirSync(dirPath);
|
|
|
|
let filtered: string[];
|
|
if (type === 'plans') {
|
|
filtered = dirFiles.filter(isCanonicalPlanFile);
|
|
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: Record<string, unknown> = {
|
|
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;
|
|
}
|
|
|
|
output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
error('Failed to list phases: ' + msg);
|
|
}
|
|
}
|
|
|
|
function cmdPhaseNextDecimal(cwd: string, basePhase: string, raw: boolean): void {
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
const normalized = normalizePhaseName(basePhase);
|
|
|
|
try {
|
|
let baseExists = false;
|
|
const decimalSet = new Set<number>();
|
|
|
|
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(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`);
|
|
for (const dir of dirs) {
|
|
const match = dir.match(dirPattern);
|
|
if (match) decimalSet.add(parseInt(match[1], 10));
|
|
}
|
|
}
|
|
|
|
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
|
if (fs.existsSync(roadmapPath)) {
|
|
try {
|
|
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
const phasePattern = new RegExp(
|
|
`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
|
|
'gi',
|
|
);
|
|
let pm: RegExpExecArray | null;
|
|
while ((pm = phasePattern.exec(roadmapContent)) !== null) {
|
|
decimalSet.add(parseInt(pm[1], 10));
|
|
}
|
|
} catch {
|
|
/* ROADMAP.md read failure is non-fatal */
|
|
}
|
|
}
|
|
|
|
const existingDecimals = Array.from(decimalSet)
|
|
.sort((a, b) => a - b)
|
|
.map((n) => `${normalized}.${n}`);
|
|
|
|
let nextDecimal: string;
|
|
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) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
error('Failed to calculate next decimal phase: ' + msg);
|
|
}
|
|
}
|
|
|
|
function getRoadmapModeForPhase(cwd: string, phaseNum: string): string | null {
|
|
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}${OPTIONAL_PHASE_TAG_SOURCE}\\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 as number)
|
|
: 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: string, args: string[], raw: boolean): void {
|
|
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: string, phase: string, raw: boolean): void {
|
|
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: [] as string[],
|
|
};
|
|
|
|
const searchDirs: string[] = [];
|
|
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));
|
|
|
|
// #2237: fail loud when multiple directories match the same bare phase
|
|
// number — prevents cross-project file writes when unrelated projects
|
|
// share a .planning/phases/ tree.
|
|
const matches = dirs.filter((d) => phaseTokenMatches(d, normalized));
|
|
if (matches.length === 0) continue;
|
|
if (matches.length > 1) {
|
|
output({
|
|
...notFound,
|
|
ambiguous_matches: matches,
|
|
warning: `Phase ${normalized} is ambiguous: ${matches.length} directories match (${matches.map(m => `"${m}"`).join(', ')}). Set a distinct project_code in .planning/config.json to scope resolution.`,
|
|
}, raw, '');
|
|
return;
|
|
}
|
|
const match = matches[0];
|
|
|
|
const dirMatch =
|
|
match.match(
|
|
new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')
|
|
) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, '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();
|
|
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans);
|
|
|
|
const result: Record<string, unknown> = {
|
|
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: string): string | null {
|
|
const m = content.match(/<objective>\s*\n?\s*(.+)/);
|
|
return m ? m[1].trim() : null;
|
|
}
|
|
|
|
interface RawPlan {
|
|
id: string;
|
|
declaredWave: number | null;
|
|
dependsOn: string[];
|
|
autonomous: boolean;
|
|
objective: string | null;
|
|
filesModified: string[];
|
|
taskCount: number;
|
|
hasSummary: boolean;
|
|
/** #2830: true iff this plan's own SUMMARY declares `status: halted` (a designed stop). */
|
|
halted: boolean;
|
|
}
|
|
|
|
/**
|
|
* Resolve a raw `depends_on` token to the `RawPlan.id` it refers to
|
|
* (case-folded exact match, falling back to canonical-id matching). Returns
|
|
* `null` when the token does not resolve to any plan in this phase (a typo
|
|
* or a cross-phase reference) — every call site treats that as "ignore this
|
|
* edge", never a throw. Shared by `computeDependencyLevels`'s DAG-edge
|
|
* resolution, the `depends_on` display mapping, and (#2830) the
|
|
* halt-propagation node resolution, so the three can never disagree about
|
|
* which token resolves to which plan.
|
|
*/
|
|
function resolveDependencyId(
|
|
dep: string,
|
|
planMap: Map<string, RawPlan>,
|
|
canonicalToId: Map<string, string>,
|
|
): string | null {
|
|
const lower = dep.toLowerCase();
|
|
return planMap.has(lower) ? (planMap.get(lower) as RawPlan).id : (canonicalToId.get(lower) ?? null);
|
|
}
|
|
|
|
// O(V + E). Assigns each in-phase plan its longest-path topological level over the
|
|
// in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number,
|
|
// order: string[] }. visited < rawPlans.length signals a dependency cycle. `order` (#2830) is
|
|
// the exact dequeue order this pass already produces — a valid topological order — passed to
|
|
// computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
|
|
// algorithm a second time over the same graph.
|
|
function computeDependencyLevels(
|
|
rawPlans: RawPlan[],
|
|
planMap: Map<string, RawPlan>,
|
|
canonicalToId: Map<string, string>,
|
|
): { level: Map<string, number>; visited: number; order: string[] } {
|
|
const level = new Map<string, number>();
|
|
const inDeg = new Map<string, number>();
|
|
const adj = new Map<string, string[]>();
|
|
|
|
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) {
|
|
const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId);
|
|
if (!resolvedDep) continue;
|
|
if (!adj.has(resolvedDep)) adj.set(resolvedDep, []);
|
|
(adj.get(resolvedDep) as string[]).push(p.id);
|
|
inDeg.set(p.id, (inDeg.get(p.id) ?? 0) + 1);
|
|
}
|
|
}
|
|
|
|
const queue: string[] = [];
|
|
for (const p of rawPlans) {
|
|
if ((inDeg.get(p.id) ?? 0) === 0) {
|
|
queue.push(p.id);
|
|
level.set(p.id, 0);
|
|
}
|
|
}
|
|
|
|
// Dequeue by head index (queue[head++]), NOT Array.shift(): shift() is O(n) per
|
|
// call in V8. Head-index dequeue is O(1) amortized -> O(V+E) overall. (#307)
|
|
let head = 0;
|
|
let visited = 0;
|
|
while (head < queue.length) {
|
|
const cur = queue[head++];
|
|
visited++;
|
|
const curLevel = level.get(cur) as number;
|
|
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) as number) - 1);
|
|
if (inDeg.get(dep) === 0) {
|
|
queue.push(dep);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { level, visited, order: queue };
|
|
}
|
|
|
|
function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
|
if (!phase) {
|
|
error('phase required for phase-plan-index');
|
|
}
|
|
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
const normalized = normalizePhaseName(phase);
|
|
|
|
let phaseDir: string | null = null;
|
|
let phaseDirName: string | null = 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: [], runnable: [], has_checkpoints: false },
|
|
raw,
|
|
);
|
|
return;
|
|
}
|
|
void phaseDirName; // used only to set phaseDir above
|
|
|
|
const phaseFiles = fs.readdirSync(phaseDir);
|
|
const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort();
|
|
const summaryFiles = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles);
|
|
|
|
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];
|
|
}),
|
|
);
|
|
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
|
|
// the actual summary filename, so a plan's own SUMMARY frontmatter can be
|
|
// read for its `status`. Shared builder (also used by phase-locator.cts's
|
|
// searchPhaseInDir) so the two can never disagree about which summary
|
|
// belongs to which plan.
|
|
const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
|
|
|
|
// ── Pass 1: parse each plan file ─────────────────────────────────────────
|
|
|
|
const rawPlans: RawPlan[] = [];
|
|
|
|
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');
|
|
// Pass planPath so a truncated PLAN.md names the file in the #1882 diagnostic.
|
|
const fm = extractFrontmatter(content, planPath);
|
|
|
|
const xmlTasks = content.match(/<task[\s>]/gi) || [];
|
|
const mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
|
|
const taskCount = xmlTasks.length || mdTasks.length;
|
|
|
|
const parsedWave = parseInt(fm['wave'] as string, 10);
|
|
const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
|
|
|
|
let dependsOn: string[] = [];
|
|
const fmDeps = fm['depends_on'];
|
|
if (Array.isArray(fmDeps)) {
|
|
dependsOn = fmDeps.map(String);
|
|
} else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
|
|
dependsOn = [fmDeps];
|
|
}
|
|
|
|
let autonomous = true;
|
|
if (fm['autonomous'] !== undefined) {
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison
|
|
autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true';
|
|
}
|
|
|
|
let filesModified: string[] = [];
|
|
const fmFiles = fm['files_modified'] || fm['files-modified'];
|
|
if (fmFiles) {
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
|
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
|
|
}
|
|
|
|
const hasSummary =
|
|
completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
|
|
|
|
// #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
|
|
// a designed stop still writes a completion record, just one whose status
|
|
// says "halted" rather than "complete". Only look up the summary file
|
|
// when one exists; there is nothing to read otherwise.
|
|
const summaryFile =
|
|
summaryFileByPlanId.get(planId) ?? summaryFileByPlanId.get(extractCanonicalPlanId(planFile));
|
|
const halted = hasSummary && summaryFile !== undefined
|
|
? isSummaryFileHalted(path.join(phaseDir, summaryFile))
|
|
: false;
|
|
|
|
rawPlans.push({
|
|
id: planId,
|
|
declaredWave,
|
|
dependsOn,
|
|
autonomous,
|
|
objective: extractObjective(content) || (fm['objective'] as string | null) || null,
|
|
filesModified,
|
|
taskCount,
|
|
hasSummary,
|
|
halted,
|
|
});
|
|
}
|
|
|
|
// ── Pass 2: topological level assignment via depends_on DAG ──────────────
|
|
|
|
const seenLower = new Map<string, string>();
|
|
for (const p of rawPlans) {
|
|
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);
|
|
}
|
|
|
|
const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
|
|
const canonicalToId = new Map(
|
|
rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]),
|
|
);
|
|
|
|
const { level, visited, order } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
|
|
|
|
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;
|
|
}
|
|
|
|
// #2830: single shared halt-propagation pass, reusing the SAME id
|
|
// resolution (planMap/canonicalToId) AND the SAME topological order
|
|
// (`order`, computeDependencyLevels's own Kahn's-algorithm dequeue
|
|
// sequence) — passed as `precomputedOrder` so computeHaltPropagation does
|
|
// NOT run Kahn's algorithm a second time over this graph.
|
|
const haltNodes = rawPlans.map((p) => ({
|
|
id: p.id,
|
|
resolvedDependsOn: p.dependsOn
|
|
.map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId))
|
|
.filter((id): id is string => id !== null),
|
|
halted: p.halted,
|
|
}));
|
|
const { blockedBy } = computeHaltPropagation(haltNodes, order);
|
|
|
|
// ── Pass 3: determine lowest bucket key and build output ─────────────────
|
|
|
|
const anyWaveZero = rawPlans.some((p) => p.declaredWave === 0);
|
|
const levelOffset = anyWaveZero ? 0 : 1;
|
|
|
|
const plans: Record<string, unknown>[] = [];
|
|
const waves: Record<string, string[]> = {};
|
|
const incomplete: string[] = [];
|
|
const runnable: string[] = [];
|
|
let hasCheckpoints = false;
|
|
const warnings: string[] = [];
|
|
|
|
for (const rawPlan of rawPlans) {
|
|
if (!rawPlan.autonomous) {
|
|
hasCheckpoints = true;
|
|
}
|
|
const blockedByIds = blockedBy.get(rawPlan.id) ?? [];
|
|
if (!rawPlan.hasSummary) {
|
|
incomplete.push(rawPlan.id);
|
|
// #2830: the runnable-only view — incomplete AND not transitively
|
|
// blocked by a halted upstream plan. Additive alongside `incomplete`,
|
|
// which keeps its existing "no SUMMARY yet" meaning unchanged.
|
|
if (blockedByIds.length === 0) {
|
|
runnable.push(rawPlan.id);
|
|
}
|
|
}
|
|
|
|
const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
|
|
const effectiveWave = computedWave;
|
|
if (rawPlan.declaredWave !== null && rawPlan.declaredWave !== computedWave) {
|
|
warnings.push(
|
|
`Plan ${rawPlan.id}: declared wave: ${rawPlan.declaredWave} but depends_on DAG places it in wave ${computedWave}`,
|
|
);
|
|
}
|
|
|
|
const plan: Record<string, unknown> = {
|
|
id: rawPlan.id,
|
|
wave: effectiveWave,
|
|
// DELIBERATELY not `resolveDependencyId`: the emitted field is a DISPLAY
|
|
// mapping, not the DAG resolution. It rewrites a dep only when it names a
|
|
// plan directly (planMap) and otherwise passes it through verbatim — a
|
|
// short canonical prefix like `24-01` stays `24-01` rather than becoming
|
|
// `24-01-auth-hardening`. #3785 pins that contract. Full resolution via
|
|
// canonicalToId is used for the wave DAG and #2830 halt propagation only;
|
|
// routing this line through it too silently changed the output shape.
|
|
depends_on: rawPlan.dependsOn.map((dep) => {
|
|
const lower = String(dep).toLowerCase();
|
|
return planMap.has(lower) ? (planMap.get(lower) as RawPlan).id : dep;
|
|
}),
|
|
autonomous: rawPlan.autonomous,
|
|
objective: rawPlan.objective,
|
|
files_modified: rawPlan.filesModified,
|
|
task_count: rawPlan.taskCount,
|
|
has_summary: rawPlan.hasSummary,
|
|
// #2830: additive fields — halted is this plan's OWN status; blocked_by
|
|
// names the halted plan(s) transitively upstream of it (empty when not
|
|
// blocked). Neither mutates has_summary/incomplete's existing meaning.
|
|
halted: rawPlan.halted,
|
|
blocked_by: blockedByIds,
|
|
};
|
|
|
|
plans.push(plan);
|
|
|
|
const waveKey = String(effectiveWave);
|
|
if (!waves[waveKey]) {
|
|
waves[waveKey] = [];
|
|
}
|
|
waves[waveKey].push(rawPlan.id);
|
|
}
|
|
|
|
const result: Record<string, unknown> = {
|
|
phase: normalized,
|
|
plans,
|
|
waves,
|
|
incomplete,
|
|
runnable,
|
|
has_checkpoints: hasCheckpoints,
|
|
};
|
|
if (planNamingWarning) result['warning'] = planNamingWarning;
|
|
if (warnings.length > 0) result['warnings'] = warnings;
|
|
|
|
output(result, raw);
|
|
}
|
|
|
|
// #2390 — phase.add title-shape heuristic. A description at or under this many
|
|
// characters, and with no sentence-ending punctuation followed by more text,
|
|
// reads as a short Title. Anything longer or multi-sentence reads as a Goal,
|
|
// not a Title. phase.add still writes the phase verbatim (it never mangles
|
|
// ROADMAP.md), but when the description looks goal-shaped the JSON result
|
|
// gains a `warning` key naming the gap, so the caller — or the orchestrating
|
|
// add-phase workflow — can split title vs. goal instead of the whole paragraph
|
|
// landing silently in the `### Phase N:` header.
|
|
const PHASE_ADD_TITLE_MAX_LEN = 80;
|
|
const PHASE_ADD_MULTI_SENTENCE_RE = /[.!?]['")\]]?\s+\S/;
|
|
|
|
function describeGoalShapedTitle(description: string): string | null {
|
|
const trimmed = description.trim();
|
|
const tooLong = trimmed.length > PHASE_ADD_TITLE_MAX_LEN;
|
|
const multiSentence = PHASE_ADD_MULTI_SENTENCE_RE.test(trimmed);
|
|
if (!tooLong && !multiSentence) return null;
|
|
const reasons = [
|
|
tooLong ? `${trimmed.length} chars (over the ${PHASE_ADD_TITLE_MAX_LEN}-char title threshold)` : null,
|
|
multiSentence ? 'multiple sentences' : null,
|
|
].filter(Boolean).join(', ');
|
|
return (
|
|
`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
|
|
`as the phase title; consider a short title with the detail moved to **Goal:**.`
|
|
);
|
|
}
|
|
|
|
function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?: string): void {
|
|
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) || '';
|
|
|
|
const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
|
|
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
const content = extractCurrentMilestone(rawContent, cwd);
|
|
|
|
const projectCode = (config.project_code as string) || '';
|
|
const prefix = projectCode ? `${projectCode}-` : '';
|
|
|
|
let _newPhaseId: number | string;
|
|
let _dirName: string;
|
|
|
|
if (customId || config.phase_naming === 'custom') {
|
|
_newPhaseId = customId || slug.toUpperCase();
|
|
if (!_newPhaseId) error('--id required when phase_naming is "custom"');
|
|
_dirName = `${prefix}${_newPhaseId}-${slug}`;
|
|
} else {
|
|
// Collect all phase numbers visible in the current-milestone content.
|
|
// Three sources are scanned so that a phase in ANY representation
|
|
// (section header, roadmap bullet, or on-disk directory) is counted:
|
|
|
|
// 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
|
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
|
// 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
|
|
// The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
|
|
// or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
|
|
// are counted and cannot collide with a freshly-added phase. (#1229)
|
|
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
|
|
|
const usedPhaseNums = new Set<number>();
|
|
let m: RegExpExecArray | null;
|
|
|
|
while ((m = headerPattern.exec(content)) !== null) {
|
|
const num = parseInt(m[1], 10);
|
|
if (num !== 999) usedPhaseNums.add(num);
|
|
}
|
|
while ((m = bulletPattern.exec(content)) !== null) {
|
|
const num = parseInt(m[1], 10);
|
|
if (num !== 999) usedPhaseNums.add(num);
|
|
}
|
|
|
|
// 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
|
|
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) usedPhaseNums.add(num);
|
|
}
|
|
}
|
|
|
|
// phase.add appends after the highest *used* number. Collecting numbers from
|
|
// section headers, roadmap bullets, AND on-disk dirs above is what prevents the
|
|
// #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
|
|
// an existing number.
|
|
const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
|
|
_newPhaseId = maxUsed + 1;
|
|
const paddedNum = String(_newPhaseId).padStart(2, '0');
|
|
_dirName = `${prefix}${paddedNum}-${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)) as string} ${_newPhaseId} to break down)\n`;
|
|
|
|
let updatedContent: string;
|
|
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 titleWarning = describeGoalShapedTitle(description);
|
|
|
|
const result: Record<string, unknown> = {
|
|
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,
|
|
};
|
|
if (titleWarning) result['warning'] = titleWarning;
|
|
|
|
output(result, raw, result['padded']);
|
|
}
|
|
|
|
function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): void {
|
|
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 as string) || '';
|
|
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') {
|
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
|
let m: RegExpExecArray | null;
|
|
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: Record<string, unknown>[] = [];
|
|
for (const description of descriptions) {
|
|
const slug = generateSlugInternal(description) || '';
|
|
let newPhaseId: number | string;
|
|
let dirName: string;
|
|
if (config.phase_naming === 'custom') {
|
|
newPhaseId = slug.toUpperCase();
|
|
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)) as string} ${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: string, afterPhase: string, description: string, raw: boolean): void {
|
|
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) || '';
|
|
|
|
const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
|
|
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
const content = extractCurrentMilestone(rawContent, cwd);
|
|
|
|
const normalizedAfter = normalizePhaseName(afterPhase);
|
|
const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter);
|
|
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i');
|
|
const headingMatch = targetPattern.test(content);
|
|
|
|
const bulletPattern = new RegExp(
|
|
`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\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) {
|
|
const checklistPattern = new RegExp(
|
|
`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\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`);
|
|
}
|
|
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
const normalizedBase = normalizePhaseName(afterPhase);
|
|
const decimalSet = new Set<number>();
|
|
|
|
// #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
|
|
// scan above — a missing phasesDir (no decimal sub-phases yet) is the
|
|
// expected, silent case (empty decimalSet). A readdirSync failure once the
|
|
// dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
|
|
// `phase insert` proceed with an incomplete decimalSet and risk writing a
|
|
// decimal phase number that collides with an existing on-disk directory
|
|
// the scan simply never saw — surfaced loud instead, like the sibling.
|
|
if (fs.existsSync(phasesDir)) {
|
|
// Initialized (not just declared) so TS's definite-assignment check is
|
|
// satisfied without relying on control-flow narrowing through error()'s
|
|
// `never` return, which TS does not propagate through a destructured
|
|
// module-property function reference — error() still halts the process
|
|
// before `dirs` below is ever computed from this placeholder value.
|
|
let entries: fs.Dirent[] = [];
|
|
try {
|
|
entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
|
|
}
|
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
const decimalPattern = new RegExp(
|
|
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`,
|
|
);
|
|
for (const dir of dirs) {
|
|
const dm = dir.match(decimalPattern);
|
|
if (dm) decimalSet.add(parseInt(dm[1], 10));
|
|
}
|
|
}
|
|
|
|
const rmPhasePattern = new RegExp(
|
|
`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
|
|
'gi',
|
|
);
|
|
let rmMatch: RegExpExecArray | null;
|
|
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}`;
|
|
const insertConfig = loadConfig(cwd);
|
|
const projectCode = (insertConfig.project_code as string) || '';
|
|
const pfx = projectCode ? `${projectCode}-` : '';
|
|
const _dirName = `${pfx}${_decimalPhase}-${slug}`;
|
|
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
|
|
|
|
platformEnsureDir(dirPath);
|
|
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
|
|
|
|
let updatedContent: string;
|
|
|
|
if (isBulletStyle) {
|
|
const boldBulletPattern = new RegExp(
|
|
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`,
|
|
'i',
|
|
);
|
|
const useBold = boldBulletPattern.test(content);
|
|
const phaseLabel = useBold
|
|
? `**Phase ${_decimalPhase}: ${description}**`
|
|
: `Phase ${_decimalPhase}: ${description}`;
|
|
const bulletEntry = `\n- [ ] ${phaseLabel}`;
|
|
|
|
const targetBulletPattern = new RegExp(
|
|
`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\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: number;
|
|
if (nextBulletMatch) {
|
|
insertIdx = bulletLineEnd + (nextBulletMatch.index as number);
|
|
} else {
|
|
insertIdx = bulletLineEnd;
|
|
}
|
|
|
|
updatedContent =
|
|
rawContent.slice(0, insertIdx) + bulletEntry + rawContent.slice(insertIdx);
|
|
} else {
|
|
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)) as string} ${_decimalPhase} to break down)\n`;
|
|
|
|
const headerPattern = new RegExp(
|
|
`(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\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);
|
|
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
|
|
|
|
let insertIdx: number;
|
|
if (nextPhaseMatch) {
|
|
insertIdx = headerIdx + headerMatch![0].length + (nextPhaseMatch.index as number);
|
|
} 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);
|
|
}
|
|
|
|
interface RenameDirInfo {
|
|
dir: string;
|
|
prefix: string;
|
|
oldDecimal: number;
|
|
slug: string;
|
|
}
|
|
|
|
interface RenameIntInfo {
|
|
dir: string;
|
|
oldInt: number;
|
|
letter: string;
|
|
decimal: number | null;
|
|
slug: string;
|
|
}
|
|
|
|
function renameDecimalPhases(
|
|
phasesDir: string,
|
|
baseInt: number,
|
|
removedDecimal: number,
|
|
): { renamedDirs: { from: string; to: string }[]; renamedFiles: { from: string; to: string }[] } {
|
|
const renamedDirs: { from: string; to: string }[] = [];
|
|
const renamedFiles: { from: string; to: string }[] = [];
|
|
const decPattern = new RegExp(`^(0*${baseInt})\\.(\\d+)-(.+)$`);
|
|
const dirs = readSubdirectories(phasesDir, true);
|
|
const toRename: RenameDirInfo[] = 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 is RenameDirInfo => item !== null && item.oldDecimal > removedDecimal)
|
|
.sort((a, b) => b.oldDecimal - a.oldDecimal);
|
|
|
|
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}`;
|
|
retryRenameSync(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);
|
|
retryRenameSync(
|
|
path.join(phasesDir, newDirName, f),
|
|
path.join(phasesDir, newDirName, newFileName),
|
|
);
|
|
renamedFiles.push({ from: f, to: newFileName });
|
|
}
|
|
}
|
|
}
|
|
return { renamedDirs, renamedFiles };
|
|
}
|
|
|
|
function renameIntegerPhases(
|
|
phasesDir: string,
|
|
removedInt: number,
|
|
): { renamedDirs: { from: string; to: string }[]; renamedFiles: { from: string; to: string }[] } {
|
|
const renamedDirs: { from: string; to: string }[] = [];
|
|
const renamedFiles: { from: string; to: string }[] = [];
|
|
const dirs = readSubdirectories(phasesDir, true);
|
|
const toRename: RenameIntInfo[] = 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((item): item is RenameIntInfo => item !== null)
|
|
.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}`;
|
|
retryRenameSync(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);
|
|
retryRenameSync(
|
|
path.join(phasesDir, newDirName, f),
|
|
path.join(phasesDir, newDirName, newFileName),
|
|
);
|
|
renamedFiles.push({ from: f, to: newFileName });
|
|
}
|
|
}
|
|
}
|
|
return { renamedDirs, renamedFiles };
|
|
}
|
|
|
|
function decrementRoadmapPhaseNumber(raw: string, removedInt: number): string {
|
|
const num = parseInt(raw, 10);
|
|
if (!Number.isInteger(num) || num <= removedInt || num === 999) return raw;
|
|
return String(num - 1);
|
|
}
|
|
|
|
function decrementRoadmapPhaseToken(raw: string, removedInt: number): string {
|
|
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: string, removedInt: number): string {
|
|
const num = parseInt(raw, 10);
|
|
if (!Number.isInteger(num) || num <= removedInt || num === 999) return raw;
|
|
return String(num - 1).padStart(raw.length, '0');
|
|
}
|
|
|
|
/**
|
|
* Return the RAW text of the `dataRowIndex`-th data row line (0-based, in
|
|
* file order — header and delimiter rows excluded) of the FIRST GFM table
|
|
* found in `sectionText`, or `null` when the table or that row doesn't exist.
|
|
*
|
|
* F8 (#2245 review, nit) support helper: addresses a table row by its
|
|
* STRUCTURAL position rather than by matching its (possibly non-unique)
|
|
* trimmed cell content — see the Progress-ordinal renumber's padding-recovery
|
|
* use below for why content-matching is unsafe here (two rows with identical
|
|
* trimmed Phase text, or a row whose already-rewritten new value coincides
|
|
* with another row's pre-edit text, would otherwise resolve to the wrong line).
|
|
*/
|
|
function findDataRowLine(sectionText: string, dataRowIndex: number): string | null {
|
|
const lines = sectionText.split(/\r?\n/);
|
|
let headerIdx = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const trimmed = lines[i].trim();
|
|
if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
|
|
headerIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
if (headerIdx === -1) return null;
|
|
|
|
let seen = -1;
|
|
for (let i = headerIdx + 2; i < lines.length; i++) {
|
|
if (!lines[i].trim().startsWith('|')) break;
|
|
seen += 1;
|
|
if (seen === dataRowIndex) return lines[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function updateRoadmapAfterPhaseRemoval(
|
|
roadmapPath: string,
|
|
targetPhase: string,
|
|
isDecimal: boolean,
|
|
removedInt: number,
|
|
cwd: string,
|
|
): void {
|
|
withPlanningLock(cwd, () => {
|
|
let content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
const escaped = escapeRegex(targetPhase);
|
|
|
|
// SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
|
|
// detail section INCLUDING its own heading line. Migrated onto deleteSection
|
|
// (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
|
|
// tokenizeHeadings + this predicate, then splices out the range from that
|
|
// heading's own start through the next heading of the SAME-OR-HIGHER level —
|
|
// whatever that heading's text is. This fixes a data-loss bug in the prior
|
|
// hand-rolled regex, whose lookahead only recognised ANOTHER "Phase N:"
|
|
// heading as a stop boundary: removing the LAST phase in a roadmap left no
|
|
// such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
|
|
// away everything after it — including a trailing `## Progress` heading and
|
|
// its tracking table.
|
|
const phaseHeadingRe = new RegExp(
|
|
`^Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
|
|
'i',
|
|
);
|
|
content = deleteSection(
|
|
content,
|
|
(h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text),
|
|
);
|
|
content = content.replace(
|
|
new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'),
|
|
'',
|
|
);
|
|
// ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
|
|
// for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
|
|
// of updateTableCell). Scoped to the `## Progress` section — mirroring
|
|
// deriveProgressFromRoadmap's read-side scoping (phase-lifecycle.cts) —
|
|
// so a same-numbered row in an earlier, unrelated table (e.g. a
|
|
// `| Phase | Requirements | Count |` table preceding `## Progress`,
|
|
// #2012) is never touched. Matches the row by its FIRST cell only: for an
|
|
// integer removal, a zero-pad-insensitive leading-integer comparison
|
|
// (`01.`, `1.`, `1 `, bare `1` all match phase 1; a decimal sub-phase
|
|
// cell like `2.5` never matches an integer removal); for a decimal
|
|
// removal, the exact decimal token. This replaces the prior regex's
|
|
// `\.?\s` requirement, which silently left a COMPACT unpadded row (e.g.
|
|
// `|2|0/2|Planned|-|`) undeleted — its closing `|` follows the digit with
|
|
// no whitespace to match (#2245 audit) — and which was also unscoped to
|
|
// any particular table.
|
|
const progressHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
|
|
if (progressHeadingMatch && progressHeadingMatch.index !== undefined) {
|
|
const headingOffset = progressHeadingMatch.index;
|
|
const before = content.slice(0, headingOffset);
|
|
const fromHeading = content.slice(headingOffset);
|
|
const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
const progressSection =
|
|
nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
|
|
const rest = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
|
|
|
|
const matchRemovedProgressRow = (row: Record<string, string>): boolean => {
|
|
const firstCellRaw = (Object.values(row)[0] ?? '').trim();
|
|
if (isDecimal) {
|
|
return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
|
}
|
|
const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
|
|
if (!leadingMatch || leadingMatch[2]) return false;
|
|
return parseInt(leadingMatch[1], 10) === removedInt;
|
|
};
|
|
|
|
const deleteResult = deleteTableRow(progressSection, matchRemovedProgressRow);
|
|
if (deleteResult.ok) {
|
|
content = before + deleteResult.value + rest;
|
|
}
|
|
}
|
|
|
|
if (!isDecimal) {
|
|
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
|
// is re-emitted verbatim — a tagged later phase still gets renumbered.
|
|
content = content.replace(
|
|
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi,
|
|
(_match, prefix: string, num: string, suffix: string) =>
|
|
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`,
|
|
);
|
|
content = content.replace(
|
|
/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi,
|
|
(_match, prefix: string, num: string, suffix: string) =>
|
|
`${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`,
|
|
);
|
|
// ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
|
|
// updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
|
|
// directly above). The prior whole-document regex
|
|
// `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
|
|
// file — including a same-shaped cell in an UNRELATED, earlier table
|
|
// (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
|
|
// preceding `## Progress`; #2245-class scoping defect, same family as
|
|
// the row-delete fix above). Scoped here to the `## Progress` section
|
|
// only, mirroring that same section-slice-then-splice-back pattern.
|
|
//
|
|
// Loops because updateTableCell only rewrites the FIRST matching row
|
|
// per call. `processedOrdinalRows` tracks by row INDEX (stable across
|
|
// iterations — this only edits cell content, it never inserts/deletes
|
|
// rows) so an already-decremented row's new value — which may still
|
|
// numerically exceed `removedInt` — is never re-selected and
|
|
// decremented a second time (matching on the row's CURRENT value alone,
|
|
// without this guard, would keep re-firing on each pass).
|
|
//
|
|
// `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
|
|
// required: a decimal sub-phase ordinal like `2.5` (no whitespace
|
|
// between the dot and the next character) never matches it, so it is
|
|
// left untouched — identical decimal-safety to the prior behaviour.
|
|
//
|
|
// updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
|
|
// only, so the row's original leading/trailing alignment padding is
|
|
// recovered by a narrow, anchored lookup within that row's OWN raw
|
|
// line — addressed by ROW INDEX (`matchedRowIndex`, via
|
|
// `findDataRowLine`), not by searching the whole section for content
|
|
// matching the trimmed value (F8 #2245 review: two rows with identical
|
|
// trimmed Phase text, or a row whose already-rewritten new value
|
|
// coincides with another row's pre-edit text, would otherwise resolve
|
|
// to the WRONG row's padding — the first/leftmost content match found).
|
|
// The lookup searches for `escapeCell(current)` (F3 #2245 review: the
|
|
// ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
|
|
// escaped form, so searching for the unescaped `current` would
|
|
// silently fail to find an escaped-pipe cell's own line — preserving
|
|
// every other byte of the row (ADR-2143 §7 byte-parity) while only the
|
|
// digits actually change.
|
|
const ordinalHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
|
|
if (ordinalHeadingMatch && ordinalHeadingMatch.index !== undefined) {
|
|
const ordinalHeadingOffset = ordinalHeadingMatch.index;
|
|
const ordinalBefore = content.slice(0, ordinalHeadingOffset);
|
|
const ordinalFromHeading = content.slice(ordinalHeadingOffset);
|
|
const ordinalNextHeadingOffset = ordinalFromHeading.search(/\n#{1,2}[ \t]/);
|
|
let ordinalSection =
|
|
ordinalNextHeadingOffset >= 0
|
|
? ordinalFromHeading.slice(0, ordinalNextHeadingOffset)
|
|
: ordinalFromHeading;
|
|
const ordinalRest =
|
|
ordinalNextHeadingOffset >= 0 ? ordinalFromHeading.slice(ordinalNextHeadingOffset) : '';
|
|
|
|
const phaseCellShapeRe = /^(\d+)(\.\s)/;
|
|
const processedOrdinalRows = new Set<number>();
|
|
let matchedRowIndex: number | null = null;
|
|
|
|
for (;;) {
|
|
matchedRowIndex = null;
|
|
const cellResult = updateTableCell(
|
|
ordinalSection,
|
|
(row, index) => {
|
|
if (processedOrdinalRows.has(index)) return false;
|
|
const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
|
|
if (!m) return false;
|
|
const num = parseInt(m[1], 10);
|
|
if (!Number.isInteger(num) || num <= removedInt || num === 999) return false;
|
|
processedOrdinalRows.add(index);
|
|
matchedRowIndex = index;
|
|
return true;
|
|
},
|
|
'Phase',
|
|
(current) => {
|
|
const m = phaseCellShapeRe.exec(current);
|
|
if (!m) return current;
|
|
const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
|
|
const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
|
|
const targetLine =
|
|
matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
|
|
const padMatch = targetLine
|
|
? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex(escapeCell(current))}(\\s*)\\|`).exec(targetLine)
|
|
: null;
|
|
const leadPad = padMatch ? padMatch[1] : ' ';
|
|
const trailPad = padMatch ? padMatch[2] : ' ';
|
|
return `${leadPad}${escapeCell(newContent)}${trailPad}`;
|
|
},
|
|
);
|
|
if (!cellResult.ok) break;
|
|
ordinalSection = cellResult.value;
|
|
}
|
|
|
|
content = ordinalBefore + ordinalSection + ordinalRest;
|
|
}
|
|
content = content.replace(
|
|
/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g,
|
|
(_match, phaseNum: string, planNum: string) =>
|
|
`${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`,
|
|
);
|
|
content = content.replace(
|
|
/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
|
|
(_match, prefix: string, num: string) =>
|
|
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
|
|
);
|
|
content = content.replace(
|
|
/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
|
|
(_match, prefix: string, num: string) =>
|
|
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
|
|
);
|
|
}
|
|
|
|
platformWriteSync(roadmapPath, content);
|
|
});
|
|
}
|
|
|
|
interface PhaseRemoveOptions {
|
|
force?: boolean;
|
|
}
|
|
|
|
function cmdPhaseRemove(
|
|
cwd: string,
|
|
targetPhase: string,
|
|
options: PhaseRemoveOptions,
|
|
raw: boolean,
|
|
): void {
|
|
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;
|
|
|
|
const subdirs = readSubdirectories(phasesDir, true);
|
|
const targetDir = subdirs.find((d) => phaseTokenMatches(d, normalized)) || null;
|
|
|
|
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 });
|
|
|
|
let renamedDirs: { from: string; to: string }[] = [];
|
|
let renamedFiles: { from: string; to: string }[] = [];
|
|
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 (e) {
|
|
// #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
|
|
// rename subsequent phase directories ON DISK one at a time — a mid-loop
|
|
// failure leaves SOME directories already renumbered and others not, with
|
|
// no way to recover which (the callee's own renamedDirs/renamedFiles never
|
|
// reach this scope when it throws). Silently swallowing this and falling
|
|
// through to updateRoadmapAfterPhaseRemoval below used to rewrite
|
|
// ROADMAP.md's phase numbers assuming the ENTIRE renumbering succeeded,
|
|
// permanently desyncing ROADMAP.md from the actual (partially-renamed)
|
|
// on-disk directory names. Surface loud instead of compounding it.
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
|
|
}
|
|
|
|
updateRoadmapAfterPhaseRemoval(
|
|
roadmapPath,
|
|
targetPhase,
|
|
isDecimal,
|
|
parseInt(normalized, 10),
|
|
cwd,
|
|
);
|
|
|
|
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
|
let stateUpdated = false;
|
|
if (fs.existsSync(statePath)) {
|
|
// #2640: report whether STATE.md content actually changed, not just file
|
|
// existence (fs.existsSync was trivially true). Also ensure the body
|
|
// transform produces a diff so readModifyWriteStateMd's no-op guard
|
|
// (#948) doesn't skip the frontmatter resync — without that, the
|
|
// progress.* frontmatter block stays stale when the body has no
|
|
// 'Total Phases:' or 'of N' phrase.
|
|
stateUpdated = readModifyWriteStateMd(
|
|
statePath,
|
|
(stateContent: string) => {
|
|
let modified = stateContent;
|
|
const totalRaw = stateExtractField(modified, 'Total Phases');
|
|
if (totalRaw) {
|
|
modified =
|
|
stateReplaceField(modified, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) ||
|
|
modified;
|
|
}
|
|
const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
|
|
if (ofMatch) {
|
|
modified = modified.replace(
|
|
/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i,
|
|
`$1${parseInt(ofMatch[2], 10) - 1}$3`,
|
|
);
|
|
}
|
|
// #2640: if neither body field was found, the transform is a no-op.
|
|
// readModifyWriteStateMd's no-op guard (#948) would then skip the
|
|
// frontmatter resync, leaving progress.* stale. Force a body diff
|
|
// ONLY when a phase directory was actually removed (targetDir !== null)
|
|
// so the guard passes and syncStateFrontmatter rebuilds the frontmatter
|
|
// from the post-deletion disk/ROADMAP state. Without the targetDir gate,
|
|
// a no-op removal (ROADMAP-only phase, no directory) would inject a
|
|
// spurious 'Total Phases:' line into a body that intentionally lacked one.
|
|
if (targetDir && modified === stateContent) {
|
|
// subdirs was read before the deletion; excluding the removed target
|
|
// gives the remaining count. Renumbering changes names but not count.
|
|
const remainingPhases = subdirs.filter(
|
|
(d) => phaseTokenMatches(d, normalized) === false,
|
|
).length;
|
|
if (totalRaw) {
|
|
modified =
|
|
stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
|
|
} else {
|
|
// No 'Total Phases:' field in the body — append one so the no-op
|
|
// guard sees a diff. syncStateFrontmatter will then rebuild the
|
|
// frontmatter progress.* block from the real disk/ROADMAP count.
|
|
modified = `Total Phases: ${remainingPhases}\n` + modified;
|
|
}
|
|
}
|
|
return modified;
|
|
},
|
|
cwd,
|
|
);
|
|
}
|
|
|
|
output(
|
|
{
|
|
removed: targetPhase,
|
|
directory_deleted: targetDir,
|
|
renamed_directories: renamedDirs,
|
|
renamed_files: renamedFiles,
|
|
roadmap_updated: true,
|
|
state_updated: stateUpdated,
|
|
},
|
|
raw,
|
|
);
|
|
}
|
|
|
|
interface WriteSpec {
|
|
filePath: string;
|
|
before: string;
|
|
after: string;
|
|
}
|
|
|
|
function writePlanningFileSet(writes: WriteSpec[]): void {
|
|
const applied: WriteSpec[] = [];
|
|
try {
|
|
for (const write of writes) {
|
|
if (write.before === write.after) continue;
|
|
platformWriteSync(write.filePath, write.after);
|
|
applied.push(write);
|
|
}
|
|
} catch (err) {
|
|
for (const write of applied.reverse()) {
|
|
try {
|
|
platformWriteSync(write.filePath, write.before);
|
|
} catch (rollbackErr) {
|
|
const errObj = err as Error & { rollbackError?: unknown };
|
|
errObj.rollbackError = rollbackErr;
|
|
const rollbackMsg =
|
|
rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
errObj.message +=
|
|
`\nWARNING: rollback failed while restoring ${write.filePath} ` +
|
|
`(${rollbackMsg}). Planning files under .planning/ may be left in an ` +
|
|
`inconsistent, partially rolled back state. Inspect ROADMAP.md / REQUIREMENTS.md / ` +
|
|
`STATE.md before re-running phase complete.`;
|
|
break;
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function phaseDisplayNameFromRoadmap(roadmapContent: string | null, phaseNum: string | null): string | null {
|
|
if (!roadmapContent || !phaseNum) return null;
|
|
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
|
|
const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:\\s*([^\\n]+)`, 'im'));
|
|
if (!heading) return null;
|
|
const name = heading[1].replace(/\(INSERTED\)/i, '').trim();
|
|
return name || null;
|
|
}
|
|
|
|
function phaseDisplayNameFromSlug(slug: string | null): string | null {
|
|
if (!slug) return null;
|
|
const name = slug.replace(/-/g, ' ').trim();
|
|
return name || null;
|
|
}
|
|
|
|
function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
|
if (!phaseNum) {
|
|
error('phase number required for phase complete');
|
|
}
|
|
|
|
// #2028: fail safe in workstream mode with no active workstream. With no active
|
|
// workstream and no --ws, planningDir(cwd) resolves to root .planning, so
|
|
// phase.complete would write STATE.md/ROADMAP.md (and mislabel milestone status)
|
|
// into the shared root that other workstreams read. Mirror the #1912 guard that
|
|
// init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
|
|
// explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
|
|
const availableWorkstreams = listAvailableWorkstreams(cwd);
|
|
const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
|
|
if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
|
|
error(
|
|
`phase.complete requires a workstream in workstream mode — no active workstream is set, so root STATE.md/ROADMAP.md (likely stale) would be written. ` +
|
|
`Pass --ws <name> or run ${formatGsdSlash('workstream set', resolveRuntime(cwd)) as string} first. ` +
|
|
`Available workstreams: ${availableWorkstreams.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
|
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
const today = realClock.localToday();
|
|
|
|
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
|
|
if (!phaseInfoRaw) {
|
|
error(`Phase ${phaseNum} not found`);
|
|
}
|
|
const phaseInfo = phaseInfoRaw as unknown as Record<string, unknown>;
|
|
|
|
const planCount: number = phaseInfo['plans']
|
|
? (phaseInfo['plans'] as string[]).length
|
|
: 0;
|
|
const summaryCount: number = phaseInfo['summaries']
|
|
? (phaseInfo['summaries'] as string[]).length
|
|
: 0;
|
|
let requirementsUpdated = false;
|
|
|
|
const warnings: string[] = [];
|
|
// #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
|
|
// roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
|
|
// below (inside withPlanningLock) alongside the warnings[] entry, so a
|
|
// caller can assert on the typed field instead of the warning's prose.
|
|
let staleCheckIndeterminate = false;
|
|
const phaseFullDir = path.join(cwd, phaseInfo['directory'] as string);
|
|
|
|
// #2648: fail-closed plan-coverage gate. phase.complete used to gate ONLY on a
|
|
// single *-VERIFICATION.md status, so a phase could close "complete" while an
|
|
// arbitrary number of its plans — including plans a lock/recovery decision
|
|
// silently dropped — had no completion record (a confirmed production incident
|
|
// closed a phase with 6/30 plans unexecuted, including its entire final UI
|
|
// scope, with every tool-reported signal green). Now refuse completion when any
|
|
// plan lacks a matching *-SUMMARY.md, UNLESS that plan is explicitly retired
|
|
// via machine-readable `status: superseded` frontmatter (the #2349 marker).
|
|
//
|
|
// scanPhasePlans is the superseded-AWARE counter (it drops status: superseded
|
|
// plans from planFiles before returning), so a deliberately-retired plan never
|
|
// appears in the unsummarized set and never blocks completion — closing the
|
|
// Goodhart hole (delete a SUMMARY to raise the %) without regressing the
|
|
// legitimate lock/recovery pattern (retire a plan instead of executing it).
|
|
// This is evaluated BEFORE the verification-gate transaction below so a
|
|
// plan-coverage refusal fails fast without mutating ROADMAP/STATE. The count
|
|
// path (cmdPhaseComplete's own planCount/summaryCount above) is NOT superseded-
|
|
// aware (it comes from findPhaseInternal/phase-locator.cts); that is fine for
|
|
// DISPLAY (the X/Y cell) but must not be the gate — the gate needs the
|
|
// superseded-adjusted set so retired plans don't re-block the very phases the
|
|
// marker exists to unblock. Matches roadmap.cts's already-correct-but-unenforced
|
|
// `summaryCount >= planCount` predicate, now enforced at the completion seam.
|
|
const coverageScan = scanPhasePlans(phaseFullDir);
|
|
// #2648 security: fail CLOSED when the phase directory cannot be read.
|
|
// scanPhasePlans deliberately swallows readdirSync errors and returns an empty
|
|
// plan set ({planFiles: []}), which is indistinguishable from a readable empty
|
|
// phase. For a COVERAGE gate that is the wrong posture: "I could not read the
|
|
// plans" must mean "I cannot prove coverage," not "all plans are summarized" —
|
|
// otherwise any I/O failure (permissions, ENOTDIR, EBUSY on Windows, a dir
|
|
// present in ROADMAP.md but missing/unreadable on disk) silently re-opens the
|
|
// exact hole this gate exists to close. Distinguish the two: a readable
|
|
// directory with zero plans is a legitimately complete empty phase; an
|
|
// UNREADABLE directory is a fail-closed refusal. Mirrors cmdPhaseInsert's own
|
|
// readdirSync-fail-closed posture (a swallow there used to risk writing a
|
|
// colliding phase number).
|
|
try {
|
|
fs.readdirSync(phaseFullDir);
|
|
} catch (readErr) {
|
|
error(
|
|
`Phase ${phaseNum} cannot be completed: its plan directory is unreadable (${phaseInfo['directory'] as string}: ${(readErr as NodeJS.ErrnoException).code || (readErr as Error).message}), so plan coverage cannot be verified. Restore read access and retry — a coverage gate that passes when it cannot read the plans is no gate at all (#2648).`,
|
|
ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE,
|
|
);
|
|
}
|
|
const unsummarizedPlans = findUnsummarizedPlans(
|
|
coverageScan.planFiles,
|
|
coverageScan.summaryFiles,
|
|
);
|
|
if (unsummarizedPlans.length > 0) {
|
|
// Sanitize plan filenames before interpolation: they come raw from
|
|
// readdirSync and could carry C0 control chars / DEL (a committable filename
|
|
// could spoof the terminal in plain-error mode). Strip them so the message is
|
|
// safe to print regardless of --json-errors. Path traversal sequences are not
|
|
// a code-execution vector here (printed only, never reopened from the message).
|
|
const sanitize = (name: string): string => name.replace(/[\u0000-\u001f\u007f]/g, '?');
|
|
const listed = unsummarizedPlans.slice(0, 20).map(sanitize).join(', ');
|
|
const more = unsummarizedPlans.length > 20 ? ` (and ${unsummarizedPlans.length - 20} more)` : '';
|
|
// Audit surface (#2648 review M1): name how many plans were excluded as
|
|
// superseded so a reviewer can see WHICH work was declared retired, not just
|
|
// that some plans are missing summaries. The status: superseded marker is a
|
|
// committable, review-time-trusted bypass; surfacing its count keeps that
|
|
// bypass visible rather than silent.
|
|
const phaseInfoPlanCount = Array.isArray(phaseInfo['plans']) ? (phaseInfo['plans'] as string[]).length : 0;
|
|
const supersededCount =
|
|
coverageScan.planFiles.length === 0 ? 0 : Math.max(0, phaseInfoPlanCount - coverageScan.planFiles.length);
|
|
const supersededNote = supersededCount > 0
|
|
? ` ${supersededCount} plan(s) excluded as status: superseded (retired).`
|
|
: '';
|
|
error(
|
|
`Phase ${phaseNum} cannot be completed: ${unsummarizedPlans.length} plan(s) have no completion record (*-SUMMARY.md): ${listed}${more}.` +
|
|
supersededNote +
|
|
` Execute the plans and write their summaries, or retire a plan with machine-readable \`status: superseded\` frontmatter (#2349) if it was deliberately dropped — a retired plan is excluded from this gate. ` +
|
|
`Completing a phase with unexecuted plans is what lost an entire promised deliverable silently (#2648).`,
|
|
ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE,
|
|
);
|
|
}
|
|
|
|
try {
|
|
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 verificationFilePath = path.join(phaseFullDir, file);
|
|
const content = fs.readFileSync(verificationFilePath, 'utf-8');
|
|
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
|
|
// from historical metadata in the file body (e.g. `previous_status: gaps_found`).
|
|
// A full-text regex like /status: gaps_found/ matches the substring inside
|
|
// `previous_status: gaps_found`, producing spurious warnings even when the
|
|
// current frontmatter status is `passed`.
|
|
const verFm = extractFrontmatter(content, verificationFilePath) as Record<string, unknown>;
|
|
// Normalise to lower-case so `status: Passed` (title-case) is not missed.
|
|
const verStatus = typeof verFm['status'] === 'string' ? verFm['status'].trim().toLowerCase() : '';
|
|
if (verStatus === 'human_needed') warnings.push(`${file}: needs human verification`);
|
|
if (verStatus === 'gaps_found') warnings.push(`${file}: has unresolved gaps`);
|
|
}
|
|
} catch {
|
|
/* best-effort (#2245 audit): this is an ADVISORY pre-scan of UAT/
|
|
* VERIFICATION files for `warnings` in the phase-complete output — the
|
|
* actual completion GATE is readVerificationStatus below (a separate
|
|
* mechanism). A readdirSync/readFileSync failure here just means fewer
|
|
* warnings are surfaced this run, not a blocked or corrupted completion. */
|
|
}
|
|
|
|
// #2572: artifact↔disk advisory for the SUMMARYs of the phase being completed.
|
|
//
|
|
// A SUMMARY asserts "I created these files". Nothing checked that claim for
|
|
// phase summaries — the `verify-summary` verb has existed since the beginning
|
|
// but was only ever pointed at `.planning/research/SUMMARY.md`. An interrupted
|
|
// or over-reported phase therefore counted toward 100% silently.
|
|
//
|
|
// Joins the same ADVISORY channel as the pre-scan above: findings land in
|
|
// `warnings[]` (rendered by execute-phase.md's "If has_warnings is true"
|
|
// step), never in the completion GATE (readVerificationStatus below).
|
|
// Completion is never blocked.
|
|
//
|
|
// `checkCommits: false` — only the file-existence half is surfaced here, so
|
|
// the `git cat-file` probes would be spawned and their result discarded. The
|
|
// hash pattern is a loose `\b[0-9a-f]{7,40}\b` that matches any hex-shaped
|
|
// token in prose, too noisy to put in front of a user even as a warning.
|
|
//
|
|
// `Infinity` — report every referenced file, not the CLI verb's default first
|
|
// two, so a phase that lists twelve files and landed three says so. The verb
|
|
// keeps its 2-file default; only this caller opts out of the cap.
|
|
try {
|
|
const phaseDirRel = phaseInfo['directory'] as string;
|
|
// `summaries` arrives pre-sorted from the phase locator, so warning order is
|
|
// deterministic across platforms rather than readdir-dependent.
|
|
const summaryNames = (phaseInfo['summaries'] as string[] | undefined) || [];
|
|
for (const summaryName of summaryNames) {
|
|
const v = verifyMod.verifySummaryCore(
|
|
cwd,
|
|
`${phaseDirRel}/${summaryName}`,
|
|
Infinity,
|
|
{ checkCommits: false },
|
|
);
|
|
const missing = v.checks.files_created.missing;
|
|
if (missing.length > 0) {
|
|
warnings.push(
|
|
`${summaryName}: references ${missing.length} file(s) not on disk: ${missing.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
} catch {
|
|
/* best-effort, same posture as the #2245 pre-scan above: an unreadable
|
|
* SUMMARY means one fewer advisory this run, never a blocked completion. */
|
|
}
|
|
|
|
let nextPhaseNum: string | null = null;
|
|
let nextPhaseName: string | null = null;
|
|
let isLastPhase = true;
|
|
|
|
const verificationBlocked = withPlanningLock(cwd, () => {
|
|
// #2617: pass the project's runtime so the blocked-completion error below
|
|
// suggests the command surface this runtime actually installs
|
|
// ($gsd-… on Codex) rather than a hard-coded Claude-style string.
|
|
const verificationStatus = readVerificationStatus(phaseFullDir, { runtime: resolveRuntime(cwd) });
|
|
// #3057 B3: the staleness check inside readVerificationStatus can itself
|
|
// fail (fs / scanPhasePlans / clock error), in which case `status` above
|
|
// was routed as if nothing were stale (unchanged fail-open routing) — but
|
|
// that must not be silently identical to a check that actually ran and
|
|
// found nothing stale. Join the SAME advisory channel the UAT/VERIFICATION
|
|
// pre-scan above already uses (`warnings[]`, rendered by execute-phase.md's
|
|
// "If has_warnings is true" step) rather than inventing a new one. This
|
|
// only fires on the non-blocking path (status resolves to 'passed' despite
|
|
// the indeterminate check) — the blocked path below carries its own note.
|
|
if (verificationStatus.staleCheckIndeterminate) {
|
|
staleCheckIndeterminate = true;
|
|
warnings.push(
|
|
`verification staleness check could not complete for phase ${phaseNum} — routed as not-stale, but this was not actually verified (#3057)`,
|
|
);
|
|
}
|
|
if (verificationStatus.status !== 'passed') {
|
|
return verificationStatus;
|
|
}
|
|
|
|
const runPhaseCompleteTransaction = () => {
|
|
const writes: WriteSpec[] = [];
|
|
let roadmapContent: string | null = null;
|
|
|
|
if (fs.existsSync(roadmapPath)) {
|
|
const originalRoadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
roadmapContent = originalRoadmapContent;
|
|
|
|
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
|
|
// #2067: the gap between `]` and `Phase N` must allow only whitespace /
|
|
// markdown bold emphasis — NOT greedy `.*`. A greedy gap matched a later
|
|
// phase whose description merely mentioned the completed phase number,
|
|
// so completing an already-checked phase (idempotent re-run) checked the
|
|
// wrong phase's box. Mirrors the tight pattern used by phase-insert
|
|
// (`]\\s*(?:\\*\\*)?Phase`).
|
|
// #2067/#2200: line-anchored (^, optional leading indent) so an
|
|
// inline / backticked prose literal cannot match. Milestone-scoped below
|
|
// (mutateMilestonePhase) so a Backlog entry or a same-numbered shipped-
|
|
// milestone phase cannot be flipped either.
|
|
// ADR-2143 §4 note / #2245 audit: this is the phase-LIST checkbox — it
|
|
// lives in the milestone's `- [ ] Phase N: …` checklist, OUTSIDE any
|
|
// `### Phase N` detail section, so there is no section for
|
|
// withPhaseSection to bind to. Migrated onto the sectionizer's
|
|
// `updateBullet` bullet-write seam: the pattern itself is unchanged,
|
|
// only the "find the right line, splice it back" plumbing moved off a
|
|
// whole-slice `.replace()` onto the seam. Applied per single physical
|
|
// line by updateBullet, so the pattern no longer needs the `m` flag
|
|
// (it never sees more than one line at a time); see
|
|
// planCountBodyPattern below for the sites that were migrated onto
|
|
// withPhaseSection instead.
|
|
//
|
|
// #2245 review Fix 6: this is behaviour-preserving for GSD-GENERATED
|
|
// inputs (the only shape ROADMAP.md ever actually has), NOT byte-parity
|
|
// across every conceivable input. `updateBullet` is fence-aware — a
|
|
// checkbox-shaped line inside a fenced (``` / ~~~) code block is never
|
|
// offered to `match`/`transform` — whereas the retired whole-slice
|
|
// `.replace()` had no such fence tracking and would have flipped a
|
|
// bullet-shaped line inside a fence too. That divergence has no live
|
|
// bug because a GSD-authored ROADMAP.md milestone checklist never puts
|
|
// its own `- [ ] Phase N: …` entries inside a fenced code block, but it
|
|
// is a real (and correct) behavioural difference on pathological input.
|
|
const checkboxPattern = new RegExp(
|
|
`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`,
|
|
'i',
|
|
);
|
|
|
|
// Progress table row: update Plans Complete/Status/Completed columns BY
|
|
// COLUMN NAME (handles 4- or 5-column RoadmapProgress tables) via the
|
|
// markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
|
|
// cells[]-index regex. Applied inside mutateMilestonePhase below (per
|
|
// milestone window), further scoped to the ## Progress heading within
|
|
// that window so the row lookup doesn't bind to an earlier table (e.g.
|
|
// | Phase | Requirements | Count |) whose rows also start with the
|
|
// phase number (#2012).
|
|
// #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".
|
|
const phaseCellRe = new RegExp(`^${phaseEscaped}\\.?(?:\\s|$)`, 'i');
|
|
const rowMatch = (row: Record<string, string>): boolean => phaseCellRe.test((row['Phase'] ?? '').trim());
|
|
const dateShape = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
/**
|
|
* Within `text` (already scoped to one milestone window by the
|
|
* caller), scope further to the `## Progress` heading section (up to
|
|
* the next `#`/`##` heading) when present, run `edit` against just
|
|
* that slice, and splice the result back — falling back to the whole
|
|
* `text` when no `## Progress` heading exists (mirrors phase-
|
|
* lifecycle.cjs's deriveProgressFromRoadmap read-side scoping).
|
|
*/
|
|
const editProgressHeadingSlice = (text: string, edit: (scoped: string) => string): string => {
|
|
const progressMatch = text.match(/^##[ \t]+Progress\b/im);
|
|
if (!progressMatch || progressMatch.index === undefined) {
|
|
return edit(text);
|
|
}
|
|
const headingOffset = progressMatch.index;
|
|
const beforeHeading = text.slice(0, headingOffset);
|
|
const fromHeading = text.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 beforeHeading + edit(scoped) + after;
|
|
};
|
|
|
|
// ADR-2143 §4: the plan-count write is now routed through
|
|
// withPhaseSection (see mutateMilestonePhase below), which hands this
|
|
// pattern ONLY phase N's own detail-section body — so the pattern no
|
|
// longer needs its own `#{2,4}\s*Phase\s+N` anchor + skip-ahead-past-
|
|
// interior-headings lookahead; the section boundary itself confines
|
|
// the match (the #2067/#2200 boundary-crossing class is now
|
|
// structurally impossible for this site rather than regex-enforced).
|
|
const planCountBodyPattern = /(\*\*Plans:\*\*\s*)[^\n]+/i;
|
|
|
|
const phaseInfoSummaries = phaseInfo['summaries'] as string[];
|
|
|
|
// #2200: apply the phase-checkbox flip, the plan-count write, and the
|
|
// per-plan checkbox flips ONLY within the current milestone's region(s)
|
|
// (primary section + optional Phase Details section). A bullet/heading in
|
|
// a shipped milestone, a Backlog section, or a backticked prose literal is
|
|
// outside the window and stays untouched. With no versioned active
|
|
// milestone, fall back to whole-content mutation (prior behaviour).
|
|
const mutateMilestonePhase = (slice: string): string => {
|
|
let s = slice;
|
|
s = updateBullet(
|
|
s,
|
|
(_bulletText, rawLine) => checkboxPattern.test(rawLine),
|
|
(rawLine) => rawLine.replace(checkboxPattern, `$1x$2 (completed ${today})`),
|
|
);
|
|
|
|
s = editProgressHeadingSlice(s, (scoped) => {
|
|
let text = scoped;
|
|
|
|
const plansResult = updateTableCell(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
|
|
if (plansResult.ok) text = plansResult.value;
|
|
|
|
const statusResult = updateTableCell(text, rowMatch, 'Status', ' Complete ');
|
|
if (statusResult.ok) text = statusResult.value;
|
|
|
|
// Preserve only a valid ISO date (#1161: idempotent; self-heal
|
|
// garbage). Ragged-tolerant (#2245 Blocker 2): decide via the
|
|
// CURRENT Completed cell inside a single updateTableCell callback
|
|
// (its own tolerant row scan) rather than gating on
|
|
// findTableWithColumns (which requires the WHOLE table to parse —
|
|
// a ragged SIBLING row elsewhere used to silently no-op this
|
|
// row's date stamp too).
|
|
const completedResult = updateTableCell(text, rowMatch, 'Completed', (current) =>
|
|
dateShape.test(current.trim()) ? current : ` ${today} `);
|
|
if (completedResult.ok) text = completedResult.value;
|
|
|
|
return text;
|
|
});
|
|
|
|
// ADR-2143 §4: the plan-count write and the per-plan checkbox flips
|
|
// are both scoped to phase N's OWN detail section via
|
|
// withPhaseSection — the edit callback below only ever sees that
|
|
// section's body, so neither regex can escape into a sibling
|
|
// phase's section, a shipped milestone, or a Backlog entry.
|
|
s = withPhaseSection(s, phaseNum, (body) => {
|
|
let b = body.replace(planCountBodyPattern, `$1${summaryCount}/${planCount} plans complete`);
|
|
for (const summaryFile of phaseInfoSummaries) {
|
|
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
|
if (!planId) continue;
|
|
const planEscaped = escapeRegex(planId);
|
|
const planCheckboxPattern = new RegExp(
|
|
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
|
|
'i',
|
|
);
|
|
b = b.replace(planCheckboxPattern, '$1x$2');
|
|
}
|
|
return b;
|
|
});
|
|
return s;
|
|
};
|
|
|
|
const milestoneRanges = currentMilestoneRawRanges(roadmapContent, cwd);
|
|
if (milestoneRanges) {
|
|
// Splice later windows first so an earlier window's offsets are not
|
|
// shifted by a length-changing mutation in a later window.
|
|
const windows = [milestoneRanges.details, milestoneRanges.primary]
|
|
.filter((w): w is { start: number; end: number } => w !== null)
|
|
.sort((a, b) => b.start - a.start);
|
|
for (const w of windows) {
|
|
roadmapContent =
|
|
roadmapContent.slice(0, w.start)
|
|
+ mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
|
|
+ roadmapContent.slice(w.end);
|
|
}
|
|
} else {
|
|
roadmapContent = mutateMilestonePhase(roadmapContent);
|
|
}
|
|
|
|
writes.push({
|
|
filePath: roadmapPath,
|
|
before: originalRoadmapContent,
|
|
after: roadmapContent,
|
|
});
|
|
|
|
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
|
|
if (fs.existsSync(reqPath)) {
|
|
const phaseEsc = phaseMarkdownRegexSource(phaseNum);
|
|
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
|
|
const phaseSectionMatch = currentMilestoneRoadmap.match(
|
|
new RegExp(
|
|
`(#{2,4}\\s*Phase\\s+${phaseEsc}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`,
|
|
'i',
|
|
),
|
|
);
|
|
|
|
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
|
|
const reqMatch = sectionText.match(
|
|
/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i,
|
|
);
|
|
|
|
const originalReqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
let reqContent = originalReqContent;
|
|
|
|
// #2316: `citedReqIds` — the REQ-IDs ROADMAP's own **Requirements:**
|
|
// line for this phase actually cites — is hoisted out of the
|
|
// `if (reqMatch)` block (previously scoped only inside it) so the
|
|
// ghost-ID cross-check below (~#2316-1) can consult it. `TBD` is the
|
|
// literal placeholder `phase.add`/`-batch`/`-insert` seed
|
|
// (`**Requirements**: TBD`, src/phase.cts:833,920,1078) — never a
|
|
// real REQ-ID, so it is filtered out wherever a cited-ID list feeds
|
|
// a warning (#2316-7 boundary).
|
|
const isPlaceholderReqId = (id: string): boolean => id.toUpperCase() === 'TBD';
|
|
let citedReqIds: string[] = [];
|
|
// #2316-1: Traceability-row writes that matched NO row (ghost or
|
|
// otherwise) — the `if (reqUpdate.ok)` below previously had no
|
|
// `else`, discarding this fact silently instead of surfacing it.
|
|
const traceabilityWriteMisses: string[] = [];
|
|
|
|
if (reqMatch) {
|
|
// #2334 HIGH 3: filter the tokenized capture to the REQ-ID SHAPE —
|
|
// the SAME shape bodyReqIds (`\*\*([A-Z][A-Z0-9]*-\d+)\*\*`, below)
|
|
// and tableReqIds (`([A-Z][A-Z0-9]*-\d+)`, below) already require —
|
|
// so the ghost-ID / unregistered comparisons stay shape-symmetric.
|
|
// Without this, `[^\n]+` split on `[,\s]+` turned EVERY word after
|
|
// the ID list into a "cited REQ-ID": the shipped
|
|
// `templates/roadmap.md:32` line
|
|
// `**Requirements**: [REQ-01, REQ-02] <!-- brackets optional, ... -->`
|
|
// warned to register `<!--`, `brackets`, `optional`, `-->`, etc., and
|
|
// `**Requirements:** None` warned to register the literal word
|
|
// `None`. This subsumes the `TBD` placeholder special-case (`TBD`
|
|
// does not match the REQ-ID shape either); `isPlaceholderReqId` is
|
|
// kept below as a defensive no-op for any caller that still hands
|
|
// it a raw token.
|
|
const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
|
|
citedReqIds = reqMatch[1]
|
|
.replace(/[\[\]]/g, '')
|
|
.split(/[,\s]+/)
|
|
.map((r) => r.trim())
|
|
.filter(Boolean)
|
|
.filter((r) => REQ_ID_SHAPE_RE.test(r));
|
|
|
|
for (const reqId of citedReqIds) {
|
|
const reqEscaped = escapeRegex(reqId);
|
|
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
|
|
// #2945: the flip is CONDITIONAL (porting #2788 defect-2's rollback from
|
|
// cmdRequirementsMarkComplete). Capture the pre-flip content; if a
|
|
// traceability row EXISTS for this ID below but its Status write is rejected
|
|
// (Out/Deferred/Blocked), the checkbox is rolled back so the two surfaces
|
|
// cannot silently diverge. A requirement recorded as deferred must not read
|
|
// as shipped.
|
|
const checkboxRe = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
|
const beforeCheckbox = reqContent;
|
|
reqContent = reqContent.replace(checkboxRe, '$1x$2');
|
|
const checkboxFlipped = reqContent !== beforeCheckbox;
|
|
|
|
// Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
|
|
// ... Complete | via the markdown-table seam (ADR-2143 §7). Match the
|
|
// row by its FIRST cell's value (the requirement-ID column) regardless
|
|
// of that column's HEADER name — real tables head it `REQ-ID`, others
|
|
// `Requirement` (#2769/#2203); this mirrors the prior regex's first-cell
|
|
// `\|\s*<id>\s*\|` anchor, not a by-name lookup. Object.values(row) is in
|
|
// header order, so [0] is the first column. Case-insensitive.
|
|
const reqRowMatch = (row: Record<string, string>): boolean =>
|
|
(Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
|
|
// Ragged-tolerant (#2245 Blocker 2): drive the write purely off
|
|
// updateTableCell's own tolerant row scan — a DIFFERENT
|
|
// requirement's row elsewhere in the same table having a
|
|
// mismatched cell count must never silently no-op THIS
|
|
// requirement's write. The "only flip Pending/In Progress ->
|
|
// Complete" gate is folded into the newValue callback so one
|
|
// updateTableCell call both probes and writes.
|
|
// #2945: track tableHit (did the callback actually CHANGE the value?) so the
|
|
// checkbox rollback below can distinguish "row existed and accepted" from
|
|
// "row existed and rejected".
|
|
let tableHit = false;
|
|
const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => {
|
|
// #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
|
|
// gaps_found response) can complete without hand-editing the table.
|
|
if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
|
|
tableHit = true;
|
|
return ' Complete ';
|
|
}
|
|
return current;
|
|
});
|
|
if (reqUpdate.ok) {
|
|
reqContent = reqUpdate.value;
|
|
} else if (!isPlaceholderReqId(reqId)) {
|
|
traceabilityWriteMisses.push(reqId);
|
|
}
|
|
|
|
// #2945 defect-2 (port of milestone.cts:200-210): if a row EXISTS for this
|
|
// ID but its Status write was rejected (row reads Out/Deferred/Blocked,
|
|
// which the callback returned unchanged), roll the checkbox back so the
|
|
// checkbox and the row cannot silently diverge. reqUpdate.ok === a row
|
|
// matched (existence probe); !tableHit === the callback did not advance it.
|
|
if (checkboxFlipped && reqUpdate.ok && !tableHit) {
|
|
reqContent = beforeCheckbox;
|
|
}
|
|
}
|
|
}
|
|
|
|
// #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
|
|
// Requirements under headings whose text contains "deferred", "backlog",
|
|
// "future", or an OFF-milestone `v<N>` (case-insensitive) are explicitly
|
|
// out of current scope and must not be flagged as missing from the
|
|
// Traceability table.
|
|
//
|
|
// Strategy: walk lines, track heading depth, and toggle a "deferred" flag
|
|
// when a heading matching the pattern is encountered. A sub-heading (higher
|
|
// depth) that is ITSELF in a deferred parent remains deferred unless it
|
|
// opens a same-or-shallower heading that does NOT match the pattern.
|
|
// Lines inside fenced code blocks (``` or ~~~) are treated as content, not
|
|
// headings, to avoid false deferred-section detection from code examples.
|
|
//
|
|
// #2334 BLOCKER fix (regresses closed bug #1159 against GSD's OWN
|
|
// shipped template): #2316-4a dropped the bare `v\d+` alternative
|
|
// entirely to stop it over-matching an ACTIVE heading like "## v1
|
|
// Requirements" — but the shipped `templates/requirements.md:35`
|
|
// scaffold ships `## v2 Requirements` / "Deferred to future release"
|
|
// as its ONLY deferred marker, and `v\d+` was the ONLY alternative
|
|
// that ever matched a bare version heading (the deferred-ness lives
|
|
// in body prose, not the heading text). Dropping it regressed #1159
|
|
// for every project scaffolded from the shipped template.
|
|
//
|
|
// Fix: make the `v<N>` alternative MILESTONE-AWARE instead of
|
|
// deleting it. A `## v<N> ...` heading is deferred ONLY when `<N>`
|
|
// (MAJOR version only — "v1" vs milestone "v1.3" is the SAME major
|
|
// version) does not match the CURRENT milestone's major version,
|
|
// resolved via `stateExtractField` against STATE.md's `milestone:`
|
|
// frontmatter field (the same seam `getMilestoneInfo`/state.cts's
|
|
// frontmatter builder already use — no bespoke frontmatter parsing).
|
|
// "## v1 Requirements" while the milestone is v1.x is the ACTIVE
|
|
// milestone's own section (#2316's original ask) and must NOT be
|
|
// swallowed; "## v2 Requirements" while the milestone is v1.x is a
|
|
// genuinely future milestone (#1159's ask, and the literal shipped-
|
|
// template shape) and MUST stay suppressed. `deferred`/`backlog`/
|
|
// `future` are unaffected by milestone resolution — a genuinely
|
|
// deferred heading always spells one of those words too (see
|
|
// #2316-5 regression guard: "## Deferred v2 Requirements", "##
|
|
// Future Backlog", "## Deferred", "## Backlog", "## Future").
|
|
//
|
|
// Fail-safe: when the milestone version cannot be resolved at all
|
|
// (no STATE.md, or no `milestone:` field), fall back to the OLD
|
|
// pre-#2316-4a behavior and treat every `v\d+` heading as deferred.
|
|
// A false "deferred" here only ever SUPPRESSES a warning — strictly
|
|
// safer than spamming a warning on every v\d+-headed scaffold when
|
|
// we cannot tell whether it names the active milestone.
|
|
const DEFERRED_KEYWORD_RE = /\b(?:deferred|backlog|future)\b/i;
|
|
const HEADING_VERSION_RE = /\bv(\d+)(?:\.\d+)*\b/i;
|
|
const stateRawForMilestone = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
|
|
const currentMilestoneRaw = stateRawForMilestone
|
|
? stateExtractField(stateRawForMilestone, 'milestone')
|
|
: null;
|
|
const currentMilestoneMajor = currentMilestoneRaw ? extractMajorVersion(currentMilestoneRaw) : null;
|
|
const bodyReqIds: string[] = [];
|
|
// deferredDepth: the heading level that opened the current deferred block,
|
|
// or 0 when we are in an active section.
|
|
let deferredDepth = 0;
|
|
let inFence = false;
|
|
for (const line of reqContent.split(/\r?\n/)) {
|
|
// Track fenced code blocks (``` or ~~~).
|
|
if (/^\s*(?:```|~~~)/.test(line)) {
|
|
inFence = !inFence;
|
|
continue;
|
|
}
|
|
if (inFence) continue; // ignore content inside a code fence
|
|
|
|
const headingM = line.match(/^(#{1,6})\s+(.*)/);
|
|
if (headingM) {
|
|
const depth = headingM[1].length;
|
|
const text = headingM[2];
|
|
if (deferredDepth > 0 && depth > deferredDepth) {
|
|
// Sub-heading inside a deferred block: stays deferred regardless of name.
|
|
continue;
|
|
}
|
|
// Heading at same level or shallower than current deferred opener,
|
|
// or no active deferred block yet.
|
|
if (DEFERRED_KEYWORD_RE.test(text)) {
|
|
deferredDepth = depth; // enter a deferred block
|
|
} else {
|
|
const versionMatch = text.match(HEADING_VERSION_RE);
|
|
if (versionMatch) {
|
|
const headingMajor = versionMatch[1];
|
|
deferredDepth =
|
|
currentMilestoneMajor === null || headingMajor !== currentMilestoneMajor
|
|
? depth // unresolved milestone (fail-safe) or off-milestone version -> deferred
|
|
: 0; // same major version as the current milestone -> active
|
|
} else {
|
|
deferredDepth = 0; // back in an active section
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (deferredDepth > 0) continue; // skip content in deferred sections
|
|
|
|
// Collect bold REQ-ID patterns from active-section lines.
|
|
const reqPat = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
|
|
let bodyMatch: RegExpExecArray | null;
|
|
while ((bodyMatch = reqPat.exec(line)) !== null) {
|
|
const id = bodyMatch[1];
|
|
if (!bodyReqIds.includes(id)) bodyReqIds.push(id);
|
|
}
|
|
}
|
|
|
|
const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
|
|
const traceabilitySection = traceabilityHeadingMatch
|
|
? reqContent.slice(traceabilityHeadingMatch.index)
|
|
: '';
|
|
const tableReqIds = new Set<string>();
|
|
// #2203: match REQ-IDs in any pipe-delimited cell (not just the first
|
|
// column) so a traceability table that leads with a status column (e.g.
|
|
// | ☐ | REQ-01 | …) is parsed correctly instead of reporting every row
|
|
// as missing.
|
|
const tableRowPat = /\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/g;
|
|
let tableMatch: RegExpExecArray | null;
|
|
while ((tableMatch = tableRowPat.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`,
|
|
);
|
|
}
|
|
|
|
// #2316-1: ghost REQ-IDs — cited by ROADMAP's own **Requirements:**
|
|
// line for this phase, but registered NOWHERE in REQUIREMENTS.md
|
|
// (neither its body nor its Traceability table). The `unregistered`
|
|
// check above only ever compares REQUIREMENTS.md's own body against
|
|
// its own Traceability table; it never consults `citedReqIds`, so an
|
|
// ID that ROADMAP cites but REQUIREMENTS.md never defines at all was
|
|
// previously invisible to every guard. `TBD` (the phase.add/-batch/
|
|
// -insert placeholder) is excluded — see #2316-7 boundary.
|
|
//
|
|
// #2334 HIGH 2: classify "ghost" by PROBING THE ACTUAL WRITE
|
|
// SURFACES this same function just wrote to (:1947 checkbox,
|
|
// :1967 Traceability row) — case-insensitively — mirroring
|
|
// milestone.cts's `notFound`/`hasRow`/`doneCheckbox` classification
|
|
// (src/milestone.cts:117-141,209-215), instead of set-differencing
|
|
// `bodyReqIds` (deferred-filtered, case-sensitive, bold-only) and
|
|
// `tableReqIds` (case-sensitive) against `citedReqIds`. Those two
|
|
// indexes can disagree with the writes: an ID under a `##
|
|
// Deferred` heading gets its checkbox ticked by the write loop
|
|
// above but is deliberately EXCLUDED from `bodyReqIds` by the
|
|
// deferred-heading filter (#1159), so the old set-diff reported it
|
|
// as an unregistered ghost in the SAME response that just ticked
|
|
// its checkbox; a case-mismatched citation (`known-01` vs
|
|
// `**KNOWN-01**`) lands its write via the writes' case-insensitive
|
|
// regexes but failed the old set-diff's case-SENSITIVE
|
|
// `Array.includes`/`Set.has`. An ID whose checkbox OR Traceability
|
|
// row actually matched is registered — not a ghost — regardless of
|
|
// which section (deferred or not) it lives under.
|
|
const reqIsRegisteredAnywhere = (id: string): boolean => {
|
|
const reqEscaped = escapeRegex(id);
|
|
// Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
|
|
// insensitive: existence check, not the write's space-only match.
|
|
if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
|
|
return true;
|
|
}
|
|
// Surface 2 — Traceability row exists at all (any Status value),
|
|
// via the SAME no-op-probe-through-updateTraceabilityCell
|
|
// technique milestone.cts's `hasRow` uses (:210-214): a case-
|
|
// insensitive first-cell match, regardless of current Status.
|
|
const rowProbeMatch = (row: Record<string, string>): boolean =>
|
|
(Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
|
|
return updateTraceabilityCell(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
|
|
};
|
|
const ghostReqIds = citedReqIds.filter(
|
|
(id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id),
|
|
);
|
|
if (ghostReqIds.length > 0) {
|
|
warnings.push(
|
|
`ROADMAP Phase ${phaseNum} cites REQ-ID(s) not registered anywhere in REQUIREMENTS.md (neither body nor Traceability table): ${ghostReqIds.join(', ')} — add them to REQUIREMENTS.md or correct the ROADMAP citation`,
|
|
);
|
|
}
|
|
|
|
// #2316-1 cont.: a cited ID whose Traceability-row write matched no
|
|
// row for a reason OTHER than being a ghost (e.g. a malformed table)
|
|
// still deserves a warning instead of a silent discard — but skip
|
|
// IDs already reported above as ghosts to avoid a duplicate message
|
|
// for the same root cause.
|
|
const traceabilityWriteFailures = traceabilityWriteMisses.filter(
|
|
(id) => !ghostReqIds.includes(id),
|
|
);
|
|
if (traceabilityWriteFailures.length > 0) {
|
|
warnings.push(
|
|
`REQUIREMENTS.md: Traceability row write skipped for REQ-ID(s) cited by ROADMAP (no matching row found): ${traceabilityWriteFailures.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
writes.push({ filePath: reqPath, before: originalReqContent, after: reqContent });
|
|
// #2316-3: `requirements_updated` must reflect whether REQUIREMENTS.md
|
|
// content actually CHANGED, not merely that the file existed in the
|
|
// transaction — mirrors the `writes.push({filePath,before,after})`
|
|
// diff-tracking pattern used for the ROADMAP write above. A phase
|
|
// whose citations match nothing (ghost REQ-IDs only) must report
|
|
// `false`, not a bare "the file was present" `true`.
|
|
requirementsUpdated = reqContent !== originalReqContent;
|
|
}
|
|
}
|
|
|
|
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));
|
|
|
|
for (const dir of dirs) {
|
|
const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, '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 {
|
|
/* best-effort (#2245 audit): stage 1 of a deliberate 3-stage
|
|
* cascading fallback for locating the next phase (disk dirs → roadmap
|
|
* headings/checkboxes → lowest-outstanding-checkbox override, #2028
|
|
* below). A disk-scan failure here is indistinguishable from "found
|
|
* nothing on disk" and correctly falls through to stage 2, which
|
|
* derives the same information independently from ROADMAP.md content
|
|
* — not a silent data-loss path. */
|
|
}
|
|
|
|
if (isLastPhase && roadmapContent !== null) {
|
|
try {
|
|
const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
|
|
// #1591: match BOTH heading-style phases (`### Phase N:`) AND
|
|
// checkbox-list items, INCLUDING the canonical bold form the roadmap
|
|
// template emits (`- [ ] **Phase N: Name**`). When the active
|
|
// milestone's checklist is `- [ ]` items inside a <details> block
|
|
// (and the next phase has no directory yet, so the disk-based
|
|
// resolver finds nothing), this roadmap-enumeration fallback is the
|
|
// only path that can find the next phase. The prior heading-only
|
|
// pattern missed checkbox items, and a checkbox-only broadening still
|
|
// missed the bold template rows → is_last_phase=true on a mid-milestone
|
|
// phase. Allow optional `**`/`__` emphasis after the marker and stop
|
|
// the name capture at emphasis so bold names slug cleanly; the number
|
|
// capture is unchanged.
|
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon
|
|
// ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
|
|
// `### Phase N (Cluster B): X` resolves. Captures are unchanged.
|
|
const phasePattern = new RegExp(
|
|
`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
|
|
'gi'
|
|
);
|
|
let pm: RegExpExecArray | null;
|
|
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
|
// #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
|
|
// already skips 999 dirs on disk; stage 2's heading scan must not
|
|
// advance into backlog headings. Mirrors the /^999(?:\.|$)/ guard
|
|
// stage 1 uses at line 2536, but via isSentinelPhaseId for both ranges.
|
|
if (isSentinelPhaseId(pm[1])) continue;
|
|
if (comparePhaseNum(pm[1], phaseNum) > 0) {
|
|
nextPhaseNum = pm[1];
|
|
nextPhaseName = pm[2]
|
|
.replace(/\(INSERTED\)/i, '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, '-');
|
|
isLastPhase = false;
|
|
break;
|
|
}
|
|
}
|
|
} catch {
|
|
/* best-effort (#2245 audit): stage 2 of the next-phase cascade
|
|
* (see stage 1's comment above) — a failure here just leaves
|
|
* isLastPhase as stage 1 left it; stage 3 (#2028) below runs next
|
|
* regardless and provides a further, independent override. */
|
|
}
|
|
}
|
|
|
|
// #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
|
|
// still outstanding. The two blocks above only clear isLastPhase when a
|
|
// HIGHER-numbered phase exists, so completing the numerically-highest phase
|
|
// out of order (e.g. Phase 10 before Phase 9) wrongly read as milestone-end.
|
|
// A phase is complete iff its roadmap checkbox is `[x]` (phase.complete sets
|
|
// this on completion — including the one just marked above); any earlier
|
|
// phase in this milestone whose checkbox is still `[ ]` means the milestone
|
|
// is not done, and the LOWEST such phase is the real next actionable item —
|
|
// point next_phase at it so STATE.md advances to the gap rather than parking
|
|
// on the just-completed phase. Roadmaps without phase checkboxes (heading-
|
|
// only) retain the prior behavior — there is nothing to scan. The checkbox
|
|
// pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
|
|
// between the box and "Phase", a required `:`) so unrelated checklist lines
|
|
// that merely mention "Phase N" don't match.
|
|
if (isLastPhase && roadmapContent !== null) {
|
|
try {
|
|
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
|
|
const cbPattern = new RegExp(
|
|
`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
|
|
'gi'
|
|
);
|
|
let cbm: RegExpExecArray | null;
|
|
let lowestOutstanding: { num: string; name: string } | null = null;
|
|
while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
|
|
const isChecked = cbm[1].toLowerCase() === 'x';
|
|
// #2949: exclude sentinel-range phase ids (0.x backlog, 999.x) from candidacy.
|
|
// comparePhaseNum("0.1","12") === -12, so without this guard an unchecked 0.x
|
|
// backlog row sorts below every real phase and is wrongly selected as next_phase,
|
|
// corrupting STATE.md and desyncing current_phase from current_phase_name.
|
|
// isSentinelPhaseId covers both sentinel ranges (SENTINEL_RANGES = [0, 999]); a
|
|
// real lower-numbered outstanding phase (e.g. Phase 9) is NOT a sentinel and is
|
|
// still selected, preserving #2028's out-of-order-completion behavior.
|
|
if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum) < 0) {
|
|
if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
|
|
lowestOutstanding = {
|
|
num: cbm[2],
|
|
name: cbm[3].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
if (lowestOutstanding !== null) {
|
|
isLastPhase = false;
|
|
nextPhaseNum = lowestOutstanding.num;
|
|
nextPhaseName = lowestOutstanding.name;
|
|
}
|
|
} catch {
|
|
/* best-effort (#2245 audit): stage 3 (#2028) of the next-phase
|
|
* cascade — a failure here simply leaves isLastPhase/nextPhaseNum
|
|
* as stages 1-2 already determined them; this stage only ever
|
|
* overrides toward "not last" when it finds a genuinely lower
|
|
* outstanding phase, never the reverse. */
|
|
}
|
|
}
|
|
|
|
if (fs.existsSync(statePath)) {
|
|
const originalStateContent = platformReadSync(statePath) || '';
|
|
let stateContent = originalStateContent;
|
|
|
|
// ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
|
|
// shape/name, Status, Current Plan, Last Activity + Description, and
|
|
// the Completed/Total Phases + Progress percent block) now dispatches
|
|
// to the STATE.md Transition Module. The ~90-line inline RMW callback
|
|
// that lived here is the pure `completePhaseCore` in
|
|
// src/state-transition.cts, backed by the field-classification table.
|
|
// `updatePerformanceMetricsSection` + `syncStateFrontmatter` stay in
|
|
// this adapter: they are section-table / disk-scan concerns, not
|
|
// classified fields, and `syncStateFrontmatter` is the post-sync this
|
|
// transaction needs (it does NOT go through readModifyWriteStateMd
|
|
// because STATE.md is committed atomically with ROADMAP/REQUIREMENTS).
|
|
const nextPhaseDisplayName =
|
|
phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
|
|
phaseDisplayNameFromSlug(nextPhaseName);
|
|
const completeResult = transitionCore(
|
|
stateContent,
|
|
{
|
|
kind: 'completePhase',
|
|
phaseNum,
|
|
nextPhaseNum,
|
|
nextPhaseName: nextPhaseDisplayName,
|
|
isLastPhase,
|
|
planCount,
|
|
summaryCount,
|
|
},
|
|
{
|
|
clock: realClock,
|
|
roadmapProvider: () => roadmapContent,
|
|
sourcePath: statePath,
|
|
},
|
|
);
|
|
stateContent = completeResult.content;
|
|
|
|
stateContent = updatePerformanceMetricsSection(
|
|
stateContent,
|
|
cwd,
|
|
phaseNum,
|
|
planCount,
|
|
summaryCount,
|
|
);
|
|
// #2736: the transition holds the next phase's exact display name in
|
|
// the intent; pass it as authoritative so the sync's prose
|
|
// re-derivation cannot rewrite current_phase_name to the name's own
|
|
// parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
|
|
stateContent = syncStateFrontmatter(
|
|
stateContent,
|
|
cwd,
|
|
nextPhaseDisplayName ? { current_phase_name: nextPhaseDisplayName } : undefined,
|
|
);
|
|
|
|
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
|
|
}
|
|
|
|
writePlanningFileSet(writes);
|
|
};
|
|
|
|
if (fs.existsSync(statePath)) {
|
|
withStateLock(statePath, runPhaseCompleteTransaction);
|
|
} else {
|
|
runPhaseCompleteTransaction();
|
|
}
|
|
return null;
|
|
});
|
|
|
|
if (verificationBlocked) {
|
|
const nextStep = verificationBlocked.next_command
|
|
? ` Next: ${verificationBlocked.next_command}`
|
|
: '';
|
|
// #3057 B3: purely additive to the message text — does not change WHETHER
|
|
// this blocks (verificationBlocked was already truthy) or the
|
|
// ERROR_REASON, only whether the operator can see the staleness check
|
|
// itself did not complete. The same fact is also attached as a typed
|
|
// field (`verification_stale_check_indeterminate`) on the JSON-error-mode
|
|
// payload so a test can assert on it by value instead of regexing this
|
|
// human-readable note.
|
|
const staleCheckIndeterminate = verificationBlocked.staleCheckIndeterminate === true;
|
|
const indeterminateNote = staleCheckIndeterminate
|
|
? ' (staleness check could not complete — see #3057)'
|
|
: '';
|
|
error(
|
|
`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}${indeterminateNote}`,
|
|
ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE,
|
|
{ verification_stale_check_indeterminate: staleCheckIndeterminate },
|
|
);
|
|
}
|
|
|
|
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')) as Record<string, unknown>;
|
|
const workflow = rawConfig['workflow'] as Record<string, unknown> | undefined;
|
|
const autoPruneEnabled = workflow && workflow['auto_prune_state'] === true;
|
|
if (autoPruneEnabled && fs.existsSync(statePath)) {
|
|
// Non-hoisted: load-order matters (stateMod must be fully resolved first).
|
|
const { cmdStatePrune } = stateMod;
|
|
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,
|
|
verification_stale_check_indeterminate: staleCheckIndeterminate,
|
|
};
|
|
|
|
output(result, raw);
|
|
}
|
|
|
|
function cmdPhaseUatPassed(
|
|
cwd: string,
|
|
phaseNum: string | undefined,
|
|
raw: boolean,
|
|
opts: { policy?: { requireVerification?: boolean } } = {},
|
|
): void {
|
|
if (!phaseNum) {
|
|
error('phase number required for phase uat-passed');
|
|
}
|
|
|
|
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum!);
|
|
if (!phaseInfoRaw) {
|
|
error(`Phase ${phaseNum} not found`);
|
|
}
|
|
const phaseInfo = phaseInfoRaw as unknown as Record<string, unknown>;
|
|
const phaseFullDir = path.join(cwd, phaseInfo['directory'] as string);
|
|
|
|
const report = evaluateUatPassed(phaseFullDir, { policy: opts.policy });
|
|
|
|
output({ phase: phaseNum, ...report }, raw);
|
|
}
|
|
|
|
// #1437 — phase.list-plans: list plan files for a given phase number.
|
|
// Returns the full scan result from scanPhasePlans so callers can read plan
|
|
// paths without re-discovering the phase directory themselves.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
|
import planScanMod = require('./plan-scan.cjs');
|
|
const { scanPhasePlans } = planScanMod;
|
|
|
|
function cmdPhaseListPlans(cwd: string, phaseNum: string | undefined, raw: boolean): void {
|
|
if (!phaseNum) {
|
|
error('phase number required for phase list-plans');
|
|
}
|
|
|
|
const phaseInfo = findPhaseInternal(cwd, phaseNum!);
|
|
if (!phaseInfo) {
|
|
output({ phase: phaseNum, plan_count: 0, has_plans: false, plans: [], phase_dir: null }, raw);
|
|
return;
|
|
}
|
|
|
|
const phaseDir = path.join(cwd, (phaseInfo as unknown as Record<string, unknown>)['directory'] as string);
|
|
const scan = scanPhasePlans(phaseDir);
|
|
const phaseRel = (phaseInfo as unknown as Record<string, unknown>)['directory'] as string;
|
|
|
|
// Build absolute-usable relative paths for each plan file.
|
|
const plans = scan.planFiles.map((f: string) => toPosixPath(path.join(phaseRel, f)));
|
|
|
|
output({
|
|
phase: phaseNum,
|
|
phase_dir: phaseRel,
|
|
plan_count: scan.planCount,
|
|
has_plans: scan.planCount > 0,
|
|
plans,
|
|
}, raw);
|
|
}
|
|
|
|
export = {
|
|
cmdPhasesList,
|
|
cmdPhaseNextDecimal,
|
|
cmdFindPhase,
|
|
cmdPhasePlanIndex,
|
|
cmdPhaseAdd,
|
|
cmdPhaseAddBatch,
|
|
cmdPhaseMvpMode,
|
|
cmdPhaseInsert,
|
|
cmdPhaseRemove,
|
|
cmdPhaseComplete,
|
|
cmdPhaseUatPassed,
|
|
cmdPhaseListPlans,
|
|
computeDependencyLevels,
|
|
};
|