Files
msd-core/src/verification.cts
Jakub Zych aa03fc7fa9 fix(verification): keep passed uat results across verifier re-runs
A phase whose human UAT passed could loop forever between execute-phase
and verify-work. A gap-closure plan, or a later phase editing a covered
file, made the report stale; verify-work only recorded a passed UAT
against human_needed; the re-run verifier could not see the UAT file and
re-emitted human_needed; and execute-phase's human_needed branch then
rewrote *-UAT.md with every row back to [pending].

- verification.uat-evidence: recorded UAT rows plus the covered
  implementation files changed since the UAT. The verifier (Step 8b)
  treats a row that passed on unchanged code as verified and re-lists
  only affected rows, each with a retest_reason.
- verification.seed-uat: merges the report's human items into *-UAT.md.
  Existing rows are kept byte-for-byte; a pass is reset only with a
  recorded reason.
- verification.canonicalize-uat: the single human_needed -> passed flip,
  gated on the uat-only row predicate; refuses a stale report.
- verification.status reports stale_reason; init.progress lists every
  executed-but-stale phase in reverify_phases.
- verify-work and progress re-run the verifier themselves for a stale
  report instead of routing to execute-phase; execute-phase records a
  gap-closure checkpoint's UAT answers before dispatching the verifier.

Fail-closed properties are unchanged: a malformed fingerprint and a real
change to covered code still read stale.

Emitted-Drift-Ack-Growth: execute-phase.md — verify_phase_goal gains the gap-closure uat-record pointer and the merge-not-overwrite human_needed branch; the uat template it replaced moved into verification.seed-uat
Emitted-Drift-Ack-Growth: msd-verifier.md — new step 8b pointer to the lazily loaded verifier-uat-evidence reference plus the human_verified frontmatter key
Emitted-Drift-Ack-Growth: progress.md — route v.stale now re-verifies every stale executed phase in place instead of naming one command
2026-10-11 01:44:30 +02:00

