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

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

1904 lines
98 KiB
TypeScript

/**
* Milestone — Milestone and requirements lifecycle operations.
*
* ADR-457 build-at-publish: the hand-written bin/lib/milestone.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.
*/
import fs from 'node:fs';
import path from 'node:path';
// 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, execGit, retryRenameSync, contentChangedAfterNormalize } from './shell-command-projection.cjs';
import { formatMsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { realClock } from './clock.cjs';
import { transitionCore } from './state-transition.cjs';
import { writeSetComplete } from './write-set.cjs';
import type { WriteSet } from './write-set.cjs';
import { updateTableCell, resetQuickTaskRows, QUICK_TASKS_SECTION_ABSENT } from './markdown-table.cjs';
import { requireSafePath, PathAcceptance, type ContainedPath } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- audit.cjs is an export= CommonJS module
import auditMod = require('./audit.cjs');
const { resolveQuickTaskSummaryFile } = auditMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output, error } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cliExitMod = require('./cli-exit.cjs');
const { ExitError } = cliExitMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateContract = require('./state-contract.cjs');
const { publishStateContract } = stateContract;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, matchPhaseDirs, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, isSentinelPhaseDir } = phaseIdMod;
import { escapeRegex } from './pattern.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const {
getMilestonePhaseFilter,
extractCurrentMilestone,
getMilestoneInfo,
sliceMilestoneWindow,
} = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtilsMod = require('./core-utils.cjs');
const { extractOneLinerFromBody, countMatchedSummaries } = coreUtilsMod;
// 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;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
import phaseLocatorMod = require('./phase-locator.cjs');
const { listMilestonePhaseDirs } = phaseLocatorMod;
const { planningPaths, resolvePhaseIdConvention } = planningWorkspace;
const { extractFrontmatter } = frontmatterMod;
// ADR-3408 §8.3 / #3469: `writeStateMd` gets sync and NO preservation — the
// same #3374-shaped exposure the milestone-complete write used to carry (a
// stale body value silently clobbering fresher frontmatter, with no
// divergence signal). Routed through the single write-seam composition
// (`syncAndPreserveStateMd`) instead, under `withStateLock` — see
// `cmdMilestoneComplete`'s own STATE.md-update block for the full rationale.
const { syncAndPreserveStateMd, withStateLock, readModifyWriteStateMd } = stateMod;
// #2288 security: a milestone version label becomes a filesystem directory
// component (`milestones/<label>-phases/`) into which phase directories are
// MOVED. Any label used as a path segment must be a safe version token —
// letters/digits/'.'/'-'/'_', leading alphanumeric, no path separators and no
// `..` (the leading-alphanumeric anchor rejects a bare `..`). This gates both
// the caller-supplied `--archive-version` override and the STATE.md-derived
// live-read value, so a crafted value cannot escape `.planning/milestones/`.
const ARCHIVE_VERSION_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
interface MilestoneCompleteOptions {
name?: string;
force?: boolean;
archivePhases?: boolean;
dryRun?: boolean;
// #3726: explicit mutation opt-in. `milestone complete` is a one-way door
// (ROADMAP/REQUIREMENTS archived, every phase directory in the milestone
// MOVED, STATE.md rewritten) that used to run unconditionally on first
// invocation — including through the `query` meta-prefix, whose name reads
// as a read. Without --confirm (and without --dry-run) the command now
// refuses before any mutation. Deliberately NOT folded into `force`:
// --force bypasses the narrow TRUNCATED-scope and unstarted-phase guards
// and must keep exactly that meaning (#3726 AC 4) — intent-to-mutate and
// intent-to-override-a-guard are different declarations.
confirm?: boolean;
// #2142: opt-in quick-task archival. Default OFF (unlike archivePhases,
// which is default-ON since #1871) — acceptance criterion 1 is explicit
// that Skip/absent must preserve today's behavior. Do NOT mirror
// archivePhases' inverted `--no-archive-phases` shape.
archiveQuick?: boolean;
}
/**
* 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
* (msd-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 phase.cts's `editProgressHeadingSlice` scoping of
* `## Progress` writes to that heading's own slice.
*
* 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 };
}
function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: boolean): void {
if (!reqIdsRaw || reqIdsRaw.length === 0) {
error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
}
// Accept comma-separated, space-separated, or bracket-wrapped: [REQ-01, REQ-02]
const reqIds = reqIdsRaw
.join(' ')
.replace(/[\[\]]/g, '')
.split(/[,\s]+/)
.map((r) => r.trim())
.filter(Boolean);
if (reqIds.length === 0) {
error('no valid requirement IDs found');
}
const reqPath = planningPaths(cwd).requirements;
if (!fs.existsSync(reqPath)) {
output({ updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds }, raw, 'no requirements file');
return;
}
let reqContent = fs.readFileSync(reqPath, 'utf-8');
const updated: string[] = [];
const alreadyComplete: string[] = [];
const notFound: string[] = [];
// #2140: IDs reconciled on the checkbox surface only — a traceability table
// exists but has no row for the ID. Without this bucket the payload for a
// partial reconcile is byte-identical to a full one, and audit-milestone (which
// reads the table) still sees Pending while the CLI reported success.
const tableUnmatched: string[] = [];
// A traceability table is present if the file has a requirement-ID column
// header: "Requirement", "Requirement ID", or "REQ-ID" (#2769/#2203) — kept
// in sync with the positional first-cell rowMatch/hasRow below so a
// REQ-ID-headed table (the real-world format) participates in the
// write-set and the #2140 drift check below, not just the "Requirement"
// case. A REQUIREMENTS.md with no such table is legitimate (mid-roadmap),
// so a missing row only counts as drift when a table actually exists.
const hasTable = /^\|\s*(?:Requirement(?:\s*ID)?|REQ[-\s]?ID)\s*\|/im.test(reqContent);
// ADR-2143 §6 per-surface write-set, tracked PER requirement ID: a
// multi-ID batch must not OR one ID's surface outcome into another's —
// that is the exact #2140 class one level up (an ID whose traceability
// row is absent/unmatched must not have its partial write masked by a
// different ID in the same invocation that fully reconciled). Reported
// additively as `write_set` below — it does not change the existing
// marked_complete/already_complete/not_found/table_unmatched/updated
// computation, which stays byte-for-behaviour identical (#2140's tactical
// fix already surfaces the checkbox-only-partial-write case via
// table_unmatched; this only adds the structured ADR-2143 shape on top).
const writeSet: WriteSet = [];
for (const reqId of reqIds) {
const reqEscaped = escapeRegex(reqId);
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
// Use replace() + compare to avoid the test()+replace() global regex
// lastIndex bug where test() advances state and replace() misses matches.
// (#2788 defect 2: the flip is CONDITIONAL — when a traceability row EXISTS
// for this ID but its Status write is rejected, the checkbox must NOT flip,
// so the two surfaces cannot silently diverge. The row-write outcome below
// gates whether the flip is kept.)
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
const beforeCheckbox = reqContent;
const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
const checkboxFlipped = afterCheckbox !== beforeCheckbox;
if (checkboxFlipped) reqContent = afterCheckbox;
// Surface 2 — the traceability row: | <REQ-ID> | Phase N | Pending | → ... Complete |
// via the markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
// regex. 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. Object.values(row) is in header order so [0] is the
// first column. Case-insensitive (mirrors the prior regex's 'i' flag).
const rowMatch = (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 ->
// Complete" gate is folded into the newValue callback so one
// updateTableCell call both probes the current value and writes.
let tableHit = false;
const tableUpdate = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
// #2788: accept `Gaps Found` as a forward input too — `revert-phase` (the
// documented gaps_found response) leaves a row stranded at Gaps Found with
// no inverse; a genuinely-satisfied requirement must be able to reach
// Complete again via mark-complete, or the milestone is blocked forever.
if (/^(pending|gaps found)$/i.test(current.trim())) {
tableHit = true;
return ' Complete ';
}
return current;
});
if (tableUpdate.ok) {
reqContent = tableUpdate.value;
}
// #2788 defect 2: if a row EXISTS for this ID but its Status write was
// rejected (e.g. the row reads `Blocked`, which mark-complete does not
// accept), roll the checkbox back so the checkbox and the row cannot
// silently diverge. The checkbox and the row are two representations of the
// same fact; flipping one while the other rejects the write is the lie.
let checkboxHit = checkboxFlipped;
const rowExistsProbe = tableUpdate; // ok === a row matched (probes existence)
if (checkboxFlipped && rowExistsProbe.ok && !tableHit) {
reqContent = beforeCheckbox;
checkboxHit = false;
}
// ADR-2143 §6 per-ID write-set entries: this ID's checkbox surface is
// always tracked; the traceability surface is tracked only when the file
// has a traceability table at all (same `hasTable` gate the existing
// required-surface logic below uses) — omitted entirely, not a false
// `applied:false`, when no table is required of this file.
writeSet.push({ requirement: reqId, surface: 'checkbox', applied: checkboxHit });
if (hasTable) {
writeSet.push({ requirement: reqId, surface: 'traceability', applied: tableHit });
}
// Coverage of the traceability surface for this ID (computed after any flip).
// hasRow keys on the ID's FIRST cell (the requirement-ID column, by position —
// see rowMatch above) so a bare mention of the ID in a non-traceability table
// does not masquerade as a real row.
// Ragged-tolerant (#2245 Blocker 2): same reasoning as the write above — a
// sibling row's raggedness must not blind this classification to a row
// that genuinely exists. Probe via a no-op updateTableCell write (its own
// tolerant scan) instead of findTableWithColumns (whole-table parse gate).
let currentStatusCell = '';
const statusProbe = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
currentStatusCell = current;
return current;
});
const hasRow = statusProbe.ok;
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent);
const doneTable = Boolean(hasRow && /^complete$/i.test(currentStatusCell.trim()));
// #2788 defect 2: when a traceability table exists AND this ID has a row in
// it, `updated`/`marked_complete` must reflect the ROW moving, not a
// checkbox-only flip. Otherwise (`table_unmatched` — no row for this ID, or
// no table at all) the checkbox flip is a legitimate partial reconcile / the
// sole completion surface, so the #2140 OR semantics are preserved.
const rowExists = hasTable && hasRow;
const idUpdated = rowExists ? tableHit : (checkboxHit || tableHit);
if (idUpdated) {
updated.push(reqId);
} else if (doneTable || (doneCheckbox && !hasTable)) {
// Fully reconciled: the table row is Complete, OR the checkbox is done and
// there is no table to reconcile against. (A [x] checkbox with a Pending or
// absent row is NOT fully reconciled when a table exists — #2140.)
alreadyComplete.push(reqId);
} else if (!doneCheckbox && !doneTable) {
notFound.push(reqId);
}
// else: doneCheckbox && hasTable && !doneTable — partially reconciled. It is
// neither updated, already_complete, nor not_found; the table_unmatched bucket
// below carries the truthful partial-reconcile signal.
// Surface traceability drift: checkbox reconciled (this run or before) but the
// table has no row for this ID. This is what makes a partial reconcile
// distinguishable from a full one (#2140).
if (hasTable && doneCheckbox && !hasRow) {
tableUnmatched.push(reqId);
}
}
if (updated.length > 0) {
platformWriteSync(reqPath, reqContent);
}
// ADR-2143 §6: `writeSet` above already carries one WriteOutcome per
// (requirement, surface) this invocation could have written to — per ID,
// not ORed across the batch. `write_set` and `write_set_complete` are
// additive: they do not replace or gate `updated` / `marked_complete` /
// `already_complete` / `not_found` / `table_unmatched`, which remain
// computed exactly as before (see #2140 note above — that fix already
// surfaces a checkbox-only partial write via `table_unmatched`;
// `write_set_complete` is a structured, ADR-2143-shaped read of the SAME
// per-surface, per-ID facts, `false` if ANY id's ANY required surface did
// not apply, since `writeSetComplete` requires EVERY entry to have
// applied, never an OR across surfaces OR across IDs).
output(
{
updated: updated.length > 0,
marked_complete: updated,
already_complete: alreadyComplete,
not_found: notFound,
table_unmatched: tableUnmatched,
total: reqIds.length,
write_set: writeSet,
write_set_complete: writeSetComplete(writeSet),
},
raw,
`${updated.length}/${reqIds.length} requirements marked complete`,
);
}
/**
* #2388: a requirement ID shared by more than one plan in a phase must not be
* handed to `cmdRequirementsMarkComplete` until every plan declaring it has
* finished (i.e. has a `*-SUMMARY.md`) — otherwise the ID reads `Complete` in
* REQUIREMENTS.md ~20 minutes before its sibling plans even run, and long
* before `verify_phase_goal` has a chance to catch a gap.
*
* Pure read-only gate: scans sibling `*-PLAN.md` files in the SAME phase
* directory as `planPath` (excluding `planPath` itself) and, for each
* candidate ID, blocks it only when a sibling plan ALSO declares that ID in
* its own `requirements:` frontmatter AND that sibling has no matching
* `*-SUMMARY.md` yet. An ID no sibling declares is never blocked — a
* single-plan (non-shared) ID is always `ready`, preserving immediate
* marking with no added latency (acceptance criterion 4). Does not read or
* write REQUIREMENTS.md itself; callers pass the `ready` subset on to
* `cmdRequirementsMarkComplete` (whose own flip semantics are untouched).
*/
function cmdRequirementsReadyIds(cwd: string, args: string[], raw: boolean): void {
const planPathArg = args[0];
if (!planPathArg) {
error('plan path required. Usage: requirements ready-ids <plan-path> REQ-01,REQ-02');
}
const reqIds = args
.slice(1)
.join(' ')
.replace(/[\[\]]/g, '')
.split(/[,\s]+/)
.map((r) => r.trim())
.filter(Boolean);
if (reqIds.length === 0) {
output({ ready: [], blocked: [], total: 0 }, raw, 'no requirement IDs provided');
return;
}
const planAbsPath = path.resolve(cwd, planPathArg);
// #3183: `planPathArg` may point at a root plan (`<phaseDir>/<n>-PLAN.md`)
// or a nested plan (`<phaseDir>/plans/PLAN-<n>.md`, #3139 layout) —
// scanPhasePlans always operates on the PHASE dir, so a nested plan needs
// one extra `dirname` to reach it, and its planFiles-relative identity
// carries the `plans/` prefix scanPhasePlans itself applies.
const isNestedPlanPath = path.basename(path.dirname(planAbsPath)) === 'plans';
const phaseDir = isNestedPlanPath ? path.dirname(path.dirname(planAbsPath)) : path.dirname(planAbsPath);
const currentRelative = isNestedPlanPath ? `plans/${path.basename(planAbsPath)}` : path.basename(planAbsPath);
// #3183: canonical plan/summary sets (root+nested, superseded-excluded)
// from the single owner, rather than a root-only hand-rolled readdirSync
// filter — a superseded sibling that still declares reqId with no SUMMARY
// used to block the ID forever (false-block); it is now excluded upstream.
const phaseScan = scanPhasePlans(phaseDir);
const siblingPlanFiles = phaseScan.planFiles.filter((f) => f !== currentRelative);
const parseFrontmatterReqIds = (content: string, sourcePath?: string): string[] => {
const fm = extractFrontmatter(content, sourcePath);
const fmReq = fm.requirements;
if (Array.isArray(fmReq)) return fmReq.map((r) => String(r).trim()).filter(Boolean);
if (typeof fmReq === 'string') {
return fmReq
.replace(/[\[\]]/g, '')
.split(/[,\s]+/)
.map((r) => r.trim())
.filter(Boolean);
}
return [];
};
const ready: string[] = [];
const blocked: string[] = [];
for (const reqId of reqIds) {
let blockedBySibling = false;
for (const siblingFile of siblingPlanFiles) {
const siblingPath = path.join(phaseDir, siblingFile);
let siblingContent: string;
try {
siblingContent = fs.readFileSync(siblingPath, 'utf-8');
} catch {
continue;
}
const siblingReqIds = parseFrontmatterReqIds(siblingContent, siblingPath);
const siblingDeclaresId = siblingReqIds.some((id) => id.toLowerCase() === reqId.toLowerCase());
if (!siblingDeclaresId) continue;
// Sibling declares the SAME ID — it must have finished (produced a
// SUMMARY) before this ID is ready to mark Complete. Canonical pairing
// via countMatchedSummaries (root+nested, all three naming forms)
// instead of a bespoke -PLAN.md→-SUMMARY.md regex swap.
const siblingHasSummary = countMatchedSummaries([siblingFile], phaseScan.summaryFiles) > 0;
if (!siblingHasSummary) {
blockedBySibling = true;
break;
}
}
if (blockedBySibling) blocked.push(reqId);
else ready.push(reqId);
}
output(
{ ready, blocked, total: reqIds.length },
raw,
`${ready.length}/${reqIds.length} requirement(s) ready to mark complete`,
);
}
/**
* #2388: revert this phase's own requirement IDs out of `Complete` when
* `verify_phase_goal` returns `gaps_found` — a gap verdict must not leave a
* premature `Complete` (from a shared ID's first-declaring plan, or from any
* other early write) sitting in REQUIREMENTS.md indefinitely.
*
* Mirrors `cmdRequirementsMarkComplete`'s two write surfaces in reverse:
* checkbox `[x]` -> `[ ]`, and traceability Status `Complete` -> `Gaps
* Found`. Phase-scoping is the CALLER's responsibility — this function only
* ever touches the exact IDs it is given, so a caller passing just this
* phase's own `phase_req_ids` never touches another phase's `Complete` row.
* Never call this on the pass path; it is `gaps_found`-only.
*/
function cmdRequirementsRevertPhase(cwd: string, reqIdsRaw: string[], raw: boolean): void {
const reqIds = (reqIdsRaw || [])
.join(' ')
.replace(/[\[\]]/g, '')
.split(/[,\s]+/)
.map((r) => r.trim())
.filter(Boolean);
if (reqIds.length === 0) {
output({ reverted: [], unchanged: [], total: 0 }, raw, 'no requirement IDs provided');
return;
}
const reqPath = planningPaths(cwd).requirements;
if (!fs.existsSync(reqPath)) {
output(
{ reverted: [], unchanged: reqIds, total: reqIds.length, reason: 'REQUIREMENTS.md not found' },
raw,
'no requirements file',
);
return;
}
let reqContent = fs.readFileSync(reqPath, 'utf-8');
const reverted: string[] = [];
const unchanged: string[] = [];
for (const reqId of reqIds) {
const reqEscaped = escapeRegex(reqId);
let idReverted = false;
// Surface 1 — checkbox: - [x] **REQ-ID** -> - [ ] **REQ-ID**
const checkboxPattern = new RegExp(`(-\\s*\\[)x(\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
const afterCheckbox = reqContent.replace(checkboxPattern, '$1 $2');
if (afterCheckbox !== reqContent) {
reqContent = afterCheckbox;
idReverted = true;
}
// Surface 2 — traceability row: Status Complete -> Gaps Found. Only
// flips a row currently reading Complete (mirrors mark-complete's own
// "only flip Pending -> Complete" gate, in reverse).
const rowMatch = (row: Record<string, string>): boolean =>
(Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
let tableHit = false;
const tableUpdate = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
if (/^complete$/i.test(current.trim())) {
tableHit = true;
return ' Gaps Found ';
}
return current;
});
if (tableUpdate.ok) {
reqContent = tableUpdate.value;
if (tableHit) idReverted = true;
}
if (idReverted) reverted.push(reqId);
else unchanged.push(reqId);
}
if (reverted.length > 0) {
platformWriteSync(reqPath, reqContent);
}
output(
{ reverted, unchanged, total: reqIds.length },
raw,
`${reverted.length}/${reqIds.length} requirement(s) reverted from Complete`,
);
}
/**
* #2142 (code-review FIX 4): the single owned "should the Quick Tasks
* Completed table be reset, and is a reset failure worth a warning" decision
* — shared by `cmdMilestoneComplete` (which folds this into its own
* `withStateLock` transform, since it already holds that lock for the
* closure-transition write happening in the same block) and `cmdQuickArchive`
* (which routes through `readModifyWriteStateMd`'s own transform instead, per
* the lock-reentrancy note on that function). Only the WRITE mechanics
* differ between the two callers — the decision itself ("skip a
* `QUICK_TASKS_SECTION_ABSENT` result silently; surface any other failure")
* was previously duplicated verbatim at both call sites.
*
* Never throws: a reset failure degrades to returning `content` unchanged
* with a non-null `warning`, mirroring both callers' pre-existing
* "liberal but visible" posture.
*/
function applyQuickTasksReset(content: string): { content: string; warning: { field: string; reason: string } | null } {
const resetResult = resetQuickTaskRows(content);
if (resetResult.ok) {
return { content: resetResult.value.content, warning: null };
}
if (resetResult.reason !== QUICK_TASKS_SECTION_ABSENT) {
return { content, warning: { field: 'quick_tasks_table', reason: resetResult.reason } };
}
return { content, warning: null };
}
function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCompleteOptions, raw: boolean): void {
if (!version) {
error('version required for milestone complete (e.g., v1.0) — and --confirm to mutate');
}
// #2288 security: `version` is a CLI positional that is interpolated into
// multiple filesystem sinks below — `path.join(archiveDir, `${version}-ROADMAP.md`)`,
// `${version}-REQUIREMENTS.md`, `${version}-MILESTONE-AUDIT.md`, and the
// `${version}-phases` archive directory that phase dirs are MOVED into. Reject
// path separators / `..` here (same guard as `--archive-version`) so a crafted
// version cannot write or relocate content outside `.planning/milestones/`.
if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
error(`milestone complete: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
}
// #3726: confirmation gate — refuse before ANY read of the tree beyond the
// arg checks above, so an unconfirmed invocation is a guaranteed no-op on
// disk. The threat model is the NEVER_VALID_FLAGS one (msd-tools.cjs): a
// caller supplies a token it believes is inert and a destructive operation
// proceeds unchecked — here the caller-side belief was that the `query`
// meta-prefix implies a read, and `query milestone.complete <v>` archived
// the milestone with no confirmation. The prefix is an intentional
// invocation-compatibility mechanism, not a permission boundary, so the
// gate lives on the destructive command itself and covers every invocation
// path. --dry-run needs no confirmation (it mutates nothing and is the
// recommended first step); --force does NOT imply it (see
// MilestoneCompleteOptions.confirm).
if (!options.dryRun && !options.confirm) {
error(
`milestone complete is irreversible: it archives ROADMAP.md and REQUIREMENTS.md, MOVES every phase ` +
`directory for ${version} into .planning/milestones/, and rewrites STATE.md. ` +
`Nothing has been changed. Re-run with --confirm to proceed, or --dry-run to preview exactly what would move.`,
);
}
const roadmapPath = planningPaths(cwd).roadmap;
const reqPath = planningPaths(cwd).requirements;
const statePath = planningPaths(cwd).state;
// #1911: derive the archive base from the workstream-aware planning root so
// `milestone complete --ws` archives into the workstream, not root. planningPaths(cwd).planning
// resolves to the workstream base when MSD_WORKSTREAM is set and to root .planning otherwise
// (flat mode is a no-op).
const planningBase = planningPaths(cwd).planning;
const milestonesPath = path.join(planningBase, 'MILESTONES.md');
const archiveDir = path.join(planningBase, 'milestones');
const phasesDir = planningPaths(cwd).phases;
const today = realClock.localToday();
const milestoneName = options.name || version;
// ADR-3408 §8.5 / #3469: "liberal but visible" — when the write-seam
// composition's preservation stage restores a curated frontmatter value
// over a disagreeing freshly-derived one, that divergence is surfaced
// here rather than silently absorbed (the direct answer to #3374's
// `warnings: []`). Structured (field + reason), not prose, so a caller can
// assert on the value rather than regex a rendered message.
//
// Named `preservation_warnings`, NOT `warnings`: `cmdPhaseComplete` already
// exposes a sibling field called `warnings` typed as prose `string[]`. Reusing
// that name here for a structured `{field, reason}[]` shape would be the
// "Generative Fix Divergence" anti-pattern — two sibling state commands
// sharing one field name with different element types. `warnings` stays
// one meaning (prose) repo-wide; this is a distinct, machine-assertable
// signal for ADR-3408 §8.5's "preservation is visible" rule. Check
// `preservation_warnings.length` rather than a companion `has_warnings`
// flag — that flag existed only to mirror `cmdPhaseComplete`'s channel,
// which this field intentionally does not claim to be.
const preservationWarnings: Array<{ field: string; reason: string }> = [];
// Scope stats and accomplishments to only the phases belonging to the
// current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
// (same logic used by cmdPhasesList and other callers).
// #3184 review finding: this scope computation + refusal MUST run BEFORE
// `platformEnsureDir(archiveDir)` below — a refused run (scope not COMPLETE,
// no --force) must be a true no-op on disk, and creating the archive
// directory first left an empty directory behind even on refusal.
const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
if (isDirInMilestone.missingExplicitVersion) {
error(`no phases found for milestone ${version} in ROADMAP.md`);
}
// #3184/#3166: `milestone complete` is the ONE-WAY-DOOR consumer of the
// milestone window (ROADMAP/REQUIREMENTS archived, phase directories
// MOVED). #3166 is specifically the TRUNCATED case: the milestone's
// heading IS found but its section closes before the phase region, and the
// phase filter degrades to pass-all (see getMilestonePhaseFilter above) —
// silently archiving every phase directory on disk. UNREADABLE (no
// ROADMAP.md at all) and UNSCOPED (no section for this version) are
// pre-existing, legitimately-handled states — `missingExplicitVersion`
// above already errors where that matters, and a missing ROADMAP.md has
// its own documented graceful path — so only TRUNCATED is refused here.
// The read-path consumers keep the pass-all degrade for every scope
// (ADR-3180 Decision 3's Rejected section: deny-all there would trade one
// silent wrong answer for another); this write path refuses on TRUNCATED
// alone, positioned before `platformEnsureDir` so a refusal stays a no-op
// on disk.
if (isDirInMilestone.scope === SCOPE.TRUNCATED && !options.force) {
error(
`Cannot mark milestone complete: the ROADMAP window for "${version}" is truncated ` +
`(the milestone heading was found but its section ends before reaching any phase ` +
`entries, even though the ROADMAP has phase entries elsewhere), so phase scoping ` +
`cannot be trusted for this destructive operation. Re-run with --force to override.`,
);
}
// Guard: prevent marking complete when ROADMAP still lists phases that have
// no directory on disk (disk_status: no_directory). This catches the case
// where the active milestone was erroneously marked complete before phases
// were even started. The scan scopes the ROADMAP via the `version` argument
// (getMilestonePhaseFilter / extractCurrentMilestone above) and runs whenever
// --force is absent — a fresh project with no `### Phase N:` headings in the
// scoped slice yields an empty `noDirectoryPhases` and the guard is a no-op,
// so no STATE match is required to avoid false positives.
// Pass --force to override this guard.
//
// #2946: the scan used to be nested inside `if (stateVersion && stateVersion
// === version)`, which silently disarmed the guard whenever STATE.md's
// `milestone:` field was desynced or absent — functionally an implicit
// --force on a one-way-door operation (ROADMAP/REQUIREMENTS archived, phase
// directories MOVED). The STATE field is not the source of truth for which
// phases belong to this milestone; the ROADMAP scoping is. The scan now runs
// unconditionally, and a present-but-mismatched STATE field emits a WARNING
// so the suspicious condition is visible rather than silent.
if (!options.force) {
try {
// Read STATE.md's milestone field only to detect a suspicious mismatch;
// it no longer gates the scan. (#2946)
let stateVersion: string | null = null;
try {
const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
if (stateRaw) {
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
if (milestoneMatch) stateVersion = milestoneMatch[1].trim();
}
} catch {
/* skip — stateVersion stays null, scan still runs */
}
if (stateVersion !== null && stateVersion !== version) {
// #2946: emit a WARNING so the suspicious STATE mismatch is visible
// rather than silently disarming the guard. Plain-text diagnostic on
// stderr, matching the existing [msd-tools] WARNING convention
// (state.cts). A missing STATE.md `milestone:` field is not warned
// here — the scan still runs, and "no milestone declared" is a normal
// state for a fresh project, not a suspicious drift.
//
// `stateVersion` comes from a user-controlled file (STATE.md) and is
// not validated like the CLI `version` arg (ARCHIVE_VERSION_LABEL_RE).
// Sanitize before interpolating into stderr so ANSI escapes / control
// chars / secret-looking strings cannot be echoed verbatim into a CI
// log or terminal (CONTRIBUTING.md security: secret-looking values in
// stderr). `version` is already constrained to [A-Za-z0-9._-].
const safeStateVersion = stateVersion.replace(/[\x00-\x1f\x7f]/g, '?').slice(0, 80);
process.stderr.write(
`[msd-tools] WARNING: STATE.md milestone: "${safeStateVersion}" ≠ requested "${version}" — ` +
`running the unstarted-phase guard against the ROADMAP scoped for "${version}" anyway.\n`,
);
}
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
// #3184/#2946: scope the unstarted-phase guard to the same `version`
// window `getMilestonePhaseFilter` used above, NOT to
// extractCurrentMilestone's own STATE.md-derived window — those two
// can disagree (that disagreement is exactly what the WARNING above
// detects), and scoping this guard to the wrong window under-detects
// unstarted phases on the destructive completion path. Calls the same
// sliceMilestoneWindow owner getMilestonePhaseFilter's versionOverride
// branch calls (a prior pass here re-composed locate+select+section-end
// locally, which review caught as a second, disagreeing derivation of
// the same window — ADR-3180 Decision 4(c)); falls back to
// extractCurrentMilestone's whole-document result only for the
// free-form (no versioned milestones anywhere) shape, where both
// windows converge to the same value regardless of which version drove
// the lookup.
const scopedContent = sliceMilestoneWindow(roadmapContent, version) ?? extractCurrentMilestone(roadmapContent, cwd);
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
const noDirectoryPhases: string[] = [];
let pm: RegExpExecArray | null;
const phaseDirEntries = ((): string[] => {
try {
return fs
.readdirSync(phasesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
} catch {
return [];
}
})();
while ((pm = phasePattern.exec(scopedContent)) !== null) {
const phaseNum = pm[1];
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not
// real phases — they legitimately have no directory and must not block
// milestone completion. Mirrors the engine-wide sentinel convention
// (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
// the #1445 /^999/ progress filters). (#1580)
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this local check already covered both 0 and 999; now delegates to the single canonical owner.
if (isSentinelPhaseId(phaseNum)) continue;
const normalized = normalizePhaseName(phaseNum);
// A phase has disk_status: 'no_directory' when no phase directory
// with a matching token exists on disk. Use the same matchPhaseDirs
// owner that roadmap.analyze uses to avoid false positives on decimal
// (2.1) and letter-suffix (12A) phase IDs. (#2528)
const hasDirectory = matchPhaseDirs(phaseDirEntries, normalized).matches.length > 0;
if (!hasDirectory) {
noDirectoryPhases.push(phaseNum);
}
}
if (noDirectoryPhases.length > 0) {
error(
`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`,
);
}
} catch (e) {
// ADR-3889: error() now throws ExitError (carries no message) instead
// of calling process.exit() directly, so it can no longer be detected
// by sniffing e.message — an ExitError from our own guard above must be
// re-thrown UNCONDITIONALLY, before any message inspection, or the
// guard silently stops blocking milestone completion.
if (e instanceof ExitError) throw e;
// If the error came from our guard, re-throw it; otherwise skip silently.
const message = e instanceof Error ? e.message : String(e);
if (message && message.startsWith('Cannot mark milestone complete:')) throw e;
// Phase scan failed (e.g. ROADMAP unreadable) — allow completion to proceed.
}
}
// Gather stats from phases (scoped to current milestone only)
let phaseCount = 0;
let totalPlans = 0;
let totalTasks = 0;
const accomplishments: string[] = [];
// #2761 (round-11 BLOCKER): resolved ONCE, ambiently (no explicit `ws` —
// this function has none of its own and already resolves everything else
// off `cwd` via planningPaths(cwd), matching the ambient-workstream
// contract `resolvePhaseIdConvention`/`planningDir` share), and threaded
// into the single `listMilestonePhaseDirs` call directly below. Left
// unthreaded, `phaseIdConvention` stays `undefined` and
// `getMilestonePhaseFilter` silently resolves it FROM CONFIG itself —
// which is exactly the behavior the changeset originally (and wrongly)
// claimed this PR does not reach: on a bracket-convention project that
// lazily-resolved convention changes `milestonePhaseDirs`/
// `milestonePhaseScope` below, and this function goes on to ARCHIVE
// (rename/move) whatever that set names. Resolving explicitly here removes
// the silent-inherit path without changing behavior for `null` /
// `milestone-prefixed` projects, whose resolved convention is the same
// value `undefined` would have lazily produced anyway.
const phaseConvention = resolvePhaseIdConvention(cwd);
// #3597 (ADR-3180 Decision 2): SINGLE resolution of "which phase
// directories belong to the current milestone" AND the SCOPE discriminator
// that resolution came from — shared verbatim by the read-only stats loop
// immediately below, the --dry-run preview, and the real archive pass, so
// none of the three can ever disagree. `listMilestonePhaseDirs` never
// throws (its own doc comment), so this is safe to call unguarded ahead of
// the try/catch that scopes the stats roll-up below.
//
// The stats loop's own ENUMERATION behavior is intentionally left
// unaffected by a non-COMPLETE scope — it reports what is actually on
// disk, same as before #3597. Only the destructive archive pass (and its
// --dry-run preview) refuses to act on a non-COMPLETE (non-answer) scope;
// see the guard built from `milestonePhaseScope` further down.
const { value: milestonePhaseDirs, scope: milestonePhaseScope } = listMilestonePhaseDirs(phasesDir, {
cwd,
versionOverride: version,
phaseIdConvention: phaseConvention,
});
try {
for (const dir of milestonePhaseDirs) {
phaseCount++;
// #3183: canonical plan/summary sets (root+nested, superseded-excluded)
// from the single owner, rather than a root-only hand-rolled readdirSync
// filter.
const phaseScan = scanPhasePlans(path.join(phasesDir, dir));
const summaries = phaseScan.summaryFiles;
totalPlans += phaseScan.planCount;
// Extract one-liners from summaries
for (const s of summaries) {
try {
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
const fm = extractFrontmatter(content, path.join(phasesDir, dir, s));
const rawOneLiner = fm['one-liner'];
const oneLiner = (typeof rawOneLiner === 'string' ? rawOneLiner : '') || extractOneLinerFromBody(content);
if (oneLiner) {
accomplishments.push(oneLiner);
}
// Count tasks: prefer **Tasks:** N from Performance section,
// then <task XML tags, then ## Task N markdown headers
const tasksFieldMatch = content.match(/\*\*Tasks:\*\*\s*(\d+)/);
if (tasksFieldMatch) {
totalTasks += parseInt(tasksFieldMatch[1], 10);
} else {
const xmlTaskMatches = content.match(/<task[\s>]/gi) || [];
const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
totalTasks += xmlTaskMatches.length || mdTaskMatches.length;
}
} catch {
/* best-effort (#2245 audit): one unreadable/malformed SUMMARY.md
* must not abort the accomplishments/task-count roll-up for every
* OTHER summary across every OTHER phase — it's simply excluded
* from the milestone's shipped-summary text. */
}
}
}
} catch {
/* best-effort (#2245 audit): mirrors the phaseDirEntries IIFE a few
* lines below this function (same phasesDir, same "try readdirSync,
* tolerate ENOENT" pattern) — phasesDir may legitimately not exist yet
* (e.g. milestone being force-completed before any phase directories
* were created). Degrades stats to phaseCount/totalPlans/totalTasks=0,
* accomplishments=[] rather than crash `milestone complete`. */
}
// #3597 (ADR-3180 Decision 2): `SCOPE.UNREADABLE` is the ONE classification
// where `getMilestonePhaseFilter` throws (no workstream ROADMAP of its
// own), leaves `milestonePhaseNums` empty, and the window degrades to a
// pass-all fallback — that fallback is what silently WIDENS the archive
// set past the single-derivation guarantee this block exists to protect
// (confirmed empirically: a workstream with phase dirs but no workstream
// ROADMAP.md reports UNREADABLE and used to enumerate every directory on
// disk). `SCOPE.TRUNCATED` already refuses the WHOLE command above.
// `SCOPE.UNSCOPED` is a DIFFERENT, pre-existing classification — e.g. a
// root project with no milestone asserted in STATE.md — whose
// `listMilestonePhaseDirs` resolution is a real (non-degraded) answer, and
// its rollover archive behavior predates this branch and must not change.
// The guard therefore refuses ONLY on UNREADABLE, not on "not COMPLETE" —
// widening to every non-COMPLETE scope was itself a regression (a root
// project with no active workstream resolves UNSCOPED, and refusing to
// archive there broke the ordinary `milestone complete` -> `phases clear`
// rollover). Computed once here from the single
// `milestonePhaseDirs`/`milestonePhaseScope` resolution above, so the
// --dry-run preview below and the real archive pass further down can never
// disagree about whether (or what) to archive.
const phasesArchiveSkippedForScope = options.archivePhases !== false && milestonePhaseScope === SCOPE.UNREADABLE;
const phasesArchiveSkipReason = phasesArchiveSkippedForScope
? `milestone window scope is "${milestonePhaseScope}" — refusing to archive phase directories until the window can be resolved (ADR-3180)`
: null;
// #2118: --dry-run preview — compute what WOULD happen without mutating.
// The stats above are read-only; all mutations start at the archive section below.
if (options.dryRun) {
const phaseDirsToArchive: string[] = [];
if (options.archivePhases !== false && !phasesArchiveSkippedForScope) {
// #3185 (ADR-3180 Decision 1) / #3597: same single routed derivation as
// the stats loop above — the dry-run preview must list exactly what
// the real archive pass below would move, including refusing to list
// anything when the window scope is not COMPLETE.
phaseDirsToArchive.push(...milestonePhaseDirs);
}
// #2142 MAJOR 5 (review): dry-run preview of quick-task archival —
// read-only, routed through the SAME `listQuickTaskDirsForArchive`
// selection `archiveQuickTaskDirectories` uses for real (directory
// entries only, `requireSafePath`-guarded, sorted) so this preview can
// never disagree with what a real run actually archives. Absent
// --archive-quick this stays `[]` and nothing on disk is touched either
// way (dry-run always returns before any mutation below).
const quickDirsToArchive: string[] = options.archiveQuick ? listQuickTaskDirsForArchive(cwd).map((d) => d.name) : [];
const dryRunResult = {
dry_run: true,
version,
name: milestoneName,
stats: { phases: phaseCount, plans: totalPlans, tasks: totalTasks },
accomplishments,
would_archive: {
roadmap: fs.existsSync(roadmapPath)
? { source: path.relative(cwd, roadmapPath).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-ROADMAP.md`)).split(path.sep).join('/') }
: null,
requirements: fs.existsSync(reqPath)
? { source: path.relative(cwd, reqPath).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-REQUIREMENTS.md`)).split(path.sep).join('/') }
: null,
audit: fs.existsSync(path.join(planningBase, `${version}-MILESTONE-AUDIT.md`))
? { source: path.relative(cwd, path.join(planningBase, `${version}-MILESTONE-AUDIT.md`)).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)).split(path.sep).join('/') }
: null,
phases: phaseDirsToArchive,
phases_archive_skipped: phasesArchiveSkippedForScope,
phases_archive_skip_reason: phasesArchiveSkipReason,
quick: quickDirsToArchive,
},
would_update: {
milestones_md: path.relative(cwd, milestonesPath).split(path.sep).join('/'),
state_md: fs.existsSync(statePath) ? path.relative(cwd, statePath).split(path.sep).join('/') : null,
},
};
output(dryRunResult, raw);
return;
}
// Ensure archive directory exists. Deliberately placed AFTER the dry-run
// early return and every refusal/guard above (missingExplicitVersion, the
// scope refusal, the unstarted-phase guard) — #3184 review finding: this
// used to run before those checks, so a refused run still left an empty
// archive directory behind. Reaching this point means the run is
// committed to mutating.
platformEnsureDir(archiveDir);
// Archive ROADMAP.md
if (fs.existsSync(roadmapPath)) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
platformWriteSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent);
}
// Archive REQUIREMENTS.md
if (fs.existsSync(reqPath)) {
const reqContent = fs.readFileSync(reqPath, 'utf-8');
// Derive the display path from the same source the writer uses (reqPath), so a
// workstream archive header points at `.planning/workstreams/<ws>/REQUIREMENTS.md`
// instead of the hardcoded root path (#1993). Root case is byte-identical.
// Normalize to POSIX separators so the header is cross-platform (Windows
// path.relative yields backslashes; the original literal was forward-slash).
const reqDisplay = path.relative(cwd, reqPath).split(path.sep).join('/');
const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`${reqDisplay}\`.\n\n---\n\n`;
platformWriteSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent);
}
// Archive audit file if exists
const auditFile = path.join(planningBase, `${version}-MILESTONE-AUDIT.md`);
if (fs.existsSync(auditFile)) {
retryRenameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
}
// Create/append MILESTONES.md entry
const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
// #3685: mirror requirementsUpdated's diff-tracking contract — the result
// below used to report `milestones_updated: true` hardcoded, never
// consulting whether the MILESTONES.md write actually changed anything.
// Captured before the write branches below so the after-comparison reports
// a real content diff instead of an assumed one.
const milestonesBefore = fs.existsSync(milestonesPath) ? fs.readFileSync(milestonesPath, 'utf-8') : null;
if (fs.existsSync(milestonesPath)) {
const existing = fs.readFileSync(milestonesPath, 'utf-8');
if (!existing.trim()) {
// Empty file — treat like new
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
} else {
// Insert after the header line(s) for reverse chronological order (newest first)
// #3415: empirically verified linear-time up to 5MB adversarial input (worst-case
// no-newline-at-all forcing full [^\r\n]* backtrack: 0.11ms@10KB -> 6.9ms@5MB).
// Non-global, `^`-anchored (no /m) so this is a single match attempt at position 0
// only — never rescanned at every offset — with no nested repeated group, so it
// cannot exhibit the #2128-class catastrophic backtracking.
// eslint-disable-next-line local/no-unbounded-quantifier -- single ^-anchored non-global attempt at pos 0, measured linear to 5MB, no nested quantifier
const headerMatch = existing.match(/^(#{1,3}\s+[^\r\n]*\r?\n(?:\r?\n)?)/);
if (headerMatch) {
const header = headerMatch[1];
const rest = existing.slice(header.length);
platformWriteSync(milestonesPath, header + milestoneEntry + rest);
} else {
// No recognizable header — prepend the entry
platformWriteSync(milestonesPath, milestoneEntry + existing);
}
}
} else {
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
}
// #3685: real content diff, not the hardcoded `true` this used to report —
// see the `milestonesBefore` capture above.
const milestonesAfter = fs.existsSync(milestonesPath) ? fs.readFileSync(milestonesPath, 'utf-8') : null;
const milestonesUpdated = milestonesAfter !== milestonesBefore;
// #2142 BLOCKER 2 (review): opt-in quick-task archival. This call MUST sit
// immediately adjacent to the STATE.md write block directly below it, with
// NO unguarded IO in between (unlike the ROADMAP/REQUIREMENTS/audit/
// MILESTONES.md writes above, none of which are wrapped in a try/catch).
// If the move ran earlier — e.g. right after `platformEnsureDir(archiveDir)`
// — and any one of those unguarded writes then threw, the quick-task
// directories would already be gone from `.planning/quick/` while the
// STATE.md Quick Tasks table reset (which lives inside `withStateLock`
// immediately below) would never be reached. That is precisely the
// STATE-vs-disk drift #2142 exists to eliminate: a table still describing
// directories that no longer exist. Keeping the move and the reset
// adjacent — separated only by this comment, never by IO that can throw —
// means either both happen or (if the move itself throws) neither does.
// `archiveQuick` is opt-in (default OFF); absent the flag this is `null`
// and every downstream read of it degrades to "no quick archival happened".
const quickArchiveResult = options.archiveQuick
? archiveQuickTaskDirectories(cwd, version)
: null;
// Update STATE.md — keep frontmatter/body semantically aligned after closure.
// ADR-1769 Phase 5: dispatches to the STATE.md Transition Module. The closure
// write (Status, Last Activity, Last Activity Description, Current Position
// reset, Operator Next Steps reset) is the pure `milestoneCompleteCore` in
// src/state-transition.cts, backed by the field-classification table. The
// runtime-specific next-milestone slash command is resolved here and injected
// via the intent so the core stays pure.
//
// ADR-3408 §8.3 / #3469: this used to write via `writeStateMd`, which gets
// sync and NO preservation — the identical shape #3374 reported for
// `phase.complete` (a stale body value silently clobbering fresher
// frontmatter). Routed through the single write-seam composition
// (`syncAndPreserveStateMd`) instead, under the same lock discipline
// `cmdPhaseComplete`'s atomic-commit adapter already uses: `withStateLock`
// wraps read + transform + sync + preserve + write so the read this
// transaction bases its transform on cannot be raced by a concurrent
// writer (closing a pre-existing TOCTOU gap `writeStateMd`'s own internal
// lock never covered, since the read used to happen before any lock was
// taken). `resync: true` mirrors `cmdPhaseComplete`'s posture (progress
// recomputed from disk; only the preserve-when-unchanged deltas apply) —
// milestone completion is the same kind of lifecycle transition.
// #3685: mirror requirementsUpdated's diff-tracking contract — this used to
// report `state_updated: fs.existsSync(statePath)`, true even on a no-op
// transaction. Declared here beside `stateUpdated`'s sibling flags and
// defaulted to `false` so the "STATE.md absent" case keeps today's answer
// (existsSync also returns false there) reached via a real content
// comparison instead.
let stateUpdated = false;
if (fs.existsSync(statePath)) {
withStateLock(statePath, () => {
const originalStateContent = platformReadSync(statePath) || '';
const result = transitionCore(
originalStateContent,
{
kind: 'milestoneComplete',
version,
nextMilestoneCommand: formatMsdSlash('new-milestone', resolveRuntime(cwd)) as string,
},
{ clock: realClock, sourcePath: statePath },
);
const divergedFields: string[] = [];
// #2111 (found by #3471 review): `milestoneCompleteCore` never declares
// `current_phase`/`current_phase_name` among the fields it touches — but
// its ## Current Position reset REWRITES the `Phase:` prose line to a
// closure message ("Milestone vX.Y complete"), which is not a number.
// That is an unavoidable side effect of the wholesale section reset
// `resetSectionVerbatim` performs, not an intent to change the phase.
// Downstream, `current_phase`/`current_phase_name` are
// `preserve-when-unchanged` rows: the #1230 delta heuristic sees the
// body source go from a real value to unparseable and — correctly, per
// ADR-3408 §8.5 Row 2 — lets the derived (empty) value win, discarding
// the curated phase entirely. §8.5 Row 2 governs a genuine mid-write
// body edit (e.g. `state.patch` deleting the Phase line); milestone
// closure is a different shape — the transition never intended to
// touch these fields at all. Re-assert them via `authoritativeFm` (the
// same #2736 intent-first mechanism `beginPhaseCore`/`completePhaseCore`
// already use to freeze a field the transition resolved out-of-band),
// so the closure-message side effect cannot clobber the last real
// phase. Scoped to non-empty strings only, mirroring #2736's own guard.
const authoritativeFm: Record<string, unknown> = {};
const preFm = extractFrontmatter(originalStateContent, statePath) as Record<string, unknown>;
const preCurrentPhase = preFm['current_phase'];
const preCurrentPhaseName = preFm['current_phase_name'];
if (typeof preCurrentPhase === 'string' && preCurrentPhase.trim().length > 0) {
authoritativeFm['current_phase'] = preCurrentPhase;
}
if (typeof preCurrentPhaseName === 'string' && preCurrentPhaseName.trim().length > 0) {
authoritativeFm['current_phase_name'] = preCurrentPhaseName;
}
// #2142: fold the Quick Tasks table reset into this SAME
// `withStateLock` transform — no second lock acquisition, no second
// `syncAndPreserveStateMd`/`platformWriteSync` pass. Only applied when
// quick archival actually MOVED something (never when the flag was
// absent, and never for a mere dry-run preview, which never reaches
// here at all). A refused reset degrades to leaving the content
// untouched and never fails milestone completion, but the two refusal
// shapes are NOT equally noteworthy (design doc §40, behavior table
// row 5): an ABSENT "Quick Tasks Completed" section is the normal,
// common case — the section is created lazily by
// `msd-core/workflows/quick.md` Step 7b, not by
// `msd-core/templates/state.md`, so most projects simply don't have
// one — and is silently skipped (compared via the shared
// `QUICK_TASKS_SECTION_ABSENT` sentinel, never by matching on the
// free-form reason string). A section that EXISTS but couldn't be
// reset (unparseable table, or columns matching neither registered
// QuickTasks variant) is a genuine anomaly and IS surfaced via
// `preservationWarnings`, the same "liberal but visible" posture the
// rest of this block already uses for a disagreeing derived STATE.md
// value.
let quickTasksResetContent = result.content;
if (quickArchiveResult && quickArchiveResult.archived > 0) {
const { content: resetContent, warning } = applyQuickTasksReset(quickTasksResetContent);
quickTasksResetContent = resetContent;
if (warning) preservationWarnings.push(warning);
}
const finalContent = syncAndPreserveStateMd(
originalStateContent,
quickTasksResetContent,
statePath,
cwd,
{
resync: true,
authoritativeFm: Object.keys(authoritativeFm).length > 0 ? authoritativeFm : undefined,
divergedFields,
},
);
platformWriteSync(statePath, finalContent);
// #3685 / #3691: compare NORMALIZED bytes, not the pre-normalize
// `finalContent` string, against the pre-normalize `originalStateContent`
// read above. `platformWriteSync` runs Markdown normalization (blank-line
// insertion around headings/fences/lists) before persisting — the
// transition core (`transitionCore`'s `## Current Position` section
// reset) regenerates that section fresh on every call, including on a
// genuine no-op re-run, and its raw un-normalized output differs from
// the already-normalized on-disk original even though the write
// converges to byte-identical content. Comparing pre-normalize strings
// (mirroring cmdPhaseComplete's shape verbatim) was verified live to
// report `true` on three consecutive byte-identical writes.
// `contentChangedAfterNormalize` runs BOTH sides through the exact same
// normalizer `platformWriteSync` used to persist (no extra disk I/O,
// and immune by construction to this ordering artifact) — this used to
// re-read the file to get the same answer; #3691 hoisted that seam so
// this site, `updateRoadmapAfterPhaseRemoval`, and `cmdPhaseComplete`'s
// roadmap/state/requirements flags all agree by construction.
stateUpdated = contentChangedAfterNormalize(statePath, originalStateContent, finalContent);
for (const field of divergedFields) {
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
}
// The authoritativeFm re-assert above (unlike a delta-based restore) is
// invisible to `divergedFields` — #2736's re-assert runs after that
// diff — so surface it explicitly here for "liberal but visible".
for (const field of Object.keys(authoritativeFm)) {
if (!divergedFields.includes(field)) {
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
}
}
});
}
// Archive phase directories if requested
let phasesArchived = false;
// #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
// #3597: `phasesArchiveSkippedForScope` (computed once, above, from the SAME
// `milestonePhaseDirs`/`milestonePhaseScope` resolution the --dry-run preview
// consumed) refuses this destructive rename loop entirely when the window
// scope is UNREADABLE — the one scope whose resolution degrades to a
// pass-all fallback (ADR-3180). UNSCOPED/TRUNCATED are real, pre-existing
// answers and archive exactly as they did before this branch.
// `phasesArchived` stays false and the refusal is surfaced on `result` below;
// nothing on disk moves, and `phaseArchiveDir` is never even created.
if (options.archivePhases !== false && !phasesArchiveSkippedForScope) {
// #2245 audit (was ERROR-HIDING): retryRenameSync moves one phase dir at a
// time — a mid-loop failure (e.g. the Nth rename) used to leave
// `phasesArchived` at its `false` default even though the first N-1 dirs
// had ALREADY been moved to phaseArchiveDir on disk, silently
// under-reporting a real partial archive in the JSON result. archivedCount
// is now computed in a `finally` so it reflects whatever succeeded before
// any failure, instead of being lost with the swallowed exception.
let archivedCount = 0;
try {
const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
platformEnsureDir(phaseArchiveDir);
// #3185 (ADR-3180 Decision 1) / #3597: same single routed derivation as
// the stats loop and the --dry-run preview above — only the CURRENT
// milestone's phase directories move, never a sentinel or an
// out-of-window directory left for a later milestone, and never a
// pass-all degrade from a non-COMPLETE scope (refused above).
for (const dir of milestonePhaseDirs) {
retryRenameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
archivedCount++;
}
} catch {
/* best-effort: phasesDir may not exist yet, or the archive rename loop
* failed partway — phasesArchived below still reflects whatever
* archivedCount succeeded before the failure. */
} finally {
phasesArchived = archivedCount > 0;
}
}
const result = {
version,
name: milestoneName,
date: today,
phases: phaseCount,
plans: totalPlans,
tasks: totalTasks,
accomplishments,
archived: {
roadmap: fs.existsSync(path.join(archiveDir, `${version}-ROADMAP.md`)),
requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)),
audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
phases: phasesArchived,
// #3597: machine-readable refusal signal — distinguishes "nothing to
// archive because the milestone genuinely has no phase directories"
// (phases: false, phases_archive_skipped: false) from "refused to
// archive because the milestone window scope was not COMPLETE"
// (phases: false, phases_archive_skipped: true, with a reason).
phases_archive_skipped: phasesArchiveSkippedForScope,
phases_archive_skip_reason: phasesArchiveSkipReason,
quick: !!quickArchiveResult && quickArchiveResult.archived > 0,
},
// #3685: mirror requirementsUpdated's diff-tracking contract — both flags
// now report a real before/after content diff instead of the previous
// hardcoded `true` (milestones_updated) / bare fs.existsSync (state_updated).
milestones_updated: milestonesUpdated,
state_updated: stateUpdated,
preservation_warnings: preservationWarnings,
};
output(result, raw);
// #3227 (design doc §40 row 26 / "Not-corruption" rule): a refreshed
// state.json `updated_at` must always mean something on disk actually
// moved. This site is unconditional because every reachable path either
// exits via `error()` (process.exit — refusals like a truncated milestone
// window, an unstarted phase, or an invalid version never reach here) or
// returns early on `--dry-run` (before any mutation, see the `dry_run:
// true` branch above) — the only way execution reaches this line is after
// the unconditional MILESTONES.md `platformWriteSync` a few lines above,
// which always runs (new file, empty file, or append) once the run is
// committed to mutating. Best-effort — cannot throw, cannot change this
// command's exit code or output. publishStateContract resolves the
// workstream planning root itself via planningPaths.
publishStateContract(cwd);
}
function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
const phasesDir = planningPaths(cwd).phases;
const confirm = Array.isArray(args) && args.includes('--confirm');
// --force bypasses the uncommitted-changes guard. Only use when the caller
// has already archived or explicitly accepts loss of uncommitted work. (#1447)
const force = Array.isArray(args) && args.includes('--force');
// #2288: explicit outgoing-version override for the archive destination.
// new-milestone.md runs `state.milestone-switch` BEFORE `phases.clear --confirm`,
// so a live read of STATE.md would already report the NEW milestone version by
// the time we get here. Callers that know the outgoing version pass it explicitly;
// absent an override, archivePhaseDirectories falls back to the live read.
const avIndex = Array.isArray(args) ? args.indexOf('--archive-version') : -1;
let archiveVersionOverride: string | null = null;
if (avIndex !== -1) {
const rawArchiveVersion = args[avIndex + 1];
// Missing / flag-shaped value: fail loud instead of silently dropping the
// override. A truncated invocation (e.g. a broken template substitution
// leaving `--archive-version` with no value) must NOT fall through to the
// live read — that silently re-files the archive under the new milestone,
// the exact #2288 bug this flag exists to prevent.
if (typeof rawArchiveVersion !== 'string' || rawArchiveVersion.startsWith('--') || rawArchiveVersion.trim() === '') {
error('--archive-version requires a value (a milestone version token, e.g. v1.0)');
}
const trimmed = rawArchiveVersion.trim();
// #2288 security: reject path separators / `..` so a crafted value cannot
// relocate phase history outside `.planning/milestones/` (phase dirs are
// MOVED into the archive dir — a traversal is data loss, not just an odd name).
if (!ARCHIVE_VERSION_LABEL_RE.test(trimmed)) {
error(`--archive-version "${trimmed}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
}
archiveVersionOverride = trimmed;
}
let cleared = 0;
if (fs.existsSync(phasesDir)) {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
// #3185 (ADR-3180 Decision 1): this carried the FIFTH copy of the
// sentinel rule and its THIRD regex variant — `/^999(?:\.|$)/` — which
// excluded 999 but NOT 0. Because this is the DESTRUCTIVE path, that
// divergence meant a `0-*` directory `roadmap analyze` preserves as a
// sentinel was DELETED here. Routed through the canonical predicate so
// every reader of "is this a sentinel phase" agrees by construction.
// #3639: the DIR-AWARE recognizer — the convention-less id predicate
// never saw bracket sentinel dirs (MSD.999-07-icebox), so they were
// counted for deletion here while the disk guards (post-#3639) preserve
// them; the destructive path must not be the one blind reader left.
const dirs = entries.filter((e) => e.isDirectory() && !isSentinelPhaseDir(e.name));
if (dirs.length > 0 && !confirm) {
error(
`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
`Pass --confirm to proceed.`,
);
}
// Guard (#1447): refuse to hard-delete phase directories that contain
// uncommitted changes. This prevents data loss when `new-milestone` runs
// `phases.clear --confirm` before the operator has archived or committed
// phase work from the outgoing milestone.
// Use `--force` to bypass this guard only when you have verified that
// archive or commit of the outgoing phases is already done.
if (dirs.length > 0 && !force) {
// Compute the path relative to cwd for git status
let relPhasesDir: string;
try {
relPhasesDir = path.relative(cwd, phasesDir);
} catch {
relPhasesDir = phasesDir;
}
let gitStatusOutput = '';
try {
const gitResult = execGit(['status', '--porcelain', relPhasesDir], { cwd, timeout: 10_000 });
if (gitResult.exitCode === 0) {
gitStatusOutput = gitResult.stdout ?? '';
}
// If git is not available or this is not a git repo, skip the guard
// (gitResult.exitCode non-zero → not a git repo → no uncommitted changes to protect).
} catch {
// git unavailable — skip guard
}
const uncommittedLines = gitStatusOutput
.split('\n')
.filter((line) => line.trim().length > 0);
if (uncommittedLines.length > 0) {
error(
`phases clear aborted: ${uncommittedLines.length} uncommitted change${uncommittedLines.length === 1 ? '' : 's'} detected in phase directories. ` +
`Archive or commit outgoing phase work before running this command, ` +
`or pass --force to skip this check and permanently delete the phase directories. (#1447)`,
);
}
}
try {
// #1871: archive phase directories instead of destroying them (shared helper).
// #2288: thread the explicit --archive-version override (if any) through.
cleared = archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride).archived;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
error('Failed to clear phases directory: ' + message);
}
}
output({ cleared }, raw, `${cleared} phase director${cleared === 1 ? 'y' : 'ies'} cleared`);
}
/**
* #1871: move each non-999 phase directory under `phasesDir` into
* `milestones/<version>-phases/` (collision-safe). Shared by `phases clear`
* (archive-then-remove) and the internal milestone.complete phase archival so
* phase history survives a milestone switch instead of being hard-deleted.
*
* Archive-version precedence (#2288): an explicit `archiveVersionOverride` wins
* first, then a live `getMilestoneInfo(cwd)` read (which itself defaults to a
* version like `v1.0` when ROADMAP/STATE is absent), and only a dated fallback
* label if no safe version label is resolvable at all. The override is validated
* by the caller (`cmdPhasesClear`); the live-read value is re-validated here
* (defense in depth) because `getMilestoneInfo` derives it from STATE.md's
* unvalidated `milestone:` field. The override exists because `new-milestone.md`
* runs `state.milestone-switch` BEFORE `phases.clear --confirm` — by the time
* this runs, a live read of STATE.md would already report the NEW milestone
* version, so phase history from the OLD milestone would be misfiled under the
* new version's archive directory. Callers that know the outgoing version must
* pass it explicitly.
*/
function archivePhaseDirectories(cwd: string, phasesDir: string, dirs: ReadonlyArray<{ name: string }>, archiveVersionOverride: string | null = null): { archiveDir: string; archived: number } {
// Self-protecting (#2288 security defense in depth): the sole current caller
// (`cmdPhasesClear`) already validates the override, but re-test it here so a
// future caller cannot reopen the path-traversal sink at line ~742. An override
// that fails the safe-label check is discarded (falls through to the live read
// / dated label) rather than reaching `path.join` unvalidated.
const safeOverride = archiveVersionOverride && ARCHIVE_VERSION_LABEL_RE.test(archiveVersionOverride.trim())
? archiveVersionOverride.trim()
: null;
let archiveVersion: string | null = safeOverride;
if (!archiveVersion) {
try {
// #3216 (ADR-3180 §7.2 Decision): getMilestoneInfo's version becomes a
// DIRECTORY NAME below — only a COMPLETE scope's identity is trustworthy
// enough to act on destructively. On any other scope, treat the version
// as unavailable so control falls through to the dated-label fallback,
// same as an unreadable ROADMAP/STATE.
const info = getMilestoneInfo(cwd);
const liveVersion = info.scope === SCOPE.COMPLETE ? (info.value?.version ?? null) : null;
// Defense in depth (#2288 security): getMilestoneInfo reads STATE.md's
// `milestone:` field, which is unvalidated file content. Only accept it
// as a path component if it is a safe version label; a crafted value
// (path separators / `..`) falls through to the dated label below rather
// than escaping `.planning/milestones/`.
archiveVersion = liveVersion && ARCHIVE_VERSION_LABEL_RE.test(liveVersion) ? liveVersion : null;
} catch {
/* ROADMAP/STATE unreadable — fall back to a dated label */
}
}
if (!archiveVersion) {
archiveVersion = `archived-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 8)}`;
}
const archivePhasesDir = path.join(planningPaths(cwd).planning, 'milestones', `${archiveVersion}-phases`);
platformEnsureDir(archivePhasesDir);
let archived = 0;
for (const entry of dirs) {
const src = path.join(phasesDir, entry.name);
// Collision-safe: if a same-named archive entry exists (re-run), suffix it.
let dest = path.join(archivePhasesDir, entry.name);
let n = 1;
while (fs.existsSync(dest)) {
dest = path.join(archivePhasesDir, `${entry.name}.${n++}`);
}
retryRenameSync(src, dest);
archived++;
}
return { archiveDir: archivePhasesDir, archived };
}
/**
* #2142 BLOCKER 1 (review): escape one directory-name span for insertion as
* markdown LINK TEXT (`[...]`) — a directory name containing a literal `|`,
* `[` or `]` must not be able to break the enclosing markdown. `mkdirSync`
* accepts an embedded newline in a directory name on POSIX (and `isDirectory()`
* still reports true for it), so an unescaped newline would let attacker-
* controlled content — including a markdown HEADING — land verbatim in the
* generated README.md, an indirect prompt-injection vector for any agent
* workflow step that later reads that file. Mirrors `escapeCell`'s exact
* convention (markdown-table.cts `escapeCell`): collapse `\r?\n+` to a single
* space FIRST (so a newline can never re-enter the output as a line break),
* THEN escape the escape char itself (before the rest, so a literal backslash
* in the name is never mistaken for part of an escape sequence this function
* introduces), THEN the markdown-syntax characters.
*/
function escapeMarkdownLinkText(text: string): string {
return text
.replace(/\r?\n+/g, ' ')
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]');
}
/**
* #2142 BLOCKER 1 (review): encode one path span for insertion as a markdown
* link DESTINATION (`(...)`) — `relSummary` is built from a directory name
* that may legally contain a space, a `(`/`)`, or a control character
* (including an embedded newline) on POSIX. Per CommonMark, an unbracketed
* link destination terminates at the first ASCII space/control character and
* requires parens to be balanced or escaped — any of those would truncate or
* corrupt the link, or let attacker-controlled content spill out of the
* `(...)` span into the surrounding markdown (the same indirect
* prompt-injection vector `escapeMarkdownLinkText` guards the link TEXT
* against). Percent-encodes just the unsafe set (space, `(`, `)`, and C0
* control chars incl. `\r`/`\n`, plus DEL) rather than switching to the
* angle-bracket `<...>` destination form — percent-encoding is reversible (a
* markdown viewer resolving the link still reaches the right file) and does
* not introduce a new pair of syntax characters (`<`/`>`) that would in turn
* need their own escaping.
*/
function encodeMarkdownLinkTarget(target: string): string {
return target.replace(/[\x00-\x1f\x7f ()]/g, (ch) => `%${ch.charCodeAt(0).toString(16).padStart(2, '0').toUpperCase()}`);
}
interface QuickArchiveIndexEntry {
/** Escaped for markdown LINK TEXT (`escapeMarkdownLinkText`) — see below. */
name: string;
/**
* POSIX-relative path (from `archiveQuickDir`) to the task's summary file,
* NOT yet percent-encoded for markdown link-destination use — `render()`
* applies `encodeMarkdownLinkTarget` at render time. `null` when the task
* has no resolvable summary file.
*/
summary: string | null;
}
interface QuickArchiveIndex {
entries: QuickArchiveIndexEntry[];
/** Render the entries as the `README.md` markdown body. */
render(): string;
}
/**
* #2142 (code-review FIX 2): PURE builder — scans `archiveQuickDir` and
* resolves each entry's summary link, but performs NO IO beyond the read
* scan itself; never writes. Split out of the former `writeQuickArchiveReadme`
* so tests can assert on the returned structured IR (`entries`) instead of
* substring-matching rendered markdown (CONTRIBUTING.md "Prohibited: Raw Text
* Matching on Test Outputs" — a generated archive index is a "Rendered file",
* which requires a pure builder returning IR, not the `.md`-IS-the-runtime-
* artifact exemption).
*
* (re)generates an index of every quick-task directory PHYSICALLY PRESENT in
* the archive, built by scanning the ARCHIVE directory on disk. Deliberately
* NOT built from STATE.md's Quick Tasks table (the issue evidenced that table
* drifting — 53 rows against 49 dirs, ~22 rows pointing at absent dirs, 18
* dirs missing from the table — the filesystem is the only source of truth)
* and NOT from the pre-move source list either, so a RE-RUN's index includes
* entries a PRIOR run already archived, not just this run's (design row 11).
*
* Each entry's summary link is resolved via `resolveQuickTaskSummaryFile`
* (audit.cts) — the SAME rule `scanQuickTasks` uses to read a task's record
* — imported rather than re-derived, so the read and write paths can never
* disagree about which file is a task's summary. A task WITHOUT a summary is
* still listed, just without a link — never omitted (an omission would
* under-report the index, which is worse than an unlinked entry).
*
* Entries are sorted for deterministic output. A directory name containing
* `|`, `[`, `]` or an embedded newline is neutralized via
* `escapeMarkdownLinkText` (link TEXT) so it cannot break the generated
* markdown or inject a heading — applied here, at build time, so `entries`
* itself already carries the injection-safe name (the regression test
* asserts on THIS, not on rendered output). The destination is separately
* encoded via `encodeMarkdownLinkTarget` (link TARGET) at RENDER time, so a
* space/paren/control char in the name cannot truncate or corrupt the
* `(...)` span. The summary path is normalized to POSIX
* (`.split(path.sep).join('/')`) so the link is stable across platforms.
*
* Throws when `archiveQuickDir` is unreadable — the caller (`writeQuickArchiveReadme`)
* is the best-effort boundary, not this builder.
*/
function buildQuickArchiveIndex(archiveQuickDir: string): QuickArchiveIndex {
const dirEntries = fs.readdirSync(archiveQuickDir, { withFileTypes: true });
const dirNames = dirEntries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
const entries: QuickArchiveIndexEntry[] = dirNames.map((dirName) => {
const taskDir = path.join(archiveQuickDir, dirName);
const summaryPath = resolveQuickTaskSummaryFile(taskDir, dirName);
const escapedName = escapeMarkdownLinkText(dirName);
if (summaryPath) {
const relSummary = path.relative(archiveQuickDir, summaryPath).split(path.sep).join('/');
return { name: escapedName, summary: relSummary };
}
// No summary file — list the directory, but never link into it (there
// is nothing to point at). See indexListsTaskWithoutSummaryWithoutLink.
return { name: escapedName, summary: null };
});
return {
entries,
render(): string {
const lines: string[] = ['# Archived Quick Tasks', ''];
for (const entry of entries) {
if (entry.summary !== null) {
lines.push(`- [${entry.name}](${encodeMarkdownLinkTarget(entry.summary)})`);
} else {
lines.push(`- ${entry.name}`);
}
}
lines.push('');
return lines.join('\n');
},
};
}
/**
* #2142: thin writer — calls `buildQuickArchiveIndex` and writes its
* `render()` output to `<archiveQuickDir>/README.md`. No-ops (writes
* nothing) when `archiveQuickDir` is unreadable — this is a best-effort
* index, not a gate on milestone completion. #2142 MAJOR 4 (review): the
* whole body is wrapped in a try/catch so a failure of the WRITE itself
* (read-only archive dir, full disk, or a quick-task directory literally
* named `README.md` colliding with the file being written) degrades the same
* way — this function genuinely cannot throw, matching its own
* "best-effort, not a gate" contract; the directories are already safely
* archived by the time this runs.
*/
function writeQuickArchiveReadme(archiveQuickDir: string): void {
try {
const index = buildQuickArchiveIndex(archiveQuickDir);
platformWriteSync(path.join(archiveQuickDir, 'README.md'), index.render());
} catch {
/* best-effort (#2142 MAJOR 4): a read-only archive dir, a full disk, or a
* quick-task directory literally named `README.md` colliding with the
* file this function writes must never crash `milestone complete` —
* the quick-task directories are already safely archived on disk by the
* time this index-generation step runs. */
}
}
/**
* #2142 MAJOR 5 (review): the single owned selection rule for "which
* directories under `.planning/quick/` would/will move" — directory entries
* only (symlinks are excluded here, per the MAJOR 3 note above), each
* additionally guarded with `requireSafePath` (the same guard
* `scanQuickTasks`/`archiveQuickTaskDirectories` use), sorted for
* deterministic output. Extracted so `cmdMilestoneComplete`'s dry-run
* preview, `cmdQuickArchive`'s dry-run preview, and the REAL selection inside
* `archiveQuickTaskDirectories` all call this ONE function instead of each
* re-deriving the rule — the "Generative Fix Divergence" anti-pattern this
* repo explicitly guards against (a prior version of this code had the rule
* written three times, and only the real-run copy applied `requireSafePath`,
* so a dry-run preview could list a directory the real run would silently
* skip).
*
* Returns the proven `ContainedPath` alongside each entry's bare `name`
* (`no-unconfined-path-join`'s `discardedContainmentResult` arm — a bare
* statement call to `requireSafePath` throws away the exact answer it just
* computed). The two dry-run previews only need `name` for display;
* `archiveQuickTaskDirectories` deliberately does NOT reuse `abs` for its
* rename — it re-derives and re-validates independently as TOCTOU
* defense-in-depth (see its own comment), so `abs` exists here only to make
* this function's own discard explicit, not to be trusted downstream as a
* stale-safe proof.
*/
function listQuickTaskDirsForArchive(cwd: string): Array<{ name: string; abs: ContainedPath }> {
const planningBase = planningPaths(cwd).planning;
const quickDir = planningPaths(cwd).quick;
let sourceEntries: fs.Dirent[];
try {
sourceEntries = fs.readdirSync(quickDir, { withFileTypes: true });
} catch {
// .planning/quick absent or unreadable — nothing to select.
return [];
}
const results: Array<{ name: string; abs: ContainedPath }> = [];
for (const entry of sourceEntries) {
if (!entry.isDirectory()) continue; // excludes symlinks too — see MAJOR 3 note above
let abs: ContainedPath;
try {
abs = requireSafePath(path.join(quickDir, entry.name), planningBase, 'quick task dir', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue; // symlink/escape attempt — never a candidate, in preview OR real run
}
results.push({ name: entry.name, abs });
}
return results.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
/**
* #2142: move each DIRECTORY entry under `.planning/quick/` into
* `milestones/<version>-quick/` (collision-safe), then (re)write that
* archive directory's README.md index. Sibling of `archivePhaseDirectories`
* — extracted rather than inlined into `cmdMilestoneComplete` (already
* cyclomatic 61) — mirroring its collision-safe destination-suffix loop,
* `retryRenameSync`, and `platformEnsureDir` usage.
*
* `version` is ALREADY validated by `ARCHIVE_VERSION_LABEL_RE` at
* `cmdMilestoneComplete`'s entry — this helper does not re-validate it, and
* must only ever be called after that guard has run.
*
* #2142 MAJOR 3 (review): a symlink under `.planning/quick/` — even one that
* targets a directory — is excluded by the `dirEntries` filter below
* (`fs.Dirent.isDirectory()` returns FALSE for a symlink, regardless of what
* it points at), so it is never a candidate `entry` in the first place and
* `requireSafePath` below never runs against it. `requireSafePath` is
* retained here as defense-in-depth for the NON-symlink path (a real
* directory entry whose resolved path still needs re-validating against
* `planningBase`) — the SAME guard `scanQuickTasks` (audit.cts) uses — so an
* entry that fails it is skipped, never archived, never counted. See the
* symlink regression tests in tests/milestone-archive.test.cjs
* (`symlinkEscapeIsNeverArchivedByMilestoneComplete` /
* `symlinkEscapeIsNeverArchivedByQuickArchive`) for a fixture proving neither
* the symlink nor its external target is ever moved or altered — added
* specifically so a future change to this filter cannot silently reopen the
* escape with nothing to catch it.
*
* No-op (returns `{archived: 0, entries: []}`, creates NOTHING on disk) when
* `.planning/quick/` does not exist or contains zero DIRECTORY entries — a
* stray file with no sibling directory is neither an empty-dir case nor an
* archive case.
*
* A mid-loop rename failure (or a failure to create the archive directory
* itself) does not crash `milestone complete` — it degrades to whatever
* `archived`/`entries` had already accumulated before the failure, mirroring
* the `archivedCount` finally-pattern `cmdMilestoneComplete`'s own phase
* archival uses a few hundred lines above (so a partial archive reports the
* TRUE count, never a false `0`/`false`).
*/
function archiveQuickTaskDirectories(cwd: string, version: string): { archiveDir: string; archived: number; entries: string[] } {
const planningBase = planningPaths(cwd).planning;
const quickDir = planningPaths(cwd).quick;
const archiveQuickDir = path.join(planningBase, 'milestones', `${version}-quick`);
// #2142 MAJOR 5 (review): dirNames is the SAME selection
// `listQuickTaskDirsForArchive` hands to both dry-run previews — this is
// the real run, so it cannot disagree with what a preview reported.
const dirNames = listQuickTaskDirsForArchive(cwd).map((d) => d.name);
if (dirNames.length === 0) {
// Boundary 0 (#2142): zero (safe) directory entries (empty dir, only
// stray files, or every entry excluded by the selection rule) must not
// create the archive directory. Also covers `.planning/quick/` being
// absent/unreadable — `listQuickTaskDirsForArchive` degrades to `[]`.
return { archiveDir: archiveQuickDir, archived: 0, entries: [] };
}
let archived = 0;
const entries: string[] = [];
try {
platformEnsureDir(archiveQuickDir);
for (const name of dirNames) {
const src = path.join(quickDir, name);
let safeSrc: string;
try {
// Re-validated here (not just trusted from the selection above) as
// TOCTOU defense-in-depth: `listQuickTaskDirsForArchive` and this
// rename are two separate filesystem observations, and an entry
// that was a safe real directory at selection time could in theory
// be swapped for a symlink before this loop reaches it.
safeSrc = requireSafePath(src, planningBase, 'quick task dir', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue; // symlink/escape attempt — skip, not archived
}
// Collision-safe: if a same-named archive entry exists (re-run), suffix it.
let dest = path.join(archiveQuickDir, name);
let destName = name;
let n = 1;
while (fs.existsSync(dest)) {
destName = `${name}.${n++}`;
dest = path.join(archiveQuickDir, destName);
}
retryRenameSync(safeSrc, dest);
archived++;
entries.push(destName);
}
} catch {
/* best-effort: platformEnsureDir failed, or the rename loop failed
* partway — `archived`/`entries` above already reflect exactly what
* succeeded before the failure (accumulated incrementally, never lost
* with the swallowed exception — mirrors the archivedCount pattern at
* cmdMilestoneComplete's phase-archival block). */
}
// Regenerate the README from whatever is ACTUALLY on disk now — covers
// both a clean full archive and a degraded partial one, and (on a re-run)
// includes entries a PRIOR run already archived. Skipped only when the
// archive directory itself was never created (ensureDir failed above).
if (fs.existsSync(archiveQuickDir)) {
writeQuickArchiveReadme(archiveQuickDir);
}
return { archiveDir: archiveQuickDir, archived, entries };
}
interface QuickArchiveOptions {
dryRun?: boolean;
}
/**
* #2142 escalation: `milestone.archive-quick` (CLI: `milestone archive-quick`,
* renamed from the original `quick.archive` per code-review FIX 1 — folded
* under the existing `milestone` namespace rather than adding a new top-level
* command) — the narrow archival helper the issue's own "Scope of changes"
* anticipated ("a `quick.archive`-style routine"), for callers (chiefly
* `msd-core/workflows/cleanup.md`) that need to sweep
* `.planning/quick/*` WITHOUT the full `milestone complete` close-out.
*
* `milestone complete --archive-quick` cannot be reused for this: it
* hard-errors via `missingExplicitVersion` for an already-completed
* milestone (no `### Phase N:` headings left in its ROADMAP window),
* re-archives ROADMAP.md over the very snapshot cleanup depends on, and
* appends a duplicate MILESTONES.md entry on every re-run.
*
* This command performs ONLY the two things `archiveQuickTaskDirectories`
* already does (move `.planning/quick/*` dirs into
* `milestones/<version>-quick/` + (re)write that archive's README index —
* the SAME helper `cmdMilestoneComplete` calls, so the two entry points can
* never diverge on step 1) plus a Quick Tasks Completed table reset. It
* NEVER touches ROADMAP.md, REQUIREMENTS.md, or MILESTONES.md, and runs
* NEITHER the unstarted-phase guard NOR the milestone-window/TRUNCATED
* refusal — those remain `milestone complete`'s alone.
*
* #2142 MAJOR 6 (review): the STATE.md write now routes through
* `readModifyWriteStateMd` — the same owned read-transform-write composition
* `msd-tools.cjs`'s `quick-tasks-append` handler uses (ADR-3408 §8.3 / #3469:
* "the single owned composition ... so the composition cannot diverge"). A
* prior version of this function called `platformWriteSync` directly with
* the reset result, bypassing `syncAndPreserveStateMd` entirely — the exact
* bypass shape that ADR closed. Per `src/state.cts:3289-3330`,
* `readModifyWriteStateMd` ALREADY acquires its own exclusive lock
* (`acquireStateLock`/`releaseStateLock`, a real `O_CREAT|O_EXCL` file lock,
* not reentrant) across its own read -> transform -> write cycle — so, unlike
* `cmdMilestoneComplete` (which folds the table reset into its own
* pre-existing `withStateLock` transform because it ALSO needs that lock for
* the closure-transition write happening in the same block), this function
* must NOT wrap the call in its own `withStateLock`: doing so would acquire
* the same lock file twice in the same process, and the second acquire would
* spin against a lock this same call already holds until it times out.
*/
function cmdQuickArchive(cwd: string, version: string, options: QuickArchiveOptions, raw: boolean): void {
if (!version) {
error('version required for milestone.archive-quick (e.g., v1.0)');
}
// #2288-class security: `version` becomes a filesystem directory component
// (`milestones/<version>-quick/`) that directories are MOVED into — same
// guard + wording shape `cmdMilestoneComplete` uses for its own version arg.
if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
error(`milestone.archive-quick: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
}
const statePath = planningPaths(cwd).state;
const planningBase = planningPaths(cwd).planning;
const toPosixRel = (p: string): string => path.relative(cwd, p).split(path.sep).join('/');
// --dry-run: preview only, mutates nothing. #2142 MAJOR 5 (review): routed
// through the SAME `listQuickTaskDirsForArchive` selection
// `cmdMilestoneComplete`'s own dry-run preview and the real
// `archiveQuickTaskDirectories` both use, so all three can never disagree.
if (options.dryRun) {
const quickDirsToArchive: string[] = listQuickTaskDirsForArchive(cwd).map((d) => d.name);
output(
{
dry_run: true,
version,
would_archive: quickDirsToArchive,
archive_dir: toPosixRel(path.join(planningBase, 'milestones', `${version}-quick`)),
},
raw,
);
return;
}
const quickArchiveResult = archiveQuickTaskDirectories(cwd, version);
const warnings: Array<{ field: string; reason: string }> = [];
let stateUpdated = false;
// Same silent/surfaced rule `cmdMilestoneComplete` applies: only attempt
// the reset when something actually moved, and treat the
// QUICK_TASKS_SECTION_ABSENT sentinel as a silent no-op (the section is
// created lazily by quick.md Step 7b and absent from templates/state.md,
// so absence is the common case, not an anomaly). Any other reset failure
// is surfaced via `warnings`, never thrown.
//
// #2142 MAJOR 6 (review): routed through `readModifyWriteStateMd` (see the
// docstring above) instead of a bare `platformWriteSync` — the transform
// returns the ORIGINAL content unchanged whenever the reset did not apply
// (sentinel-absent or a genuine failure), so `readModifyWriteStateMd`'s own
// no-op guard (#948, state.cts:3304) skips the write and its `false`
// return accurately reports "nothing was written" — the same "state_updated
// must report accurately" contract the prior direct-write version upheld.
if (quickArchiveResult.archived > 0 && fs.existsSync(statePath)) {
let resetWarning: { field: string; reason: string } | null = null;
stateUpdated = readModifyWriteStateMd(
statePath,
(content: string) => {
const { content: nextContent, warning } = applyQuickTasksReset(content);
resetWarning = warning;
return nextContent;
},
cwd,
);
if (resetWarning) {
warnings.push(resetWarning);
}
}
output(
{
version,
archived: quickArchiveResult.archived,
entries: quickArchiveResult.entries,
archive_dir: toPosixRel(quickArchiveResult.archiveDir),
state_updated: stateUpdated,
warnings,
},
raw,
`${quickArchiveResult.archived} quick task director${quickArchiveResult.archived === 1 ? 'y' : 'ies'} archived`,
);
}
export = {
cmdRequirementsMarkComplete,
cmdRequirementsReadyIds,
cmdRequirementsRevertPhase,
cmdMilestoneComplete,
cmdPhasesClear,
cmdQuickArchive,
buildQuickArchiveIndex,
};