Files
msd-core/src/milestone.cts
Tom Boucher bbdf7e8e84 chore(#4654): add local/no-unconfined-path-join and drain it to zero — Phase 4 of #4636 (#4674)
* chore(#4654): add local/no-unconfined-path-join and drain it to zero

Phase 4 of epic #4636 — the ratchet, and the phase that makes the epic hold.

THE MEASUREMENT THAT RESHAPED THE PHASE. An AST census (the repo's own parser,
not grep) found what the epic never enumerated: ADR-4650 named seven containment
implementations; `src/` alone held roughly 24 more hand-rolled gates across ~13
files, several guarding a write or an `fs.rmSync`. Two verified by reading rather
than pattern-matching — `research-store.cts` comments its own as "ensure the
resolved file path stays inside the store dir" immediately before a write, and
`capability-lifecycle.cts` gates `fs.rmSync` with one.

So the epic's Done-when "one containment predicate, used at every site" was FALSE
when Phase 3 reported it satisfied. It is true now: the rule is clean across
src/, scripts/, gsd-core/bin/ and hooks/ with an EMPTY allowlist.

WHY NOT THE RULE THE ISSUE PROPOSED. #4654 proposed flagging `path.join` whose
first argument is a managed root and whose later arguments derive from argv. That
is a taint analysis over 2046 call sites, in ESLint, without type information;
"derives from argv" is not locally decidable. Any approximation either floods or
is trivially evaded, and a rule that fires on hundreds of correct sites earns an
allowlist of hundreds — the opposite of a ratchet. What is actually duplicated is
the COMPARISON, not the join, and that has one recognizable shape.

  Arm 1  X.startsWith(Y + sep)            the hand-rolled containment idiom
  Arm 2  a containment predicate called as a bare statement, answer discarded

Arm 2 is the issue's "asserts the result was narrowed, not merely that a helper
was called". Its example `validatePath(x, root).resolved` is already
structurally impossible — Phase 3 un-exported `validatePath` — so the remaining
expressible failure is ignoring the answer, which is the defect that recurred
five times in this epic. The census found exactly one live instance
(`milestone.cts:1643`); it now returns the proven `ContainedPath` so consumers
stop re-deriving the path the comment above it was extracted to stop them
re-deriving.

The rule deliberately does NOT try to catch validate-one-path-use-another where
the answer is used but a different variable flows onward. That needs flow
analysis; the branded `ContainedPath` from Phase 3 is the defense there, and the
two are complementary.

PER-SITE FAMILY CHOICE, NOT A DEFAULT. Phase 3's lesson binds: collapsing a
lexical site onto the realpath family broke four tests and was caught only by the
matrix. Every migrated site was triaged individually. The six
installer-migrations tree-walks and the six capability-lifecycle gates take the
LEXICAL family because their operands are already realpath-resolved and they
deliberately treat the final component as a link; boundary sites take realpath.

TWO SITES WITH AN INVERTED CONTRACT, which a mechanical swap would have broken.
`installer-migrations.cts:127` and `runtime-artifact-install-plan.cts:144` REJECT
`target === root` by contract, while the canonical comparison ACCEPTS it. Swapped
naively, a migration could `rmdir` the user's config root and a third-party
descriptor could write at configHome itself. Both keep `=== root` as an explicit
additional arm alongside the predicate call — the predicate decides containment,
the call site keeps its own extra condition (ADR-4650 decision 6).

ONE DUPLICATE DELETED OUTRIGHT: `planning-inspect.cts`'s `isWithinRoot` was
byte-identical to `isContainedIn` and said so in its own docstring.
`isContainedIn` is now exported for callers that have already resolved both
operands and need only the comparison, with a doc note that a caller which has
NOT resolved them must use a full predicate instead.

THE MARKER, AND WHY IT IS NOT THE ALLOWLIST. Nine sites are justified holdouts and
carry `// allow-handrolled-containment: <reason>` with a mandatory, reviewable
reason. Two justifications: (a) not a containment decision — an ancestor-walk loop
condition, sub-repo grouping, worktree identity matching, declared-path coverage;
(b) it IS containment but the canonical predicate is unreachable —
`capability-validator.cjs` is a committed pre-build `.cjs` and the compiled
`security.cjs` is untracked build output, so requiring it would break a fresh
clone. `scripts/lib/drift-scan.cjs` runs under `lint:ci` with the same exposure.
The marker was renamed from `allow-lexical-prefix-match` mid-phase because that
name asserted only (a) and would have stated something false at the (b) sites.

A marker suppresses BEFORE the violation counter increments, so a file whose
every occurrence is marked still reports `staleAllowlistEntry` — otherwise a
drained entry lingers and silently re-permits the site later.

DEMONSTRATED RED, per #4654: a hand-rolled copy reintroduced into a real `src/`
file made `npm run lint` fail with the rule's full guidance message; removing it
returned the tree to clean. Both halves recorded — red alone proves nothing,
since a rule red for an unrelated reason looks identical.

DISCLOSED: `defaultRequireFromInstallRoot` (gsd-tools.cjs) previously carried two
distinct rejection messages and two manual realpath calls; routing it through
`tryWithinRoot` collapses them to one message, and a missing module now surfaces
as MODULE_NOT_FOUND rather than ENOENT. No test asserts either message. The
security property is preserved and slightly strengthened — the candidate is
realpathed and containment re-checked, and the dangling-symlink oracle closure
comes along with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#4654): record the containment ratchet in CONTEXT.md and the security model

Both entries previously described the seam without the thing that keeps it a
seam. They now state what the rule bans, and — more usefully for whoever reads
this next — what it deliberately does NOT attempt: deciding per path.join call
whether an argument came from user input. That question is not locally
decidable, and an approximation across ~2000 join sites would earn an exemption
list of hundreds, which is the opposite of a ratchet.

Also records the marker's two legitimate justifications and that its reason is
mandatory, so the escape stays reviewable rather than becoming a mute button.

Glossary gate 270 refs exit 0; install-tree goldens and CONTEXT-INDEX.json
regenerated and confirmed byte-identical rather than assumed — which also
confirms eslint-rules/ is not a shipped path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4654): close review findings and the two matrix failures

MATRIX FAILURE 1 — a collapsed message broke a negative-proof test, and my
evidence for collapsing it was wrong. I searched tests/ for the literal string
"resolves outside its install root", found nothing, and reported that no test
asserted it. The test matches a REGEX SUBSTRING, /outside its install root/, so
the literal search missed it. What broke was "NEGATIVE PROOF: a symlinked module
pointing OUTSIDE the install root is not loaded" — the test guarding the exact
property I claimed was preserved. defaultRequireFromInstallRoot now does both
checks again with both messages byte-identical, each routed through the
canonical predicate, which is better than the original since that hand-rolled
both comparisons.

MATRIX FAILURE 2 — shipped migrations are checksum-locked, and a marker cannot
serve there. migrationChecksum hashes plan.toString(), which INCLUDES comments,
so a suppression marker inside a plan body drifts the baseline exactly as an
edit does. Measured: with markers in place, two of the four still differed from
their committed checksums. The four shipped bodies are now byte-identical to
next, and the rule's config excludes those four paths BY NAME rather than by a
directory wildcard, so a NEW migration is still covered. Six containment
comparisons stay un-ratcheted there; that gap is recorded in the rule's Known
gaps, in CONTEXT.md and in the security model rather than left implicit.
Justification (c) is removed from the marker's documented reasons, because a
marker was proven unable to express it.

ADVERSARIAL REVIEW — the sharpest finding was that the rule banned the CORRECT
shape while permitting the incorrect one: startsWith(root) with no separator is
the genuinely unsafe form, since it accepts a sibling such as root-evil, and my
own test blessed it as valid. Flagging every bare startsWith would swamp the
rule, so that stays a STATED gap rather than a silent one. Closed for real: the
template-literal spelling, which the census never saw because it only inspected
plus-concatenation — that surfaced TWELVE more sites, now triaged and migrated.
A separator reached through a const alias is now resolved via scope analysis.
And isContainedIn, exported in Phase 3, was missing from the discarded-result
set, so a bare no-op call went unflagged on the one function the epic funnels
through.

SECURITY REVIEW — the marker could over-suppress two ways: a block comment
worked identically to a line comment, and one marker silently covered every
violation sharing its line. It now requires a Line comment positioned after the
flagged node ends, so it anchors to the node it trails. Four sites had dropped
an unreachable-but-deliberate equality rejection against the root; each is
restored as the call site's own arm. eslint.config.mjs still documented the OLD
marker token, which my rename missed — it would have sent the next author in
circles.

A FALSE GREEN, recorded because it nearly stuck: lint:ci reported exit 0 from a
stale eslint cache while twelve real violations existed. Every lint check here
now clears the cache first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4654): anchor a suppression marker to the violation it actually trails