1542 lines
75 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Verification Status — single queryable home for verification-status routing.
*
* Issue #651: consolidate the pass/gaps_found/human_needed routing that was
* previously scattered across ship.md and execute-phase.md into a single
* tested module. Both workflow files will later consume this module's routing
* table as the single source of truth.
*
* ADR-457 build-at-publish: source in src/verification.cts, compiled to
* msd-core/bin/lib/verification.cjs (gitignored).
*
* DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix: status extraction is scoped to
* the leading YAML frontmatter block only. A `status:` line in the body (e.g.
* inside a fenced code block) is ignored — this is the exact failure mode that
* issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
* its regex at byte 0 of the document, which provides this guarantee.
*
* #2348 staleness signal: whether a *-VERIFICATION.md is stale (a summary newer
* than it) is decided from git commit time when a file is committed AND clean,
* and from filesystem mtime otherwise. mtimes are assigned at checkout time and
* are not preserved by `git clone` / `cp -R`, and any unrelated `touch` /
* reformat / editor-save re-stales a valid report — so a committed phase could
* read `passed` on one machine and `stale` on a fresh clone purely from checkout
* order. Git commit time is content-tied and clone-stable; mtime is retained
* only for uncommitted or working-tree-dirty files, where it is the true
* last-changed signal. Both are real wall-clock change times, so the comparison
* is sound even when one file uses each.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { findProjectRoot } from './project-root.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
import io = require('./io.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
import phaseId = require('./phase-id.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 -- plan-scan.cjs is an export= CommonJS module
import scanPhasePlans = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
import coreUtilsMod = require('./core-utils.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
import planningScopeMod = require('./planning-scope.cjs');
import { execGit } from './shell-command-projection.cjs';
import { formatMsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { isContainedIn } from './security.cjs';
const { output, error } = io;
const { extractPhaseToken, scopeToPhase } = phaseId;
const { extractFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatterMod;
const { normalizeLineEndings } = coreUtilsMod;
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// ─── Constants ────────────────────────────────────────────────────────────────
/** The set of status values that the msd-verifier agent emits. */
const VERIFIER_STATUSES: ReadonlyArray<string> = ['passed', 'gaps_found', 'human_needed'];
// ─── Routing table ────────────────────────────────────────────────────────────
interface VerificationRoute {
status: string;
next_action: string;
next_command: string;
}
/**
* Canonical routing table for verification statuses.
*
* This is the single source of truth — ship.md and execute-phase.md will
* later import from here instead of embedding their own message strings.
*
* INTERNAL SENTINELS: 'missing' and 'unknown' are operational states constructed
* internally — the verifier (msd-verifier.md) never emits them. The verifier only
* emits values in VERIFIER_STATUSES (passed|gaps_found|human_needed). The guard in
* readVerificationStatus excludes 'missing' and 'unknown' from raw-status table
* lookup so they can only be reached via internal construction paths.
*
* For 'gaps_found', next_command is built at call time in readVerificationStatus
* by substituting the phase number — it is NOT stored as a function in the table.
*
* #2617: `next_command` here holds a BARE command name (`execute-phase`), never a
* prefixed one. Every return path projects it through `formatMsdSlash` with the
* caller's runtime, so Codex sees `$msd-execute-phase` and slash-hyphen runtimes
* see `/msd-execute-phase`. Storing a prefixed literal is what leaked the
* hard-coded (and deprecated) `/msd:` colon form to every runtime.
*/
const VERIFICATION_ROUTING_TABLE: Record<string, VerificationRoute> = {
passed: {
status: 'passed',
next_action: 'Verification passed — continue.',
next_command: '',
},
gaps_found: {
status: 'gaps_found',
next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
// next_command is computed at call time; this entry is never returned directly.
next_command: '',
},
human_needed: {
status: 'human_needed',
next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
// #2617: was '' — next_action told the user to "re-run the verify step" but
// named no command, while init.cts's parallel projector emitted
// `verify-work <N>` for this same state. The two surfaces disagreed on
// whether a next command existed at all; init's answer was the useful one,
// and init now delegates here rather than re-deriving it.
next_command: 'verify-work',
},
stale: {
status: 'stale',
// #4682: staleness means covered source files changed after the verifier
// last ran — the only remedy is re-running the verifier.
// /msd-verify-work never rewrites VERIFICATION.md, so advising it from
// here was an advice loop. execute-phase resumes at the verification
// gates and re-runs the verifier (its resume tree routes a stale report
// to re-verification), which regenerates VERIFICATION.md and its digest.
// the action is named for what it is — a verifier re-run, not an
// execution. No plan re-runs, and recorded UAT results are kept: the
// verifier reads them (`verification.uat-evidence`) and the UAT writer
// merges instead of overwriting (`verification.seed-uat`).
next_action: 'Verification is stale — the report no longer matches the phase. Re-run the verifier: execute-phase runs only plans that have no SUMMARY (none, when every plan is summarized), then resumes at the verification gates and regenerates VERIFICATION.md and its digest. Recorded UAT results are kept, not re-requested.',
next_command: 'execute-phase',
},
// INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
// the file has no parseable frontmatter status. Never emitted by the verifier.
missing: {
status: 'missing',
next_action: 'No verification report found — the verify step never completed. Running execute-phase is safe here: it resumes at the verification gates and does not re-run plans that already have a SUMMARY.md (see #2868).',
next_command: 'execute-phase',
},
// #4806: the report EXISTS but its frontmatter is not parseable YAML —
// fundamentally different from "missing" (the verify step DID run; re-running
// execute-phase cannot fix a YAML typo). Consumers treat any non-'passed'
// status as blocking, so this value fails safe while telling the truth.
unparseable: {
status: 'unparseable',
next_action: "The *-VERIFICATION.md frontmatter is not parseable YAML — fix the syntax error in the report itself. Re-running execute-phase cannot fix a YAML typo in an existing report.",
next_command: '',
},
// INTERNAL SENTINEL: constructed when the file has a status value not in
// VERIFIER_STATUSES. Never emitted by the verifier.
unknown: {
status: 'unknown',
next_action: '', // filled in dynamically with the raw value
next_command: 'execute-phase',
},
};
/**
* Project a BARE command name (plus optional argument tail) into the surface the
* given runtime actually installs (#2617).
*
* `formatMsdSlash` owns the per-runtime shape (`$msd-<cmd>` for shell-var
* runtimes like Codex, `/msd-<cmd>` otherwise) and is idempotent, so passing an
* already-prefixed string is safe. An empty command stays empty — "no next
* command" must not become a bare prefix.
*/
function projectNextCommand(bare: string, runtime: string, tail = ''): string {
if (!bare) return '';
return `${formatMsdSlash(bare, runtime) as string}${tail}`;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
interface FsLike {
readdirSync(dir: string): string[];
readFileSync(filePath: string, encoding: 'utf-8'): string;
statSync(filePath: string): { mtimeMs: number; isFile(): boolean };
}
/**
* Real `node:fs`-backed default satisfying FsLike. Every method wraps a call
* to `fs.<method>` rather than capturing the function reference — existing
* tests mock individual `fs` methods in place (`t.mock.method(fs, 'statSync', …)`),
* and a captured reference taken at module-load time would be invisible to
* that late mock, silently un-mocking this seam's "default" path.
*/
const defaultFsImpl: FsLike = {
readdirSync: (dir: string) => fs.readdirSync(dir),
readFileSync: (filePath: string, encoding: 'utf-8') => fs.readFileSync(filePath, encoding),
statSync: (filePath: string) => fs.statSync(filePath),
};
/**
* Outcome of a staleness check. `determined:false` means the check could NOT
* run to completion (an fs / scanPhasePlans / injected-clock failure) — this
* is distinct from `determined:true, stale:false`, which means the check ran
* to completion and genuinely found nothing stale. Collapsing the two (the
* pre-#3057 behavior: both returned `null`) let a disk-scan failure silently
* report "not stale" — the same fail-open shape as #3050. (#3057 B3)
*/
type StaleCheckResult =
| { determined: true; stale: true; verificationFile: string; summaryFile: string }
| { determined: true; stale: false }
| { determined: false };
/**
* Resolve the git commit time (epoch-ms) for each of `files` (paths relative to
* `phaseDir`) that is BOTH committed AND clean (its working-tree content matches
* HEAD), keyed by the given relative path. A file that is dirty, untracked,
* uncommitted, or in a non-repo is simply absent — callers then time it by its
* filesystem mtime. Injectable so tests exercise the clock without git. (#2348)
*/
type PhaseCleanCommitTimesFn = (phaseDir: string, files: string[]) => Map<string, number>;
/** Normalize separators to posix (git emits `/`; callers may pass `\` on Windows). */
function toPosix(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* #4155: canonicalize a covered-input path before it becomes either a
* dedup/sort/hash key or a confinement-check subject. `path.posix.normalize`
* collapses `./`, redundant slashes, and internal `..` segments (`a/../../b`
* → `../b`) — without this, two spellings of the SAME file (`src/x.cts` vs
* `./src/x.cts`) hash as different covered inputs (spurious `stale`, or a
* file double-counted into the digest under two keys), and an escape
* disguised by an internal `..` segment slips past a check that only looks
* at the string's start.
*/
function normalizeRel(p: string): string {
return path.posix.normalize(toPosix(p));
}
/** Canonicalize a covered-files list: normalize, de-duplicate, sort — the SAME
* transform computeCoveredDigest and cmdVerificationFingerprint both need
* (the digest's own key order; the CLI's own `covered_files` JSON output). */
function canonicalizeCoveredFiles(files: readonly string[]): string[] {
return Array.from(new Set(files.map(normalizeRel))).sort();
}
// ─── #4155: covered-input fingerprint ──────────────────────────────────────────
/**
* Bump on any change to the digest's input shape (path list, hashing order,
* per-file hash algorithm) so an old stored digest can never collide with a
* differently-computed new one — a version mismatch is just a mismatch.
*
* Version history:
* v1 (#4155) — every covered path's whole bytes, uniformly.
* v2 (#4623) — repo-wide planning documents (`isSharedPlanningDoc`) are
* excluded from the hash by construction.
*
* A stored digest names its own version (`v<N>:sha256:…`), and
* `readVerificationStatus` recomputes under the STORED version rather than
* this constant — so bumping it does not flip every already-verified phase
* to `stale` on upgrade. A legacy v1 report keeps v1 semantics, shared
* documents included, until it is re-fingerprinted; only a version outside
* `KNOWN_FINGERPRINT_VERSIONS` is unrecomputable and fails closed.
*/
const FINGERPRINT_VERSION = 2;
const KNOWN_FINGERPRINT_VERSIONS: ReadonlySet<number> = new Set([1, 2]);
/**
* #4623: the planning roots whose DIRECT children are repo-wide planning
* documents, as project-root-relative posix paths. Always `.planning`; plus
* the phase's OWN planning root when a phase directory is known — the parent
* of its `phases/` directory, which is how `planningDir` lays out every
* scope (`.planning`, `.planning/<project>`, `.planning/workstreams/<ws>`,
* `.planning/<project>/workstreams/<ws>`; `planning-workspace.cts`). Derived
* from the phase's position rather than from a list of layouts so a
* workstream-scoped `ROADMAP.md` is recognised without this function
* knowing what a workstream is, and so `.planning/research/notes.md` is
* NOT mistaken for one — lexically the two are indistinguishable from
* `.planning/<project>/ROADMAP.md`. A phase directory that does not sit
* under the project root (unit fixtures at a bare tmpdir) contributes no
* extra root.
*/
function sharedPlanningRoots(projectRoot: string, phaseDir?: string | null): string[] {
const roots = ['.planning'];
if (phaseDir) {
const phasesDir = path.dirname(path.resolve(phaseDir));
const planningDir = path.dirname(phasesDir);
const rel = normalizeRel(path.relative(path.resolve(projectRoot), planningDir));
// Two structural checks, both load-bearing: the phase dir's PARENT must be
// the `phases/` directory `planningDir` lays every scope out with, and the
// derived root must sit inside `.planning/`. Without them any accepted
// directory — `<root>/src/phases/01-fake` — would nominate `src` as a
// planning root and silently drop real implementation evidence from the
// digest (found by the cross-AI review of this change). A shape that fails
// either check contributes no extra root; `.planning` itself is already
// present.
if (
path.basename(phasesDir) === 'phases' &&
rel.startsWith('.planning/') &&
!rel.includes('/../') &&
!roots.includes(rel)
) {
roots.push(rel);
}
}
return roots;
}
/**
* #4623: a covered path names a repo-wide planning document when it sits
* DIRECTLY under one of `sharedPlanningRoots` — `ROADMAP.md`,
* `REQUIREMENTS.md`, `STATE.md`, `PROJECT.md`, `MILESTONES.md`,
* `config.json`, … — as opposed to a phase's own artifacts under
* `<root>/phases/<phase>/` or a research note under `.planning/research/`.
* Every phase rewrites these as ordinary bookkeeping (a roadmap checkbox, a
* requirement's traceability cell, STATE.md's position), so hashing their
* whole bytes into one phase's digest coupled every phase's staleness to
* every other phase's close — and to its OWN close, since `phase.complete`
* and `requirements mark-complete` write them after the verifier has
* already run.
*
* Defined by position, not by a name list, so the set cannot drift as new
* top-level planning documents appear (the tree already carries a dozen).
* `rel` is expected posix-normalized (`canonicalizeCoveredFiles`), so a
* `./.planning/ROADMAP.md` spelling has already collapsed to the bare form.
*/
function isSharedPlanningDoc(rel: string, roots: readonly string[] = ['.planning']): boolean {
if (rel === '' || rel.endsWith('/')) return false;
return roots.includes(path.posix.dirname(rel));
}
/**
* #4623: the fingerprint version a stored `covered_digest` was computed
* under, or `null` when the prefix is absent, malformed, or names a version
* this build cannot recompute (an unknown version is a mismatch by
* construction — the fail-closed shape `FINGERPRINT_VERSION`'s doc promises).
*/
function parseFingerprintVersion(digest: string): number | null {
const m = /^v(\d+):sha256:/.exec(digest);
if (!m) return null;
const version = Number(m[1]);
return KNOWN_FINGERPRINT_VERSIONS.has(version) ? version : null;
}
/**
* #4155: recompute the deterministic content fingerprint over a verifier's
* declared covered-input set (phase PLAN/SUMMARY, mapped requirements,
* implementation files in the change set) and return the versioned digest
* string, or `null` if the set cannot be resolved.
*
* Determinism: paths are de-duplicated and SORTED before hashing (directory
* enumeration order is irrelevant), each path is resolved relative to
* `projectRoot` (the absolute checkout path never enters the digest), and
* file BYTES are hashed (mtime never enters the digest).
*
* NOT normalized: line endings. Unlike the report-frontmatter read (which
* runs every VERIFICATION.md through `normalizeLineEndings`), covered-file
* bytes are hashed exactly as they sit on disk. A covered text file checked
* out with CRLF line endings (e.g. a Windows checkout without a `.gitattributes
* eol=lf` rule pinning it to LF) hashes differently than the same file on an
* LF checkout — a real cross-platform digest mismatch, not a bug, since MSD
* installs into arbitrary user projects with no guaranteed line-ending policy.
*
* Fail closed: a covered path that is empty, absolute, escapes
* `projectRoot` (`..` traversal), or cannot be read (missing, unreadable,
* not a regular file) makes the WHOLE fingerprint unresolvable — returns
* `null` — rather than silently hashing a partial set. Callers treat `null`
* as stale (#4155), the same fail-closed shape #3057 B3 established for the
* legacy mtime staleness check.
*
* Always reads through the REAL `node:fs`, never a caller-injected `FsLike`
* seam — same reasoning as the root canonicalization below, extended to
* every covered file: `covered_files` is expected to span the whole
* `projectRoot` (implementation files under `src/`, not just `.planning/`
* artifacts), so a caller-scoped containment wrapper narrower than
* `projectRoot` (e.g. `planning-inspect.cts`'s `containmentEnforcingVerificationFs`,
* confined to `.planning/`) would reject every implementation-file read and
* report EVERY fingerprinted phase permanently `stale` regardless of actual
* drift — the bug this comment now documents against regressing. The
* `realRel`-vs-`realRoot` re-check a few lines below already does the real
* confinement work (against `projectRoot`, the correct boundary for this
* data), so no security property is lost by bypassing a narrower seam here.
*/
function computeCoveredDigest(
projectRoot: string,
coveredFiles: readonly string[],
version: number = FINGERPRINT_VERSION,
opts: { phaseDir?: string | null } = {},
): string | null {
// #4623: `version` selects the input shape to hash under — the CURRENT
// one for a fresh fingerprint (the CLI verb), or the STORED one when
// `readVerificationStatus` recomputes against a report's own digest.
// `opts.phaseDir` lets v2 recognise the phase's own planning root
// (`sharedPlanningRoots`); without it only `.planning/` itself is shared.
if (!KNOWN_FINGERPRINT_VERSIONS.has(version)) return null;
const uniqueSorted = canonicalizeCoveredFiles(coveredFiles);
if (uniqueSorted.length === 0) return null;
const sharedRoots = version >= 2 ? sharedPlanningRoots(projectRoot, opts.phaseDir) : [];
let hashed = 0;
// Canonicalize the root ONCE — every candidate's realpath is checked against
// this, not the possibly-symlinked `projectRoot` argument itself. Always via
// the REAL fs, never fsImpl: `projectRoot` is a trusted anchor the CALLER
// derived (findProjectRoot), not attacker-influenced covered-input data —
// routing it through a caller-scoped containment seam (e.g. #4155's
// containmentEnforcingVerificationFs, confined to `.planning/`, a proper
// SUBSET of `projectRoot`) would reject the root itself and fail every
// lookup regardless of whether the covered files are legitimate.
let realRoot: string;
try {
realRoot = fs.realpathSync(projectRoot);
} catch {
return null;
}
const parts: string[] = [];
for (const rel of uniqueSorted) {
// `normalizeRel` (already applied by `canonicalizeCoveredFiles` above)
// collapses internal `..` segments before `rel` ever reaches here
// (`a/../../b` → `../b`), so this start-of-string check is already the
// full lexical confinement test — no separate post-`path.resolve`
// re-check can observe a different answer.
if (rel === '' || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) return null;
const resolved = path.resolve(projectRoot, rel);
let bytes: Buffer;
try {
// A regular file INSIDE projectRoot can still be a symlink whose TARGET
// escapes it — statSync/readFileSync follow symlinks, so the lexical
// confinement check above is not enough. realpathSync resolves the
// actual target; re-confining against realRoot closes that gap.
const real = fs.realpathSync(resolved);
// Both operands are already realpath-resolved (this fn's own realpathSync calls
// above), so the shared containment comparison applies directly (ADR-4650) —
// no re-resolution through assertWithinRoot/tryWithinRoot, which would redo work
// this function already owns for its exists-vs-escaped tri-state.
if (!isContainedIn(real, realRoot)) {
return null;
}
const st = fs.statSync(real);
if (!st.isFile()) return null;
// #4623 (v2+): a repo-wide planning document is VALIDATED exactly as
// every other covered path — confined, present, a regular file; the
// fail-closed contract above is unchanged — but its bytes contribute
// nothing to the digest. It may stay declared in `covered_files` (the
// verifier's instructions long said to list the mapped requirement,
// and every report already written does); its bookkeeping churn can
// no longer read as drift.
if (isSharedPlanningDoc(rel, sharedRoots)) continue;
bytes = fs.readFileSync(real);
} catch {
return null;
}
const fileHash = crypto.createHash('sha256').update(bytes).digest('hex');
parts.push(`${rel}\n${fileHash}\n`);
hashed++;
}
// #4623 (v2+): a declaration made ONLY of shared planning documents has no
// evidence in it at all — a constant digest over the header would satisfy
// the fingerprint pair while grounding the verification in nothing. Fail
// closed, the same way an empty declaration does.
if (version >= 2 && hashed === 0) return null;
const aggregate = crypto
.createHash('sha256')
.update(`v${version}\n${parts.join('')}`, 'utf-8')
.digest('hex');
return `v${version}:sha256:${aggregate}`;
}
/**
* #4155: the content fingerprint only recomputes digests for paths the
* verifier actually DECLARED in `covered_files` — it has no way to notice a
* plan or summary added to the phase directory AFTER verification if that
* new file was never declared. This closes that gap the same way the
* legacy mtime check always did: by re-scanning the LIVE directory (not the
* declared list) for every current `*-PLAN.md`/`*-SUMMARY.md` and checking
* each is represented in `coveredFiles` — matched by suffix (mirrors
* `matchRequestedFile`'s convention) since `coveredFiles` holds
* project-root-relative paths while the scan returns phase-relative
* filenames. Returns `true` if every current plan/summary is covered,
* `false` otherwise — callers only ever branch on this pass/fail, so no
* caller needs which artifact was uncovered.
*
* Fails CLOSED on an incomplete scan: `scanPhasePlans` never throws on a
* readdir failure — it reports it via `scope` (`SCOPE.UNREADABLE` for the
* phase dir itself, `SCOPE.TRUNCATED` for an unreadable nested `plans/`)
* with whatever files it DID manage to enumerate, per `SCOPE`'s own
* contract (`planning-scope.cts`): zero items under a non-`COMPLETE` scope
* is a NON-answer, never "this phase has no plans." Branching on `scope`
* here (rather than a try/catch, which this scan never triggers) is what
* makes an unreadable `plans/` dir report `false` instead of silently
* treating its invisible contents as vacuously covered — the same
* fail-open regression #3057 B3 fixed for the legacy path.
*/
function allCurrentArtifactsCovered(phaseDir: string, coveredFiles: readonly string[]): boolean {
const scan = scanPhasePlans(phaseDir);
if (scan.scope !== SCOPE.COMPLETE) return false;
const coveredPosix = canonicalizeCoveredFiles(coveredFiles);
return [...scan.allPlanFiles, ...scan.summaryFiles].every((artifact) => {
const artifactPosix = toPosix(artifact);
return coveredPosix.some((c) => c === artifactPosix || c.endsWith(`/${artifactPosix}`));
});
}
/**
* Match a git-emitted (repo-root-relative) path back to the caller's
* phaseDir-relative request by exact match or `/`-bounded suffix — precise
* enough that a root file and a nested `plans/` file can never collide (a plain
* basename match could). Returns the original caller-form file string, or null.
*/
function matchRequestedFile(gitPath: string, requested: string[], requestedPosix: string[]): string | null {
const g = toPosix(gitPath);
for (let i = 0; i < requested.length; i++) {
const want = requestedPosix[i];
if (g === want || g.endsWith('/' + want)) return requested[i];
}
return null;
}
/**
* Parse `git log --format=%ct --name-only` output into file → most-recent commit
* time (ms). Output is reverse-chronological, so a file's FIRST appearance
* top-down is its latest commit. `%ct` headers are pure digits; path lines
* contain a `.` (the `.md` extension) — so the two are unambiguous.
*/
function parseCommitTimes(
stdout: string,
requested: string[],
requestedPosix: string[],
): Map<string, number> {
const out = new Map<string, number>();
let currentCt: number | null = null;
for (const line of stdout.split('\n')) {
if (line.length === 0) continue;
if (/^\d+$/.test(line)) {
currentCt = Number.parseInt(line, 10);
continue;
}
if (currentCt === null) continue;
const rel = matchRequestedFile(line, requested, requestedPosix);
if (rel !== null && !out.has(rel)) out.set(rel, currentCt * 1000);
}
return out;
}
/**
* Default resolver: two bounded git calls per phase (never one-per-file — #2348 /
* "Unbounded Subprocesses"; readVerificationStatus runs per-phase in the
* init/roadmap listing loops, so per-file spawning would fan out to P×(S+1)):
*
* 1. `git log --first-parent --format=%ct --name-only -- <files…>` for commit
* times. `--first-parent` makes merge commits report their (first-parent)
* file lists — plain `--name-only` omits merge diffs, which would silently
* under-date content that landed via a conflict-resolving merge.
* 2. `git diff --name-only HEAD -- <files…>` to drop any file whose working
* tree has diverged from HEAD: a committed-then-edited file must be timed by
* its mtime (the edit), never by its now-stale commit time.
*
* Paths pass after `--` so a dash-prefixed filename cannot be read as a flag. Any
* non-answer (no repo, no commits, missing git) yields an empty map → the caller
* times every file by mtime. Never throws. The per-phase file list is small (a
* verification report + a handful of summaries), so the argv stays far below the
* Windows 32K limit. `execGitFn` is injectable so the two-call error handling is
* unit-testable without spawning git.
*/
type ExecGitFn = typeof execGit;
function defaultPhaseCleanCommitTimesMs(
phaseDir: string,
files: string[],
execGitFn: ExecGitFn = execGit,
): Map<string, number> {
if (files.length === 0) return new Map();
const requestedPosix = files.map(toPosix);
const logRes = execGitFn(['log', '--first-parent', '--format=%ct', '--name-only', '--', ...files], {
cwd: phaseDir,
});
if (logRes.error || logRes.exitCode !== 0 || logRes.stdout.length === 0) return new Map();
const commitTimes = parseCommitTimes(logRes.stdout, files, requestedPosix);
if (commitTimes.size === 0) return commitTimes;
// Drop dirty files (working tree ≠ HEAD) so their mtime is used instead. If the
// dirty-check itself is INCONCLUSIVE (git diff errored / non-zero — as opposed
// to "ran and reported no dirty files"), we cannot prove any file is clean, so
// fail SAFE: discard the commit times and let every file fall back to mtime,
// the same direction as a git-log failure. Trusting possibly-stale commit times
// here would silently mask a real edit (false "not stale"). (#2348)
const diffRes = execGitFn(['diff', '--name-only', 'HEAD', '--', ...files], { cwd: phaseDir });
if (diffRes.error || diffRes.exitCode !== 0) return new Map();
for (const line of diffRes.stdout.split('\n')) {
if (line.length === 0) continue;
const rel = matchRequestedFile(line, files, requestedPosix);
if (rel !== null) commitTimes.delete(rel);
}
return commitTimes;
}
/**
* Build a 'missing' result from the routing table.
* Used for two early-return paths: no *-VERIFICATION.md file found, and
* file present but no parseable frontmatter status.
*/
function missingResult(runtime: string, phaseArg: string): VerificationStatusResult {
const route = VERIFICATION_ROUTING_TABLE['missing'];
return {
status: route.status,
next_action: route.next_action,
next_command: projectNextCommand(route.next_command, runtime, phaseArg),
};
}
interface ResolveVerificationFileOptions {
/**
* #3473 F2: three OTHER hand-rolled selection sites (`src/commands.cts`
* determinePhaseStatus and two `verification_path` projectors in
* `src/init.cts`) additionally accept a BARE `VERIFICATION.md` — a form
* this module's own two callers (`findStaleVerificationSummary`,
* `readVerificationStatus`) had never accepted, because a bare filename
* carries no phase token and `.endsWith('-VERIFICATION.md')` structurally
* excludes it. Defaults to `false`, which is byte-for-behavior identical to
* the pre-existing (non-optioned) resolver — no call-site edit required for
* callers that do not want the bare tier.
*
* #4187: that historical asymmetry was drift, not contract. Six call sites
* grew around the shared resolver and four opted in
* (`cmdVerificationResolveFile`, `determinePhaseStatus`, both init
* `verification_path` projectors) — the two module-internal status-path
* call sites (`readVerificationStatus`, `findStaleVerificationSummary`)
* did not, so `query verification.resolve-file` resolved a bare report in
* a directory where `query verification.status` answered `missing` and
* recommended re-running execute-phase for an already-verified phase.
* Since #4187 those two pass `true` as well: every reader of the report
* set now recognizes the bare name. Tier order is unchanged — a dashed
* candidate (canonical or not, if it belongs to THIS phase) still outranks
* the bare match — so this only changes directories whose SOLE report is
* bare.
*/
allowBare?: boolean;
/**
* #3492 regression fix: the phase token (`extractPhaseToken` on the phase
* directory's own basename — same grammar `src/phase-id.cts` owns via
* `PHASE_NUMBER_TOKEN_SOURCE`) THIS call is resolving for. Every call site
* knows its own phaseDir, so every call site can derive and pass this.
*
* Pinning selection to the caller's own phase is load-bearing: preferring
* ANY canonically-shaped `<token>-VERIFICATION.md` (regardless of whose
* token it carries) let a stray cross-phase or sentinel-numbered file
* (`999-VERIFICATION.md`) outrank the phase's own non-canonical report
* (`12-review-VERIFICATION.md`) — a regression this option closes.
*
* Omitted / empty when the token cannot be derived: falls back to plain
* alphabetically-first among the SCOPED dashed candidates (see
* `phaseDirName` below), never to null.
*/
phaseToken?: string;
/**
* #3511 reconciliation: the phase directory's own basename (the same value
* every call site already passes through `extractPhaseToken` to derive
* `phaseToken` above) — needed separately because the fallback below scopes
* by `isPhaseArtifact(fileName, phaseDirName)`, not by `phaseToken`.
*
* Omitted: the fallback degrades to the plain (unscoped) alphabetically-first
* pick — the original pre-#3357 behavior — never to null.
*/
phaseDirName?: string;
/**
* #612: the repo's resolved `phase_id_convention`, threaded verbatim into
* `scopeToPhase(candidates, phaseDirName, convention)` so the fallback
* scopes a bracket dir (`{CODE}.{MM}-{PP}-slug`) by its real phase token
* instead of the include-everything ambiguity fail-safe. Same ADR-2121
* additive shape as every other convention thread: omitted / null resolves
* to the unchanged legacy scoping.
*/
convention?: string | null;
}
/**
* #3518: `resolveUatFile`'s options — same two knobs, same semantics, as
* `ResolveVerificationFileOptions` above (the UAT artifact is selected by the
* identical phase-pinned rule the verification report is; see
* `resolvePhaseArtifactFile` below for the single shared selection core).
*/
type ResolveUatFileOptions = ResolveVerificationFileOptions;
/**
* #3518: the shared phase-pinned artifact-selection core BOTH single-pick
* resolvers (`resolveVerificationFile` for `*-VERIFICATION.md`,
* `resolveUatFile` for `*-UAT.md`) delegate to — one rule, not two grammars
* that agree today and drift tomorrow (epic #3473 F2's defect class).
*
* `bareName` is the artifact filename WITHOUT the leading dash (`'UAT.md'`);
* a "dashed" candidate is any entry ending `-${bareName}`.
*
* Selection order:
* 1. `options.phaseToken` given and `<phaseToken>-${bareName}` is among
* the candidates — that exact file always wins: it is THIS phase's own
* artifact, and no other candidate (whichever phase's token it carries)
* can outrank it (#3492 / #3518).
* 2. Fallback — no exact phase-token match (or no token given): alphabetically
* first of the dashed candidates that are THIS phase's own, per
* `scopeToPhase(candidates, options.phaseDirName)` (#3511 reconciliation,
* below). Load-bearing: a phase whose only artifact is non-canonically
* named must keep resolving to it, not to null — this fix must not turn
* "found an artifact" into "found nothing" for anyone. A
* non-canonically-named artifact of THIS phase (e.g.
* `03-CORRECTION-VERIFICATION.md` in `03-foo`) still passes
* `isPhaseArtifact` (it names phase 03, same as the directory), so it
* is still returned here.
* 3. `options.allowBare` only — a bare `${bareName}`, ranked BELOW both
* of the above. Rationale: a dashed file names its phase, a bare one
* does not, so a dashed file (canonical or not) is always the better
* answer when both exist. Reached when neither (1) nor (2) found any
* candidate — including when (2)'s scoping filtered every dashed
* candidate out as belonging to some OTHER phase.
*
* #3511 RECONCILIATION with `isPhaseArtifact` (`src/phase-id.cts`): that
* predicate's own docblock used to flag this fallback as an open gap — its
* aggregate scans exclude a cross-phase stray, but this single-pick resolver
* did not, so it could return a stray as THE artifact while the aggregate
* scans correctly ignored it. Closed by scoping step (2) above through
* `scopeToPhase` (`src/phase-id.cts`, itself built on `isPhaseArtifact`):
* `options.phaseDirName` threads the phase directory's basename in, and the
* fallback now filters candidates through `scopeToPhase(candidates,
* phaseDirName)` before picking alphabetically-first. This does NOT reopen
* the #3357 guarantee — that guarantee is "a phase whose only report is
* non-canonically named must keep working", and a non-canonically-named
* artifact of THIS phase still passes `isPhaseArtifact` (it is membership by
* phase number, not by canonical shape), so it is still returned. Only a
* file belonging to a DIFFERENT phase is now excluded — and excluding it is
* correct: returning another phase's artifact as this phase's own is worse
* than reporting none (confidently wrong beats honestly empty).
* The fail-safe now lives entirely inside `isPhaseArtifact`, not in
* `scopeToPhase` (which is a plain filter with no unfiltered fallback):
* (a) when phase-number membership cannot be determined for `phaseDirName` at
* all (no reliable token — the zero-token directory case), every candidate is
* treated as belonging to the phase; (b) the `firstLetterPrefixed`
* bracket-ambiguity case, where a letter-prefixed-decimal dir is
* string-indistinguishable from a bracket-dir token, also includes
* everything rather than guess; (c) a token-less filename (bare
* `${bareName}`) is accepted by directory containment alone. Outside
* those cases, when scoping DOES remove every dashed candidate — a real
* cross-phase stray, or a phase whose own artifact is genuinely absent — the
* fallback below correctly falls through to `allowBare`/`null`: reporting no
* artifact, not another phase's. `options.phaseDirName` omitted entirely skips
* the filter outright (the ternary below), which is unscoped, pre-#3511
* behavior.
*
* Pure — takes an already-read directory listing and does no I/O of its own,
* so every call site keeps its existing `fsImpl` seam and no-throw contract
* untouched.
*/
function resolvePhaseArtifactFile(
entries: string[],
bareName: string,
options: ResolveVerificationFileOptions = {},
): string | null {
const candidates = entries.filter((f) => f.endsWith(`-${bareName}`)).sort();
if (candidates.length > 0) {
if (options.phaseToken) {
const thisPhaseFile = `${options.phaseToken}-${bareName}`;
if (candidates.includes(thisPhaseFile)) return thisPhaseFile;
}
// #3511: scope the fallback to files that belong to THIS phase, so a
// stray cross-phase file can no longer outrank a return of null.
// `phaseDirName` omitted, or membership undeterminable for it, →
// unscoped `candidates` (pre-#3511 behavior); otherwise strays are
// filtered out, and if that leaves nothing the code falls through to
// `allowBare`/`null` deliberately.
const scoped = options.phaseDirName
? scopeToPhase(candidates, options.phaseDirName, options.convention)
: candidates;
if (scoped.length > 0) return scoped[0];
}
if (options.allowBare && entries.includes(bareName)) return bareName;
return null;
}
/**
* Resolve which `*-VERIFICATION.md` entry in a phase directory's listing IS
* the phase's verification report, when more than one such file exists.
*
* #3357: a phase dir can legitimately hold more than one `*-VERIFICATION.md`
* — the real per-phase report (`03-VERIFICATION.md`) alongside an ad-hoc plan
* worksheet (`03-CORRECTION-VERIFICATION.md`). Picking "alphabetically first"
* (`'C' < 'V'`) silently chose the worksheet, which usually has no
* frontmatter `status:`, so a phase with a PASSING report read as `missing`.
* This was two independent hand-rolled `.sort()[0]` picks
* (findStaleVerificationSummary and readVerificationStatus) — this is the
* single resolver both now call (#3473 F2).
*
* Selection order: see `resolvePhaseArtifactFile` (the shared core this
* delegates to since #3518, itself phase-scoped since #3511) —
* phase-token-pinned, then phase-scoped alphabetically-first dashed
* fallback, then (allowBare only) a bare `VERIFICATION.md`. #3518 extracted
* this into the shared core without changing behavior; #3511's
* `phaseDirName` scoping now lives inside that shared core rather than here.
*/
function resolveVerificationFile(
entries: string[],
options: ResolveVerificationFileOptions = {},
): string | null {
return resolvePhaseArtifactFile(entries, 'VERIFICATION.md', options);
}
/**
* #3518: resolve which `*-UAT.md` entry in a phase directory's listing IS
* the phase's UAT artifact, when more than one such file exists — the UAT
* counterpart of `resolveVerificationFile`, sharing its exact selection rule
* via `resolvePhaseArtifactFile`.
*
* The bug this closes: both `uat_path` projectors in `src/init.cts` picked
* with a bare `.find((f) => f.endsWith('-UAT.md') || f === 'UAT.md')` over an
* unsorted `readdir` listing — no phase-membership check and no ordering — so
* a stray or cross-phase `04-UAT.md` sitting in phase 03's directory could
* become phase 03's `uat_path`, and WHICH file won was filesystem-dependent
* (creation order on APFS, hash order on ext4/XFS): two machines on the same
* commit could emit different `uat_path` values for the same phase. `uat_path`
* is consumed downstream by workflows that then read the named file, so a
* wrong path routes UAT state from another phase.
*
* Deterministic by construction: same answer on every machine. Phase-scoped
* (#3511): passing `options.phaseDirName` filters the alphabetically-first
* fallback (tier 2) to artifacts that belong to THIS phase — see
* `resolvePhaseArtifactFile` for the full selection order and scoping
* rationale.
*/
function resolveUatFile(
entries: string[],
options: ResolveUatFileOptions = {},
): string | null {
return resolvePhaseArtifactFile(entries, 'UAT.md', options);
}
// ─── Public API ───────────────────────────────────────────────────────────────
interface ReadVerificationStatusOptions {
fs?: FsLike;
/** Injectable per-phase clean-commit-time resolver for the staleness clock (#2348). */
phaseCleanCommitTimesMs?: PhaseCleanCommitTimesFn;
/**
* Runtime whose command surface `next_command` is projected into (#2617).
* Callers that have a cwd should pass `resolveRuntime(cwd)`. Defaults to
* `'claude'`, which yields the canonical `/msd-<cmd>` hyphen form — never the
* deprecated `/msd:` colon form this field used to hard-code.
*/
runtime?: string;
/**
* Phase number appended to the routed command (#2617). Defaults to the token
* parsed from `phaseDir`, but only when that token is unambiguously numeric.
* Callers that already know the number pass it explicitly — `init` reaches
* this with `phaseDir` unresolved in some branches.
*/
phaseNumber?: string;
/**
* #612: the repo's resolved `phase_id_convention`, threaded through to
* `resolveVerificationFile` (exact-pin token + fallback scoping) and
* `findStaleVerificationSummary` so a bracket phase dir resolves and
* scopes its report exactly like its legacy twin. Deliberately NOT used
* for the routed command argument — see the derivation comment in the
* body. Omitted / null: unchanged legacy behavior.
*/
convention?: string | null;
}
interface VerificationStatusResult {
status: string;
next_action: string;
next_command: string;
/**
* True when the internal staleness check (findStaleVerificationSummary)
* could not run to completion (an fs / scanPhasePlans / clock failure) —
* `status` above was routed as if the phase were not stale (the pre-existing
* no-throw fail-open contract, preserved unchanged), but this flag lets a
* caller distinguish "checked; nothing is stale" from "could not check" so
* the two are no longer silently identical (#3057 B3). Omitted (not present)
* when the staleness check ran to completion, or was never reached (e.g. the
* `gaps_found` short-circuit above it, or no verification file at all).
*/
staleCheckIndeterminate?: boolean;
/**
* WHY a `stale` result is stale — present only when `status` is
* `stale`. Every reason still means "re-run the verifier"; the split lets
* a caller say what happened (a gap-closure plan the report never saw is
* not the same event as a covered source file drifting under a finished
* phase) and lets progress batch the drift case across phases.
*/
stale_reason?: StaleReason;
/**
* the report's own frontmatter `status` underneath a `stale` result —
* present only when `status` is `stale`. `human_needed` here means a UAT
* is still owed (or already recorded) once the verifier has re-run.
*/
raw_status?: string;
}
/**
* the closed set of reasons a report reads `stale`.
* - `fingerprint_malformed` — the `covered_files`/`covered_digest` pair is
* incomplete, wrong-shaped, or names an unknown version (fail closed, #4155).
* - `covered_changed` — a declared covered file's bytes changed, or
* the file is gone.
* - `uncovered_artifacts` — every declared file still matches, but the
* phase directory holds a PLAN/SUMMARY the report never declared (the
* shape every gap-closure cycle produces).
* - `summary_newer` — legacy (pre-#4155) report with a SUMMARY
* changed after it.
*/
type StaleReason = 'fingerprint_malformed' | 'covered_changed' | 'uncovered_artifacts' | 'summary_newer';
const STALE_REASONS: ReadonlyArray<StaleReason> = [
'fingerprint_malformed',
'covered_changed',
'uncovered_artifacts',
'summary_newer',
];
function findStaleVerificationSummary(
phaseDir: string,
fsImpl: FsLike = defaultFsImpl,
phaseCleanCommitTimesMs: PhaseCleanCommitTimesFn = defaultPhaseCleanCommitTimesMs,
convention?: string | null,
): StaleCheckResult {
// FS errors (TOCTOU: a SUMMARY listed by scanPhasePlans then removed before statSync;
// unreadable dir; broken symlink; file->dir swap) must degrade rather than throw
// uncaught into callers that are NOT under the planning lock (init.manager /
// init.progress / uat-predicate). Mirrors readVerificationStatus's no-throw
// contract; `fsImpl` threads the same injectable-fs seam for parity/testing.
// (Review B1 on #1548.) The degraded result is `{determined:false}`, NOT the
// same value as a completed "nothing is stale" check — see StaleCheckResult
// doc and #3057 B3. The caller decides how to route an indeterminate result;
// this function only reports what it actually knows.
try {
const phaseFiles = fsImpl.readdirSync(phaseDir);
// #3492: pin selection to THIS phase's own token so a stray cross-phase
// or sentinel-numbered canonically-shaped file cannot outrank this
// phase's own (possibly non-canonical) report. #3511: phaseDirName scopes
// the fallback path to this same phase (see resolveVerificationFile docs).
// #4187: allowBare — this staleness seam must see the same report set the
// status reader sees, or a bare report could never read `stale` while its
// dashed twin could (two answers from one verb).
const phaseDirName = path.basename(phaseDir);
// #612: derive the token with the resolved convention so a bracket dir's
// own token is read behind its `{CODE}.{MM}-` prefix. #4187: keep the bare
// report tier aligned with the status reader.
const phaseToken = extractPhaseToken(phaseDirName, convention);
const verificationFile = resolveVerificationFile(phaseFiles, {
allowBare: true,
phaseToken,
phaseDirName,
convention,
});
if (!verificationFile) return { determined: true, stale: false };
const summaryFiles = (scanPhasePlans(phaseDir) as { summaryFiles: string[] }).summaryFiles
.slice()
.sort();
// No summary can be newer than the verification → never stale. Return before
// touching git so a phase with no summaries costs zero subprocesses. (#2348)
if (summaryFiles.length === 0) return { determined: true, stale: false };
// Each file's effective "last changed" time = its commit time when committed
// AND clean (content-tied and clone-stable), else its filesystem mtime (the
// uncommitted working-tree edit). Both are real wall-clock change times, so
// comparing a clean file's commit time against a dirty file's mtime is sound.
// One resolver call = two git subprocesses for the whole phase. (#2348)
const cleanCommitMs = phaseCleanCommitTimesMs(phaseDir, [verificationFile, ...summaryFiles]);
const effectiveTimeMs = (file: string): number =>
cleanCommitMs.has(file)
? (cleanCommitMs.get(file) as number)
: fsImpl.statSync(path.join(phaseDir, file)).mtimeMs;
const verificationTimeMs = effectiveTimeMs(verificationFile);
for (const summaryFile of summaryFiles) {
// The caller only needs whether the phase is stale, not which summary —
// the first stale summary (in sorted order) is enough. Short-circuit.
if (effectiveTimeMs(summaryFile) > verificationTimeMs) {
return { determined: true, stale: true, verificationFile, summaryFile };
}
}
return { determined: true, stale: false };
} catch {
return { determined: false };
}
}
/**
* Read the verification status from the first `*-VERIFICATION.md` file in
* phaseDir and return the routing result.
*
* Behavior:
* 1. Find the phase's verification report via `resolveVerificationFile`
* (canonical `<phase-token>-VERIFICATION.md` preferred; falls back to the
* alphabetically-first `*-VERIFICATION.md` that belongs to THIS phase when
* none is canonical — #3357/#3511; and, when the directory's only report
* is a bare `VERIFICATION.md`, that file — #4187, matching
* `verification.resolve-file`). If none → status 'missing'.
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
* If no frontmatter block or no `status` key → status 'missing'.
* 3. Map to routing table. Unknown non-empty value → status 'unknown'.
*
* The internal staleness check can itself fail (fs / scanPhasePlans / clock
* error); when it does, `status` is routed as if nothing were stale (the
* pre-existing no-throw fail-open contract — unchanged), but the returned
* result carries `staleCheckIndeterminate: true` so a caller can distinguish
* "checked; nothing is stale" from "could not check" (#3057 B3).
*
* @param phaseDir - Absolute path to the phase directory.
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
* `opts.runtime` selects the command surface `next_command` is
* projected into (#2617).
*/
function readVerificationStatus(
phaseDir: string,
opts: ReadVerificationStatusOptions = {},
): VerificationStatusResult {
const fsImpl: FsLike = opts.fs ?? defaultFsImpl;
const phaseCleanCommitTimesMs: PhaseCleanCommitTimesFn =
opts.phaseCleanCommitTimesMs ?? defaultPhaseCleanCommitTimesMs;
const runtime = opts.runtime ?? 'claude';
// Phase token for the gaps_found command — deliberately convention-LESS
// even when `opts.convention` is present: the token becomes a bare COMMAND
// ARGUMENT below, and a bare bracket phase number is milestone-ambiguous
// (`02` cannot tell MSD.01-02 from MSD.02-02), so the argument keeps its
// pre-#612 shape. The convention-aware token is derived separately for
// FILE RESOLUTION only (`resolutionToken`, at the readdir below).
const baseName = path.basename(phaseDir);
const phaseToken = extractPhaseToken(baseName);
const derivedPhaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
// #2617: the phase number becomes a COMMAND ARGUMENT, so it is appended only
// when it is unambiguously one. extractPhaseToken also returns project-code
// forms (`PROJ-07`), which are indistinguishable by shape from an ordinary
// directory name — `msd-651-parent` yields `msd-651` — and emitting
// `execute-phase msd-651` is worse than emitting no argument at all. Callers
// that already know the number (init) pass it explicitly and always get it.
const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
const phaseArg = phaseArgSource ? ` ${phaseArgSource}` : '';
// 1. Find *-VERIFICATION.md
let verificationFile: string | null = null;
try {
const entries = fsImpl.readdirSync(phaseDir);
// #3492: pin selection to THIS phase's own token so a stray cross-phase
// or sentinel-numbered canonically-shaped file cannot outrank this phase's
// own report. #612: derive a separate convention-aware RESOLUTION token
// for bracket directories while the routed command argument above stays
// convention-less and milestone-unambiguous. #4187: keep the bare report
// tier aligned with every other verification reader.
const resolutionToken = opts.convention === 'bracket'
? extractPhaseToken(baseName, opts.convention)
: phaseToken;
verificationFile = resolveVerificationFile(entries, {
allowBare: true,
phaseToken: resolutionToken,
phaseDirName: baseName,
convention: opts.convention,
});
} catch {
// Directory unreadable → treat as missing
verificationFile = null;
}
if (!verificationFile) {
return missingResult(runtime, phaseArg);
}
// 2. Read and parse frontmatter using the shared parser.
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
const filePath = path.join(phaseDir, verificationFile);
let rawStatus: string | null = null;
let fm: ReturnType<typeof extractFrontmatter> = {};
try {
// #3707-CR follow-up MINOR 1: normalize line endings at this read
// boundary — this function's own `readFileSync` is the equivalent seam
// `planning.inspect`'s `buildUatRows`/`readDocument` route through for
// UAT/REQUIREMENTS documents, but `readVerificationStatus` had no such
// normalization of its own. A lone-CR VERIFICATION.md's `---\r...\r---`
// frontmatter fence never matched `extractFrontmatter`'s byte-0
// `---\n`/`---\r\n` check, so `status: passed` was read as absent and
// this function reported 'missing' — under-reporting a completed
// verification as if the step never ran, the fail-safe direction but the
// same root cause as the false-clean class fixed elsewhere in #3707-CR.
const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
fm = extractFrontmatter(content, filePath);
// #4806: an unparseable frontmatter block is NOT "missing" — the file
// exists and verification ran. Report a distinct status so the caller is
// sent to fix the YAML, not to re-run execute-phase.
if ((fm as unknown as Record<symbol, unknown>)[FRONTMATTER_UNPARSEABLE] === true) {
return {
status: 'unparseable',
next_action: "The *-VERIFICATION.md frontmatter is not parseable YAML — fix the syntax error in the report itself. Re-running execute-phase cannot fix a YAML typo in an existing report.",
next_command: '',
};
}
const statusVal = fm['status'];
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
// only accept string values — arrays and objects are not valid status values.
if (typeof statusVal === 'string') {
const trimmed = statusVal.trim();
rawStatus = trimmed.length > 0 ? trimmed : null;
}
} catch {
rawStatus = null;
}
if (!rawStatus) {
return missingResult(runtime, phaseArg);
}
// gaps_found takes priority over stale — gap closure is the correct next
// step regardless of whether summaries are newer than the verification file.
if (rawStatus === 'gaps_found') {
const entry = VERIFICATION_ROUTING_TABLE['gaps_found'];
return {
status: entry.status,
next_action: entry.next_action,
next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
};
}
// #4155: a report that declares a covered-input fingerprint is checked by
// RECOMPUTING that fingerprint over current file content — strictly
// content-grounded, and it REPLACES (not supplements) the legacy
// SUMMARY-mtime check below for that report. A report with no fingerprint
// metadata (every report written before #4155) keeps the exact legacy
// mtime-based behavior, unchanged.
const coveredFilesVal = fm['covered_files'];
const coveredDigestVal = fm['covered_digest'];
// A report OPTS IN to the fingerprint check by declaring EITHER field —
// once opted in, an incomplete or malformed pair (one field present but
// not the other, an empty array, a non-array, a blank digest) fails closed
// to `stale` rather than silently downgrading to the weaker legacy
// mtime-only check, which would only ever notice a newer SUMMARY.
const declaresFingerprint = coveredFilesVal !== undefined || coveredDigestVal !== undefined;
const hasWellFormedFingerprint =
Array.isArray(coveredFilesVal) &&
coveredFilesVal.length > 0 &&
coveredFilesVal.every((f) => typeof f === 'string') &&
typeof coveredDigestVal === 'string' &&
coveredDigestVal.trim().length > 0;
let staleCheckIndeterminate = false;
let isStale: boolean;
let staleReason: StaleReason = 'summary_newer';
if (declaresFingerprint) {
// Stated directly rather than relying on `null !== coveredDigestVal`
// being true whenever the pair is malformed: `!hasWellFormedFingerprint`
// fails closed explicitly, and its `||` short-circuit means
// computeCoveredDigest/allCurrentArtifactsCovered never run on a
// malformed (wrong-shaped) `coveredFilesVal`. The two `||`s after it
// short-circuit in turn: the live-directory re-scan (for a plan/summary
// added AFTER verification and never declared in covered_files) only
// runs once the digest itself has already matched.
//
// #4623: recompute under the STORED digest's own version, not the
// current constant — a v1 report written before the shared-document
// exclusion keeps v1 semantics rather than going stale on upgrade. An
// unknown version parses to `null`, which `computeCoveredDigest`
// refuses (returns `null`), so the compare below fails closed.
const storedVersion =
hasWellFormedFingerprint && typeof coveredDigestVal === 'string'
? parseFingerprintVersion(coveredDigestVal)
: null;
// same three checks, same order, same short-circuit — only named, so
// the result can say which one fired.
if (!hasWellFormedFingerprint || storedVersion === null) {
isStale = true;
staleReason = 'fingerprint_malformed';
} else if (
computeCoveredDigest(findProjectRoot(phaseDir), coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal
) {
isStale = true;
staleReason = 'covered_changed';
} else if (!allCurrentArtifactsCovered(phaseDir, coveredFilesVal)) {
isStale = true;
staleReason = 'uncovered_artifacts';
} else {
isStale = false;
}
} else {
const staleCheck = findStaleVerificationSummary(
phaseDir,
fsImpl,
phaseCleanCommitTimesMs,
opts.convention,
);
isStale = staleCheck.determined && staleCheck.stale;
// staleCheck is either {determined:true, stale:false} (checked; nothing
// stale) or {determined:false} (could not check — fs/scan/clock failure).
// Both fall through to normal routing below (the pre-existing no-throw
// fail-open contract is unchanged), but the indeterminate case is flagged
// on the returned result so a caller can tell the two apart (#3057 B3).
staleCheckIndeterminate = !staleCheck.determined;
}
if (isStale) {
const entry = VERIFICATION_ROUTING_TABLE['stale'];
return {
status: entry.status,
next_action: entry.next_action,
// #4682: execute-phase resumes at the verification gates and re-runs
// the verifier, regenerating VERIFICATION.md and its digest — the same
// routing the `missing` sentinel has used since #2868.
next_command: projectNextCommand('execute-phase', runtime, phaseArg),
stale_reason: staleReason,
raw_status: rawStatus,
};
}
// 3. Route — exclude internal sentinels from raw-file lookup (they are
// constructed internally above, never written by the verifier).
if (
rawStatus in VERIFICATION_ROUTING_TABLE &&
rawStatus !== 'missing' &&
rawStatus !== 'unknown' &&
rawStatus !== 'stale' &&
rawStatus !== 'gaps_found'
) {
const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
return {
status: entry.status,
next_action: entry.next_action,
next_command: projectNextCommand(entry.next_command, runtime, phaseArg),
...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
};
}
// Unknown value
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
return {
status: unknownRoute.status,
next_action: `Unexpected verification status '${rawStatus}'. If this is an intentional non-standard marker (e.g. a hand-set failed/superseded state), no action is needed. Otherwise, run execute-phase to regenerate verification — it will not re-run plans that already have a SUMMARY.md.`,
next_command: projectNextCommand(unknownRoute.next_command, runtime, phaseArg),
...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
};
}
interface IsPhaseCompleteDeps {
fs?: FsLike;
/** Injectable per-phase clean-commit-time resolver, threaded through to readVerificationStatus. */
phaseCleanCommitTimesMs?: PhaseCleanCommitTimesFn;
/** Runtime whose command surface next_command is projected into (#2617). */
runtime?: string;
/** Phase number appended to the routed command (#2617). */
phaseNumber?: string;
/**
* #612: the repo's resolved `phase_id_convention`, threaded through to
* readVerificationStatus so a bracket phase dir resolves and scopes its
* report exactly like its legacy twin. Omitted / null: unchanged.
*/
convention?: string | null;
}
interface PhaseCompletionValue {
complete: boolean;
verification: VerificationStatusResult;
}
/**
* isPhaseComplete — the single canonical owner of "is phase P complete?"
* (ADR-3180 §7.4, Decision 1). Sited beside readVerificationStatus, which it
* wraps.
*
* DISK-STRICT (#2957, maintainer decision 2026-08-08; ADR-3180 §7.4 amended
* af92fd4c9): readVerificationStatus is called UNCONDITIONALLY here — plan
* count is NOT a precondition. A phase with zero plans and a passing
* `*-VERIFICATION.md` is complete (#3168). A ROADMAP checkbox has no machine
* authority and is never consulted — this function never reads ROADMAP.md.
*
* `complete` is exactly `verification.status === 'passed'`. `verification`
* carries the FULL routing result (status/next_action/next_command), so a
* caller can distinguish a failing verdict (`gaps_found`/`human_needed`/
* `stale`/`unknown`) from an absent one (`missing`) — both are "not
* complete", but they are not the same non-answer.
*
* `scope` is UNREADABLE when `phaseDir` itself could not be listed — this is
* INDEPENDENT of readVerificationStatus's own no-throw fail-open contract for
* a missing `*-VERIFICATION.md` file (a well-formed answer,
* `verification.status === 'missing'`, scope COMPLETE): a caller must not
* read `value.complete: false` here as a confident "not complete" the way it
* can for a genuinely-checked missing file.
*
* Does NOT import scanPhasePlans / plan-scan.cjs — the owner consumes plan
* counts from its caller when a caller needs them for a different question
* (e.g. buildPhaseCompletionProjection's own `implementation_complete`); it
* never re-derives or requires them itself.
*/
function isPhaseComplete(
phaseDir: string,
deps: IsPhaseCompleteDeps = {},
): { value: PhaseCompletionValue; scope: Scope } {
const fsImpl: FsLike = deps.fs ?? defaultFsImpl;
let readable = true;
try {
fsImpl.readdirSync(phaseDir);
} catch {
readable = false;
}
const verification = readVerificationStatus(phaseDir, {
fs: deps.fs,
phaseCleanCommitTimesMs: deps.phaseCleanCommitTimesMs,
runtime: deps.runtime,
phaseNumber: deps.phaseNumber,
convention: deps.convention,
});
return {
value: {
complete: verification.status === 'passed',
verification,
},
scope: readable ? SCOPE.COMPLETE : SCOPE.UNREADABLE,
};
}
/**
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
* emit via io.output().
*
* @param cwd - Current working directory (used to resolve phaseDirArg).
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
* @param raw - Whether to emit raw (non-JSON) output.
*/
function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void {
if (!phaseDirArg) {
error('phase directory required for verification.status');
return;
}
const phaseDir = path.resolve(cwd, phaseDirArg);
const result = readVerificationStatus(phaseDir, { runtime: resolveRuntime(cwd) });
output(result, raw);
}
/**
* CLI command handler: resolve which `*-VERIFICATION.md` in `phaseDirArg` is
* the phase's own report, via the shared `resolveVerificationFile` seam, and
* emit its absolute path.
*
* #3492 F3: the ONE seam shell callers (verify-work.md's writer, transition.md's
* awk reader) route through instead of hand-rolling `ls *-VERIFICATION.md |
* head -1` / an awk glob scan — both of which pick alphabetically-first and so
* diverge from every JS reader now pinned to the phase's own token.
*
* Emits `{ verification_file: "<absolute path>" | "" }` (empty when no
* candidate resolves, including an unreadable directory). `raw` emits the
* bare path string (possibly empty) so `VAR=$(msd_run query
* verification.resolve-file "$PHASE_DIR" --raw)` is directly assignable.
*
* @param cwd - Current working directory (used to resolve phaseDirArg).
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
* @param raw - Whether to emit raw (non-JSON) output.
*/
function cmdVerificationResolveFile(cwd: string, phaseDirArg: string | undefined, raw: boolean): void {
if (!phaseDirArg) {
error('phase directory required for verification.resolve-file');
return;
}
const phaseDir = path.resolve(cwd, phaseDirArg);
let verificationPath = '';
try {
const entries = fs.readdirSync(phaseDir);
const phaseDirName = path.basename(phaseDir);
const phaseToken = extractPhaseToken(phaseDirName);
const verificationFile = resolveVerificationFile(entries, { allowBare: true, phaseToken, phaseDirName });
if (verificationFile) {
verificationPath = path.join(phaseDir, verificationFile);
}
} catch {
verificationPath = '';
}
output({ verification_file: verificationPath }, raw, verificationPath);
}
/**
* #4623: parse the argv tokens after `verification.fingerprint <phaseDir>`
* into a covered-file list. The router hands over a raw positional slice,
* so every `--files`-style form other `msd-tools` verbs accept (`commit
* --files a b`, `docs/CLI-TOOLS.md`) used to reach `computeCoveredDigest`
* with the literal token `--files` — or an unsplit `"a,b"` — as a covered
* path, and the whole command failed closed with "a covered file is
* missing, unreadable, or escapes the project root". On the reporting
* project that message convinced two people the digest was permanently
* unrecomputable.
*
* Accepted, all equivalent and freely mixed:
* - bare positionals `a b` (the documented form, unchanged)
* - a single flag `--files a`
* - a comma-separated value `--files a,b` (also `--files=a,b`)
* - a repeated flag `--files a --files b`
*
* Only a `--files` VALUE is comma-split: a bare positional keeps its bytes,
* so the documented form's behaviour on a comma-bearing filename is
* unchanged. Any other `--flag` is an explicit usage error, never a path —
* a mis-typed flag must not fail as "file missing" again. (`--raw` never
* reaches here; the CLI entry point splices it out before routing.)
*/
function parseFingerprintFileArgs(tokens: readonly string[]): { files: string[] } | { error: string } {
const files: string[] = [];
const EMPTY_VALUE = '--files requires at least one path for verification.fingerprint (a path, or a comma-separated list)';
const splitList = (value: string): string[] =>
value
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token === '--files') {
const value = tokens[i + 1];
if (value === undefined || value.startsWith('--')) {
return { error: '--files requires a value for verification.fingerprint (a path, or a comma-separated list)' };
}
const list = splitList(value);
// An empty or all-comma value is a usage error, never a silent no-op —
// the caller would otherwise meet the generic zero-files error and go
// looking for a missing path.
if (list.length === 0) return { error: EMPTY_VALUE };
files.push(...list);
i++;
} else if (token.startsWith('--files=')) {
const list = splitList(token.slice('--files='.length));
if (list.length === 0) return { error: EMPTY_VALUE };
files.push(...list);
} else if (token.startsWith('--')) {
return {
error: `unknown flag ${token} for verification.fingerprint (covered files are bare positionals or --files <a[,b]>, repeatable)`,
};
} else {
files.push(token);
}
}
return { files };
}
/**
* CLI command handler (#4155): compute the covered-input fingerprint the
* verifier embeds in VERIFICATION.md frontmatter (`covered_files`,
* `covered_digest`). The verifier is an LLM agent, not a hashing engine —
* this command does the deterministic math so the agent only has to name
* the covered paths and copy the result into frontmatter.
*
* Emits `{ covered_files: <sorted deduped paths>, covered_digest: <digest> }`
* on success. A covered path that is missing, unreadable, or escapes the
* project root fails the WHOLE command (fail closed — a partial fingerprint
* would be worse than none): `error()` is called and nothing is emitted.
*
* @param cwd - Current working directory.
* @param phaseDirArg - Phase directory path (absolute or relative to cwd);
* its project root is the base covered paths resolve against.
* Must be an existing directory (#4623): with the
* phase dir omitted, the first covered file used to be
* taken as the phase dir and the rest hashed — a
* plausible digest over the wrong set, at exit 0.
* @param fileArgs - The argv tokens after the phase dir, parsed by
* `parseFingerprintFileArgs`: covered-input paths
* relative to the project root, bare or via `--files`.
* @param raw - Whether to emit raw (non-JSON) output: just the
* `covered_digest` string, so `VAR=$(msd_run query
* verification.fingerprint "$PHASE_DIR" ... --raw)` is
* directly assignable. `covered_files` is unambiguous
* from the caller's own input list in that mode, so
* only the computed digest needs a raw form.
*/
function cmdVerificationFingerprint(
cwd: string,
phaseDirArg: string | undefined,
fileArgs: readonly string[],
raw: boolean,
): void {
if (!phaseDirArg) {
error('phase directory required for verification.fingerprint');
return;
}
const phaseDir = path.resolve(cwd, phaseDirArg);
let phaseDirIsDir = false;
try {
phaseDirIsDir = fs.statSync(phaseDir).isDirectory();
} catch {
// not found → not a directory
}
if (!phaseDirIsDir) {
error(
`phase directory not found: ${phaseDirArg} — verification.fingerprint takes the phase directory first, then the covered files`,
);
return;
}
const parsed = parseFingerprintFileArgs(fileArgs);
if ('error' in parsed) {
error(parsed.error);
return;
}
const files = parsed.files;
if (files.length === 0) {
error('at least one covered file required for verification.fingerprint');
return;
}
const projectRoot = findProjectRoot(phaseDir);
// canonicalizeCoveredFiles here is for the emitted `covered_files` field —
// computeCoveredDigest canonicalizes its own `coveredFiles` argument
// internally too (it must, for callers like readVerificationStatus that
// pass raw, un-canonicalized frontmatter values), so passing an
// already-canonical list keeps that internal pass a cheap no-op rather
// than a second meaningfully different canonicalization.
const uniqueSorted = canonicalizeCoveredFiles(files);
const digest = computeCoveredDigest(projectRoot, uniqueSorted, FINGERPRINT_VERSION, { phaseDir });
if (digest === null) {
// #4623: name the one null that is NOT a bad path — a declaration made
// only of shared planning documents hashes nothing under v2, and the
// generic message below would send the caller looking for a missing file
// that is not missing. Discriminated AFTER the v2 attempt, and only when a
// v1 pass over the same list (which hashes, and therefore validates, every
// path) succeeds: an all-shared list with a missing or directory member is
// a bad path first, and gets the generic message.
const sharedRoots = sharedPlanningRoots(projectRoot, phaseDir);
if (
uniqueSorted.every((f) => isSharedPlanningDoc(f, sharedRoots)) &&
computeCoveredDigest(projectRoot, uniqueSorted, 1) !== null
) {
error(
`could not compute fingerprint — every covered file is a repo-wide planning document (direct children of ${sharedRoots.join(', ')} never enter the digest); declare the phase's own artifacts and implementation files`,
);
return;
}
error('could not compute fingerprint — a covered file is missing, unreadable, or escapes the project root');
return;
}
output({ covered_files: uniqueSorted, covered_digest: digest }, raw, digest);
}
export = {
VERIFIER_STATUSES,
VERIFICATION_ROUTING_TABLE,
STALE_REASONS,
canonicalizeCoveredFiles,
defaultPhaseCleanCommitTimesMs,
resolveVerificationFile,
resolveUatFile,
findStaleVerificationSummary,
readVerificationStatus,
isPhaseComplete,
cmdVerificationStatus,
cmdVerificationResolveFile,
computeCoveredDigest,
sharedPlanningRoots,
isSharedPlanningDoc,
parseFingerprintVersion,
parseFingerprintFileArgs,
cmdVerificationFingerprint,
};