Files
msd-core/src/verify-command-grounding.cts
Tom Boucher 79781e68eb enhance(#2401): ground verify-command paths and inherit prior-phase commands (#3678)
* feat(#2401): ground <automated> verify-command paths and inherit prior-phase commands

Adds a deterministic resolvability probe over each PLAN.md <automated> verify
command and surfaces the nearest prior phase's proven commands to the planner
at every context window.

- src/verify-command-grounding.cts: recognizer (not a shell interpreter) that
  grounds a leading cd <literal> chain and npm --prefix <literal>, and reports
  unresolvable rather than guessing. Never executes command text.
- gsd-tools check verify-command-paths <N>: per-phase probe, wired into
  plan-phase.md before the plan-check pass.
- init.plan-phase gains prior_verify_commands, ungated by context_window.
- gsd-plan-checker: new Verify Command Path Resolvability dimension that
  reports the failing target and never prescribes a replacement.

Also fixes first-match-wins prefix bucketing in scripts/lint-test-file-count.cjs
(readdir order is not stable across platforms, so a module whose name extends
another's with a hyphen bucketed differently on Linux than on macOS).

Closes #2401

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

* fix(#2401): ground the canonical --prefix form, quoted paths, and absolute cd resets

Independent review found three defects in the recognizer:

- npm --prefix DIR run SCRIPT never reached the script-existence check,
  because the pattern required npm and run to be adjacent. That is the
  form the docs tell planners to prefer, so script_missing never fired
  for it. The prefix flag and its value are now stripped before matching.
- --prefix captured with \S+, so a quoted path containing a space was
  truncated to a stray opening quote and reported as a missing directory
  - a false blocker, worse than the bug this feature fixes. The capture
  is now quote-aware.
- A chained cd whose later segment was absolute concatenated instead of
  resetting, producing a nonsense path and another false blocker. The
  fold now resets on an absolute segment.

Also replaces the bespoke phase-directory regex with the canonical
phase-id helpers. Real phase directories are NN-slug, not phase-N-slug,
so the prior-command harvest matched nothing outside its own fixtures
and the planner-inheritance half of this feature was dead code.

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

* refactor(#2401): source task blocks from the canonical sectionizer

The module carried its own copy of the <task>-block grammar - a fourth
hand-rolled mirror of the one markdown-sectionizer owns. verify.cts keeps
its copy only because it needs the type= attribute the canonical helper
discards; this module never reads that attribute, so it can share the
owner outright instead of adding a test around a copy.

extractAutomatedCommands now takes task bodies from extractTaggedBlocks
and the out-of-task remainder from stripTaggedBlocks. A task-grammar
parity test pins the attributed task-name set against the canonical
helper across six awkward task shapes.

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

* fix(#2401): extract agent-file overflow to references and repair the property arbitrary

The remote matrix run came back red with 19 failures, four root causes:

- agents/gsd-plan-checker.md and agents/gsd-planner.md both blew the
  49152 agent cap. Their bodies move to gsd-core/references/, leaving
  @-reference stubs, per the documented overflow pattern.
- The new checker dimension invoked gsd_run before the canonical
  preamble that defines it. The call is deleted outright: plan-phase.md
  already runs the probe and hands the result in as {VERIFY_PATHS}, so
  the dimension consumes that rather than re-running anything.
- fc.fullUnicodeString does not exist in fast-check 4.8.0. Replaced with
  fc.string({ unit: 'binary' }), which covers the same 0000-10FFFF range.
- Three runtime-loaded files grew; acknowledged in the existing ack
  fragments that already own those bare filenames, since two ack sources
  may never name the same path.

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

* test(#2401): regenerate golden install-tree fixtures for the new references

Adding two files under gsd-core/references/ changes what the installer
emits into every runtime's tree, so all 19 golden install-parity
fixtures went stale. Regenerated with npm run gen:install-tree; the
delta is exactly the two new reference paths per runtime, no removals.

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

* chore(#2401): backfill changeset pr number to 3678

* fix(#2401): treat ~ as a home expansion only at the start of a path

Windows CI caught this on both shards; the Linux-only remote matrix
cannot see it. The dynamic-path refusal rejected ~ anywhere, and a
GitHub Windows runner's tmpdir is an 8.3 short name -
C:\Users\RUNNER~1\AppData\Local\Temp - so a valid absolute Windows
path came back unresolvable/dynamic_path.

This was a production bug, not a test artifact: any Windows user whose
project path carries an 8.3 short name, or any literal ~, silently lost
the probe entirely - every command degrading to unresolvable with no
explanation.

~ is a home expansion only at the start of a path; elsewhere it is an
ordinary literal. The check is now split: $, backtick, *, ? and newline
stay refused anywhere (substitution and globs, and the glob characters
are illegal in Windows path components regardless), while ~ is refused
only leading, tolerating one leading quote since the check runs before
quote stripping.

The prior tests only caught this on Windows because only Windows puts a
~ in tmpdir. Four new tests pin it on every platform via a fixture
directory literally named RUNNER~1.

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-08-19 15:21:15 -04:00

755 lines
25 KiB
TypeScript

/**
* Verify-command grounding probe (#2401).
*
* #2401: a planner authored `<automated>cd ../../frontend && npm run lint</automated>`
* whose target did not resolve from the executor's cwd, and the plan-checker —
* lacking a deterministic probe — hand-reasoned the filesystem and prescribed two
* successively-wrong replacement paths.
*
* This module answers "can this `<automated>` verify command's target directory be
* grounded?" WITHOUT ever executing the command. PLAN.md is LLM-authored untrusted
* text, so this module never `exec`s, `spawn`s, or otherwise shells out — it reads
* only via `fs.statSync`, `fs.existsSync`, `fs.readFileSync`, `fs.readdirSync`.
*
* It is a RECOGNIZER, not a shell interpreter (deliberate, per Greenspun's Tenth
* Rule — the fix for #2401 is refusing to guess, not writing a bigger shell
* parser): exactly two forms are grounded (`cd <literal>` and
* `npm --prefix <literal>`), and anything this probe cannot ground returns
* `unresolvable`, which is a warning and never a blocker.
*
* A bare ancestor climb (`cd ../..`, no trailing named segment) is genuinely
* ambiguous under parallel-worktree execution — the checker's root and the
* executor's root differ, so "my grandparent directory" cannot be asserted
* about, and it is reported `outside_root` without touching the filesystem.
* A climb that names a concrete sibling (`cd ../../frontend`, the exact #2401
* shape) still names something checkable, so it is resolved and probed like any
* other target.
*/
import fs from 'node:fs';
import path from 'node:path';
import { extractTaggedBlocks, stripTaggedBlocks } from './markdown-sectionizer.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
import phaseIdMod = require('./phase-id.cjs');
const { stripProjectCodePrefix, extractPhaseToken, comparePhaseNum } = phaseIdMod;
// ─── Types ────────────────────────────────────────────────────────────────────
type VerifyCommandStatus = 'ok' | 'broken' | 'unresolvable' | 'not_applicable' | 'pending_creation';
type VerifyCommandSeverity = 'blocker' | 'warning' | 'none';
type VerifyCommandReason =
| 'missing_dir'
| 'no_manifest'
| 'script_missing'
| 'dynamic_path'
| 'outside_root'
| 'manifest_unreadable'
| null;
type VerifyCommandForm = 'cd' | 'prefix' | null;
interface AutomatedCommand {
plan: string;
task: string;
command: string;
}
interface ResolveOptions {
projectRoot?: string;
declaredPaths?: string[];
}
interface ResolvedVerifyCommand {
command: string;
status: VerifyCommandStatus;
severity: VerifyCommandSeverity;
reason: VerifyCommandReason;
form: VerifyCommandForm;
rawTarget: string | null;
target: string | null;
manifest: string | null;
script: string | null;
sentinel: boolean;
base: string;
}
interface ProbedVerifyCommand extends ResolvedVerifyCommand {
plan: string;
task: string;
}
interface ProbeCounts {
blocker: number;
warning: number;
total: number;
}
interface ProbePhaseResult {
status: VerifyCommandStatus;
commands: ProbedVerifyCommand[];
counts: ProbeCounts;
readError: string | null;
}
interface ProbePhaseOptions {
phaseDir: string;
projectRoot: string;
}
interface HarvestedCommand {
phase: string;
plan: string;
task: string;
command: string;
}
interface HarvestResult {
commands: HarvestedCommand[];
readError: string | null;
}
interface HarvestOptions {
planningDir: string;
/**
* A phase-id token (`phase-id.cjs`'s grammar) or a plain number. Accepting
* either lets callers pass a decimal/lettered token (`'2.1'`, `'12A'`)
* without lossy `Number()` coercion; a plain number is stringified before
* comparison via `comparePhaseNum`.
*/
beforePhase: number | string;
limit?: number;
lookback?: number;
}
// ─── Extraction ───────────────────────────────────────────────────────────────
const TASK_NAME_RE = /<name>([\s\S]*?)<\/name>/;
const AUTOMATED_BLOCK_RE = /<automated[^>]{0,200}>([\s\S]*?)<\/automated>/g;
/**
* Bounds walking pathological input (e.g. hundreds of unclosed `<automated>`
* openers). `extractTaggedBlocks`/`stripTaggedBlocks` (task-block grammar
* owner, `./markdown-sectionizer.cjs`) already use a ReDoS-safe
* stop-at-next-open pattern with no separate iteration cap of their own — a
* document full of unclosed `<task>` openers never matches, so their walk is
* a single linear scan regardless. This guard only bounds the `<automated>`
* scan this module still runs directly, matching the pre-existing behavior.
*/
const MAX_BLOCK_WALK = 20000;
/**
* Run `AUTOMATED_BLOCK_RE` over `text`, pushing `{plan: '', task, command}`
* for each non-empty trimmed block onto `out`, in order. Shares `guard`
* across every caller in one `extractAutomatedCommands` invocation so the
* MAX_BLOCK_WALK bound applies to the WHOLE document's `<automated>` count,
* not per task body. Returns `false` when the bound was hit (caller stops
* walking further task bodies immediately); `true` otherwise.
*/
function extractAutomatedFromText(
text: string,
task: string,
out: AutomatedCommand[],
guard: { n: number },
): boolean {
AUTOMATED_BLOCK_RE.lastIndex = 0;
let aMatch: RegExpExecArray | null;
while ((aMatch = AUTOMATED_BLOCK_RE.exec(text)) !== null) {
const raw = (aMatch[1] ?? '').trim();
if (raw !== '') out.push({ plan: '', task, command: raw });
if (aMatch.index === AUTOMATED_BLOCK_RE.lastIndex) AUTOMATED_BLOCK_RE.lastIndex += 1;
guard.n += 1;
if (guard.n > MAX_BLOCK_WALK) return false;
}
return true;
}
/**
* Extract every `<automated>…</automated>` command from PLAN.md text,
* attaching the owning `<task><name>` when the block sits inside a
* `<task>…</task>`. Never throws; a non-string or blank plan yields `[]`.
* `plan` is left `''` — callers (`probePhaseVerifyCommands`,
* `harvestPriorVerifyCommands`) set it from the filename they read.
*
* Task-block bodies are obtained from the canonical sectionizer
* (`extractTaggedBlocks`/`stripTaggedBlocks`, `./markdown-sectionizer.cjs`)
* rather than a fourth hand-rolled `<task>` grammar copy (review finding,
* generative fix divergence class). This module only needs each task's
* `<name>` and its `<automated>` blocks — never the opening tag's `type=`
* attribute — so, unlike `src/verify.cts`'s `PLAN_TASK_BLOCK_RE` (which keeps
* its own copy specifically to read `type=`), it can share the owner
* outright. Ordering: every in-task command is emitted first, task by task
* in document order (`extractTaggedBlocks` returns bodies in document
* order); every command sitting OUTSIDE any `<task>` is emitted after, in
* the order it appears in the task-stripped remainder. No existing caller or
* test depends on interleaving an outside-task block between two in-task
* blocks that surround it in the raw document.
*/
function extractAutomatedCommands(planText: unknown): AutomatedCommand[] {
if (typeof planText !== 'string' || planText.length === 0) return [];
const out: AutomatedCommand[] = [];
const guard = { n: 0 };
const taskBodies = extractTaggedBlocks(planText, 'task', true);
for (const body of taskBodies) {
const nameMatch = TASK_NAME_RE.exec(body);
const task = nameMatch ? nameMatch[1].trim() : '';
if (!extractAutomatedFromText(body, task, out, guard)) return out;
}
const remainder = stripTaggedBlocks(planText, 'task', true);
extractAutomatedFromText(remainder, '', out, guard);
return out;
}
// ─── Resolution ───────────────────────────────────────────────────────────────
/**
* `$`, backtick, `*`, `?`, or a newline — a path this recognizer refuses to
* guess at anywhere in the string. `$`/backtick are substitution, `*`/`?` are
* shell globs (and illegal in Windows path components regardless), and a
* newline is never a valid single path.
*/
const DYNAMIC_PATH_ANYWHERE_RE = /[$`*?\n]/;
/**
* A LEADING `~` is shell home-expansion (`~/web`, `~user/web`) and is refused
* as dynamic; the dynamic check runs BEFORE quote stripping (so `cd
* "$FRONTEND"` is still caught), so this tolerates one leading quote char
* before the `~`. A `~` anywhere else in a path is an ordinary literal
* character — e.g. Windows 8.3 short names like `RUNNER~1` — and must resolve
* normally rather than being refused (#2401 CI regression).
*/
const DYNAMIC_PATH_LEADING_TILDE_RE = /^["']?~/;
const CD_SEGMENT_RE = /^cd\s+(.+)$/;
/**
* Quote-aware `--prefix` value capture (#2401 review Finding 2): a bare
* `\S+` capture truncates a quoted path containing a space (`--prefix "my
* dir"` → `"my`). Alternation order is double-quoted, single-quoted,
* unquoted — the surrounding quote pair (if any) is removed downstream by
* the existing `stripQuotes`.
*/
const PREFIX_FLAG_RE = /(?:^|\s)--prefix(?:=|\s+)("[^"]*"|'[^']*'|\S+)/;
/** Same value grammar as `PREFIX_FLAG_RE`, `g`-flagged for stripping (Finding 1). */
const PREFIX_FLAG_STRIP_RE = /(?:^|\s)--prefix(?:=|\s+)(?:"[^"]*"|'[^']*'|\S+)/g;
const NEEDS_NPM_RE = /^(npm|npx|pnpm|yarn|bun)\b/;
const NEEDS_MAKE_RE = /^make\b/;
const NPM_RUN_SCRIPT_RE = /\bnpm\s+run\s+([\w:@./-]+)/;
const MISSING_SENTINEL_RE = /^MISSING\b/;
const MAX_MANIFEST_BYTES = 512 * 1024;
function emptyResult(base: string): ResolvedVerifyCommand {
return {
command: '',
status: 'not_applicable',
severity: 'none',
reason: null,
form: null,
rawTarget: null,
target: null,
manifest: null,
script: null,
sentinel: false,
base,
};
}
/** Split on `&&`, `||`, `;`, and newlines; trim each segment; drop empties. No quote-awareness. */
function splitSegments(cmd: string): string[] {
return cmd
.split(/&&|\|\||;|\n/)
.map(s => s.trim())
.filter(s => s.length > 0);
}
/** Strip a single matching pair of surrounding quotes, if present. */
function stripQuotes(s: string): string {
if (s.length >= 2) {
const first = s[0];
const last = s[s.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return s.slice(1, -1);
}
}
return s;
}
/** Backslash → forward-slash, applied unconditionally (backslash paths arrive on Linux too). */
function toSlash(s: string): string {
return s.replace(/\\/g, '/');
}
/**
* `NPM_RUN_SCRIPT_RE` requires `npm` and `run` adjacent, so `npm --prefix
* ./web run lint` (the form this feature's own docs prefer) never matches
* (#2401 review Finding 1). Strip the `--prefix <value>` / `--prefix=<value>`
* flag (either ordering, quote-aware) before running the script-name match.
*/
function stripPrefixFlag(s: string): string {
return s.replace(PREFIX_FLAG_STRIP_RE, ' ');
}
/** Is `raw` (after the same quote-stripping/slash-normalization used elsewhere) an absolute path? */
function isAbsoluteCdSegment(raw: string): boolean {
return path.isAbsolute(toSlash(stripQuotes(raw)));
}
/**
* Fold chained `cd` segments left-to-right with absolute-reset semantics
* (#2401 review Finding 3): a relative segment appends onto the
* accumulator; an absolute segment discards everything accumulated so far
* and becomes the new accumulator (matching real shell `cd` semantics —
* `cd sub && cd /abs/path` ends up at `/abs/path`, not `sub//abs/path`).
* A single segment (the overwhelmingly common case) always returns that
* segment verbatim, byte-identical to the pre-fix `cdArgs[0]` behavior.
*/
function foldCdArgs(args: string[]): string {
let acc = '';
for (const raw of args) {
if (acc === '' || isAbsoluteCdSegment(raw)) {
acc = raw;
} else {
acc = `${acc}/${raw}`;
}
}
return acc;
}
function stripLeadingDotSlash(s: string): string {
return s.replace(/^\.\//, '');
}
/**
* A relative escape whose every segment is `..` (a bare ancestor climb, e.g.
* `cd ../..`) is inherently ambiguous across worktrees — flagged `outside_root`
* without touching the filesystem. A climb that names a concrete sibling (e.g.
* `cd ../../frontend`, the exact #2401 shape) still names something checkable
* and falls through to the normal filesystem probe below.
*/
function isPureAncestorClimb(rel: string): boolean {
if (rel.length === 0) return false;
const segs = rel.split(/[\\/]/).filter(s => s.length > 0);
return segs.length > 0 && segs.every(s => s === '..');
}
function declaredPathCovers(declaredPaths: string[] | undefined, norm: string): boolean {
if (!Array.isArray(declaredPaths)) return false;
const target = stripLeadingDotSlash(norm);
return declaredPaths.some(p => {
if (typeof p !== 'string') return false;
const dp = stripLeadingDotSlash(toSlash(p));
return dp === target || dp.startsWith(target + '/');
});
}
/**
* Read a package.json manifest bounded by size and guarded end-to-end; never
* throws. Returns `null` when the manifest cannot be read/parsed/shaped, or
* the parsed value when it is usable.
*/
function readManifestObject(manifestPath: string): Record<string, unknown> | null {
let size = 0;
try {
size = fs.statSync(manifestPath).size;
} catch {
return null;
}
if (size > MAX_MANIFEST_BYTES) return null;
let raw: string;
try {
raw = fs.readFileSync(manifestPath, 'utf-8');
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
}
/**
* Resolve a single `<automated>` command's target directory WITHOUT ever
* executing it. Every `fs` call is individually guarded, so this function
* never throws for any input — including a bare call with no options.
*/
function resolveVerifyCommandTarget(command: unknown, options?: ResolveOptions): ResolvedVerifyCommand {
const opts = options && typeof options === 'object' ? options : {};
const base = typeof opts.projectRoot === 'string' && opts.projectRoot !== '' ? opts.projectRoot : process.cwd();
const result = emptyResult(base);
if (typeof command !== 'string') return result;
result.command = command;
const trimmed = command.trim();
if (trimmed === '') return result;
// Nyquist "MISSING — Wave 0 must create …" sentinel; Dimension 8 owns it.
if (MISSING_SENTINEL_RE.test(trimmed)) {
result.sentinel = true;
return result;
}
const segments = splitSegments(trimmed);
let form: VerifyCommandForm = null;
let rawTarget: string | null = null;
let rest: string;
const cdArgs: string[] = [];
let i = 0;
while (i < segments.length) {
const m = CD_SEGMENT_RE.exec(segments[i]);
if (!m) break;
cdArgs.push(m[1].trim());
i += 1;
}
if (cdArgs.length > 0) {
form = 'cd';
rawTarget = foldCdArgs(cdArgs);
rest = segments.slice(i).join(' && ');
} else {
const prefixMatch = PREFIX_FLAG_RE.exec(trimmed);
if (!prefixMatch) return result; // not_applicable — neither form matched
form = 'prefix';
rawTarget = prefixMatch[1];
rest = trimmed;
}
// Dynamic-path refusal, tested against rawTarget BEFORE any unquoting.
if (DYNAMIC_PATH_ANYWHERE_RE.test(rawTarget) || DYNAMIC_PATH_LEADING_TILDE_RE.test(rawTarget)) {
result.status = 'unresolvable';
result.reason = 'dynamic_path';
result.severity = 'warning';
result.form = form;
result.rawTarget = rawTarget;
return result;
}
const norm = toSlash(stripQuotes(rawTarget));
const isAbs = path.isAbsolute(norm);
const target = isAbs ? path.normalize(norm) : path.resolve(base, norm);
result.form = form;
result.rawTarget = rawTarget;
result.target = target;
if (!isAbs) {
const rel = path.relative(base, target);
if (isPureAncestorClimb(rel)) {
result.status = 'ok';
result.severity = 'warning';
result.reason = 'outside_root';
return result;
}
}
let stat: fs.Stats | undefined;
try {
stat = fs.statSync(target, { throwIfNoEntry: false });
} catch {
stat = undefined;
}
if (!stat || !stat.isDirectory()) {
if (declaredPathCovers(opts.declaredPaths, norm)) {
result.status = 'pending_creation';
result.severity = 'none';
result.reason = null;
} else {
result.status = 'broken';
result.reason = 'missing_dir';
result.severity = 'blocker';
}
return result;
}
const restTrimmed = rest.trim();
let neededManifest: 'package.json' | 'Makefile' | null = null;
if (NEEDS_NPM_RE.test(restTrimmed)) neededManifest = 'package.json';
else if (NEEDS_MAKE_RE.test(restTrimmed)) neededManifest = 'Makefile';
if (!neededManifest) {
result.status = 'ok';
result.severity = 'none';
return result;
}
const manifestPath = path.join(target, neededManifest);
let manifestExists = false;
try {
manifestExists = fs.existsSync(manifestPath);
} catch {
manifestExists = false;
}
if (!manifestExists) {
result.status = 'broken';
result.reason = 'no_manifest';
result.severity = 'blocker';
return result;
}
result.manifest = neededManifest;
if (neededManifest === 'Makefile') {
result.status = 'ok';
result.severity = 'none';
return result;
}
const parsed = readManifestObject(manifestPath);
if (!parsed) {
result.status = 'ok';
result.reason = 'manifest_unreadable';
result.severity = 'warning';
return result;
}
const scriptMatch = NPM_RUN_SCRIPT_RE.exec(stripPrefixFlag(restTrimmed));
if (scriptMatch) {
const scriptName = scriptMatch[1];
result.script = scriptName;
const scripts = parsed['scripts'];
const hasScript =
scripts !== null &&
typeof scripts === 'object' &&
!Array.isArray(scripts) &&
Object.prototype.hasOwnProperty.call(scripts, scriptName);
if (!hasScript) {
result.status = 'ok';
result.reason = 'script_missing';
result.severity = 'warning';
return result;
}
}
result.status = 'ok';
result.severity = 'none';
return result;
}
// ─── Phase probing ────────────────────────────────────────────────────────────
const PLAN_FILE_RE = /-PLAN\.md$/i;
const ARTIFACTS_HEADING_RE = /^##[ \t]+Artifacts this phase produces\s*$/m;
const NEXT_HEADING_RE = /^##[ \t]+/m;
const FILES_BLOCK_RE = /<files>([\s\S]*?)<\/files>/g;
const ARTIFACT_BULLET_RE = /^-[ \t]+(.+)$/gm;
function toMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Every `<files>…</files>` body (split on commas/whitespace/newlines) plus
* every `- ` bullet under a `## Artifacts this phase produces` heading (up to
* the next `## ` heading), scanned across the WHOLE plan text.
*/
function extractDeclaredPaths(text: string): string[] {
const out: string[] = [];
FILES_BLOCK_RE.lastIndex = 0;
let fMatch: RegExpExecArray | null;
while ((fMatch = FILES_BLOCK_RE.exec(text)) !== null) {
const body = fMatch[1] ?? '';
for (const tok of body.split(/[,\s]+/)) {
const t = tok.trim();
if (t) out.push(t);
}
}
const headingMatch = ARTIFACTS_HEADING_RE.exec(text);
if (headingMatch) {
const after = text.slice(headingMatch.index + headingMatch[0].length);
const nextIdx = after.search(NEXT_HEADING_RE);
const section = nextIdx === -1 ? after : after.slice(0, nextIdx);
ARTIFACT_BULLET_RE.lastIndex = 0;
let bMatch: RegExpExecArray | null;
while ((bMatch = ARTIFACT_BULLET_RE.exec(section)) !== null) {
const t = (bMatch[1] ?? '').trim();
if (t) out.push(t);
}
}
return out;
}
const STATUS_PRIORITY: VerifyCommandStatus[] = ['broken', 'unresolvable', 'pending_creation', 'ok', 'not_applicable'];
/**
* Probe every `<automated>` command in a phase directory's `-PLAN.md` files
* against the filesystem. Never throws — a read failure degrades to
* `readError` with the offending file skipped.
*/
function probePhaseVerifyCommands(options: ProbePhaseOptions): ProbePhaseResult {
const { phaseDir, projectRoot } = options;
let entries: string[];
try {
entries = fs.readdirSync(phaseDir);
} catch (err) {
return {
status: 'unresolvable',
commands: [],
counts: { blocker: 0, warning: 0, total: 0 },
readError: toMessage(err),
};
}
const planFiles = entries.filter(f => PLAN_FILE_RE.test(f)).sort();
const commands: ProbedVerifyCommand[] = [];
const readErrors: string[] = [];
for (const file of planFiles) {
let text: string;
try {
text = fs.readFileSync(path.join(phaseDir, file), 'utf-8');
} catch (err) {
readErrors.push(toMessage(err));
continue;
}
const declaredPaths = extractDeclaredPaths(text);
const extracted = extractAutomatedCommands(text);
for (const cmd of extracted) {
const resolved = resolveVerifyCommandTarget(cmd.command, { projectRoot, declaredPaths });
commands.push({ ...resolved, plan: file, task: cmd.task });
}
}
const counts: ProbeCounts = { blocker: 0, warning: 0, total: commands.length };
for (const c of commands) {
if (c.severity === 'blocker') counts.blocker += 1;
else if (c.severity === 'warning') counts.warning += 1;
}
let status: VerifyCommandStatus = 'ok';
if (commands.length > 0) {
const present = new Set(commands.map(c => c.status));
status = STATUS_PRIORITY.find(s => present.has(s)) ?? 'ok';
}
return {
status,
commands,
counts,
readError: readErrors.length > 0 ? readErrors.join('; ') : null,
};
}
// ─── Prior-phase harvesting ───────────────────────────────────────────────────
const DEFAULT_LIMIT = 20;
const DEFAULT_LOOKBACK = 3;
/**
* Walk backward from `beforePhase` (descending, at most `lookback` phase
* directories) looking for the nearest prior phase whose plans carry any
* `<automated>` command. Returns that phase's commands, deduped by command
* text (first-seen order) and capped at `limit`. Never throws.
*
* Phase directories are enumerated and ordered via the canonical grammar in
* `phase-id.cjs` (#2401 review fix) rather than a bespoke `phase-N-slug`
* regex — real GSD phase directories are `01-foundation`, `3-thing`,
* `2.1-thing`, `12A-thing`, or project-code-prefixed (`CK-01-name`), never
* `phase-N-slug`. A directory name is treated as a phase directory only when
* it (after stripping an optional project-code prefix) starts with a digit;
* `notes`, `archive`, etc. are ignored. `extractPhaseToken` reads each
* directory's phase token and `comparePhaseNum` both filters (`< beforePhase`)
* and orders (descending) so decimal/lettered/sentinel tokens (`2.1`, `12A`,
* `999.1`) compare correctly instead of via lossy `Number()` parsing.
*/
function harvestPriorVerifyCommands(options: HarvestOptions): HarvestResult {
const { planningDir, beforePhase, limit = DEFAULT_LIMIT, lookback = DEFAULT_LOOKBACK } = options;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(planningDir, { withFileTypes: true });
} catch (err) {
return { commands: [], readError: toMessage(err) };
}
const beforePhaseStr = String(beforePhase);
const candidates: Array<{ token: string; dir: string }> = [];
for (const ent of entries) {
let isDir = false;
try {
isDir = ent.isDirectory();
} catch {
isDir = false;
}
if (!isDir) continue;
// Not a phase directory at all (e.g. 'notes', 'archive') — no reliable
// phase token can be read from a name that doesn't start with a digit
// once any project-code prefix ('CK-', 'PROJ-') is stripped.
if (!/^\d/.test(stripProjectCodePrefix(ent.name))) continue;
const token = extractPhaseToken(ent.name);
if (comparePhaseNum(token, beforePhaseStr) < 0) {
candidates.push({ token, dir: ent.name });
}
}
candidates.sort((a, b) => comparePhaseNum(b.token, a.token));
const readErrors: string[] = [];
let examined = 0;
for (const candidate of candidates) {
if (examined >= lookback) break;
examined += 1;
const phaseDirPath = path.join(planningDir, candidate.dir);
let planFiles: string[];
try {
planFiles = fs.readdirSync(phaseDirPath).filter(f => PLAN_FILE_RE.test(f)).sort();
} catch (err) {
readErrors.push(toMessage(err));
continue;
}
const seen = new Set<string>();
const found: HarvestedCommand[] = [];
for (const file of planFiles) {
let text: string;
try {
text = fs.readFileSync(path.join(phaseDirPath, file), 'utf-8');
} catch (err) {
readErrors.push(toMessage(err));
continue;
}
for (const cmd of extractAutomatedCommands(text)) {
if (seen.has(cmd.command)) continue;
seen.add(cmd.command);
found.push({
phase: candidate.token,
plan: path.join(phaseDirPath, file),
task: cmd.task,
command: cmd.command,
});
}
}
if (found.length > 0) {
return {
commands: found.slice(0, limit),
readError: readErrors.length > 0 ? readErrors.join('; ') : null,
};
}
}
return { commands: [], readError: readErrors.length > 0 ? readErrors.join('; ') : null };
}
export = {
extractAutomatedCommands,
resolveVerifyCommandTarget,
probePhaseVerifyCommands,
harvestPriorVerifyCommands,
};