The matrix caught this; my own test caught it, on its first execution. The case
"two violations on one line: trailing marker suppresses only the one it trails"
expected 1 error and got 0 — both were suppressed.

ROOT CAUSE: the anchoring accepted any Line comment on the node's line whose
range started at or after the node's end. A trailing marker at the END of a line
sits after EVERY node on that line, so that condition held for all of them.
"After the node" does not identify WHICH node the marker trails. The fix reads
as correct and is not.

FIX: deferred reporting. Violations accumulate during traversal instead of being
reported immediately; at Program:exit each marker claims exactly ONE pending
violation — the one on its line whose end is nearest before the marker begins —
and every unclaimed violation is then counted and reported. One marker, one
suppression. An earlier violation sharing the line is still reported, which is
the property the security review asked for and the previous attempt only
appeared to deliver.

The counter now increments at flush time rather than during traversal, so a
suppressed occurrence still does not keep an allowlist entry alive.

AND A TOOL THAT SHOULD HAVE EXISTED BEFORE THE FIRST MATRIX RUN. `node --test`
is hard-blocked here, so this rule's test file could only ever be executed on
the remote matrix — which is why a broken anchoring shipped into a run. ESLint's
programmatic Linter API is not a test runner, and exercising the rule through it
verifies every case locally in seconds. All 24 now pass locally, including the
two-on-one-line case that failed remotely. That loop should have been built
before the rule was first sent to the matrix rather than after it failed twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4654): backfill PR 4674 into the changeset and complete 70-docs.json

The phase gate requires enablementSequence and the Diataxis quadrants; 70-docs
now carries both, with the how-to quadrant skipped for a stated reason rather
than an empty field. The audience for this deliverable is a contributor who
trips the rule, and the task-oriented guidance reaches them in the ESLint
message itself — which names the correct predicate, says how to choose between
the realpath and lexical families, cites the Phase 3 regression caused by
choosing wrong, and gives the marker syntax. A docs/how-to page would be a
second, driftable copy read by nobody at the moment of failure.

enablementSequence is recorded as what it actually is: a VERIFICATION sequence,
not an enablement one. The rule is never off, so there is no off-to-on
transition to describe.

scripts/lint-docs-required.cjs now passes (ok_docs_updated) — it could not
evaluate against the mandated pr:0 placeholder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:17:46 -04: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 { formatGsdSlash, 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
* (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
* (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
* an unscoped whole-file call targets the Out-of-Scope table instead, fails
* with `{ok:false, reason:'unknown column: Status'}`, and the real
* Traceability row is never flipped, while the checkbox surface still flips
* and the command reports success (the #2140 silent-divergence class one
* level deeper). Mirrors 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 (gsd-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 GSD_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 [gsd-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(
`[gsd-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: formatGsdSlash('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
// `gsd-core/workflows/quick.md` Step 7b, not by
// `gsd-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 (GSD.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
* `gsd-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
* `gsd-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,
};