fix(#3357): one phase-pinned resolver for verification-report discovery (#3513)

A phase directory can hold more than one `*-VERIFICATION.md` — an ad-hoc `03-CORRECTION-VERIFICATION.md` worksheet beside the real `03-VERIFICATION.md`. Discovery took the alphabetically-first match, so the worksheet won and the phase could report `missing` while a passing report sat next to it.

The issue named two copies. There were seven, in four grammars: two `.sort()[0]` sites in the verification module, three `.find()` over UNSORTED readdir order (phase status, and `verification_path` twice — filesystem-dependent, so two machines on one commit could disagree), and two in shell. All seven now route through one exported `resolveVerificationFile`; the shell copies via a new `verification resolve-file` verb rather than hand-rolling the rule an eighth time.

Fixing five of seven would have been worse than fixing none: the verify-work workflow is a WRITER that stamps `status: passed` onto the file it picks, so canonical-aware readers plus an alphabetical writer means the human_needed→passed canonicalization silently no-ops forever while the worksheet gets stamped. That divergence did not exist on next.

The first resolver was itself a regression — it preferred ANY canonically-shaped name over the phase's own report, so a stray cross-phase or sentinel-numbered file outranked it. The global-canonical preference was removed rather than narrowed; the rule is pinned to the phase token via `PHASE_NUMBER_TOKEN_SOURCE`, its existing owner.

Fixed in passing: the transition workflow's awk guarded on `NR==1` instead of `FNR==1`, so across a multi-file glob it armed only on the first file — a leading worksheet with no frontmatter blocked transition even when the canonical report passed. Also removed a U+00AD soft hyphen introduced earlier on this branch.

Five broader-grammar AGGREGATE scans are deliberately out of scope — a different defect class (phase-unscoped scanning), tracked as #3511.

Closes #3357

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-14 19:18:29 -04:00
committed by GitHub
parent c66b010052
commit ddf852873c
13 changed files with 597 additions and 65 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3513
---
**A phase with more than one `*-VERIFICATION.md` no longer reports the wrong one** — verification-report discovery took the alphabetically-first match, so an ad-hoc worksheet such as `03-CORRECTION-VERIFICATION.md` beat the real `03-VERIFICATION.md` sitting beside it and the phase could report `missing` while a passing report existed. Three further copies of the same lookup picked whichever file the filesystem happened to list first, making phase status and the reported `verification_path` vary between machines. All five now share one resolver that prefers the canonically-named report and is deterministic when it has to fall back. (#3357)

View File

@@ -102,12 +102,22 @@ cat .planning/config.json 2>/dev/null || true
**Check for verification debt in this phase:** **Check for verification debt in this phase:**
```bash ```bash
# Run a preliminary frontmatter check via awk — the runtime launcher is not yet _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
# defined at this step, so avoid any runtime tool calls here. # #3492: resolve THIS phase's own report through the single shared seam
# (src/verification.cts resolveVerificationFile) instead of a blind
# `*-VERIFICATION.md` glob — a stray ad-hoc worksheet (e.g.
# `03-CORRECTION-VERIFICATION.md`) alphabetically outranks the real report
# and previously fed this awk parse the wrong file.
VERIFICATION_FILE=$(gsd_run query verification.resolve-file .planning/phases/XX-current --raw 2>/dev/null)
# awk extracts only the status: field between the two --- fences to avoid # awk extracts only the status: field between the two --- fences to avoid
# false positives from historical body text (e.g. previous_status: gaps_found). # false positives from historical body text (e.g. previous_status: gaps_found).
VERIFY_STATUS=$(awk 'NR==1&&/^---$/{in_fm=1;next}in_fm&&/^---$/{exit}in_fm&&/^status: /{print $2}' \ # FNR (not NR) re-arms the frontmatter scan per input file: NR only ever arms
.planning/phases/XX-current/*-VERIFICATION.md 2>/dev/null | head -1) # on the very FIRST line of the very first file, so a multi-file input would
# silently read empty status for every file after the first. The resolver
# above always hands back a single path, but the parse stays correct even if
# that ever changes.
VERIFY_STATUS=$(awk 'FNR==1&&/^---$/{in_fm=1;next}in_fm&&/^---$/{exit}in_fm&&/^status: /{print $2}' \
"$VERIFICATION_FILE" 2>/dev/null | head -1)
``` ```
**If VERIFY_STATUS is not `passed`:** **If VERIFY_STATUS is not `passed`:**
@@ -120,10 +130,10 @@ Verification incomplete: ${VERIFY_STATUS:-missing}
Resolve before transition. Review: `/gsd:audit-uat` Resolve before transition. Review: `/gsd:audit-uat`
``` ```
This preliminary check blocks obviously unresolved verification before the This preliminary check blocks obviously unresolved verification early, ahead
launcher is available. `gsd-tools.cjs query phase.complete` remains the of the authoritative gate below. `gsd-tools.cjs query phase.complete` (in
authoritative stale-aware gate and fail-closes unless canonical verification `update_roadmap_and_state`) remains the authoritative stale-aware gate and
status is `passed`. fail-closes unless canonical verification status is `passed`.
**If all plans complete:** **If all plans complete:**
@@ -190,7 +200,6 @@ If found, delete them — phase is complete, handoffs are stale.
**Delegate ROADMAP.md and STATE.md updates to `gsd-tools.cjs query phase.complete`:** **Delegate ROADMAP.md and STATE.md updates to `gsd-tools.cjs query phase.complete`:**
```bash ```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
TRANSITION=$(gsd_run query phase.complete "${current_phase}") TRANSITION=$(gsd_run query phase.complete "${current_phase}")
``` ```
@@ -442,7 +451,7 @@ ROADMAP=$(gsd_run query roadmap.analyze)
This returns all phases with goals, disk status, and completion info. This returns all phases with goals, disk status, and completion info.
**Section-manifest gate (#2994):** `gsd_run` is already established above (`update_roadmap_and_state` step) — fetch the dedicated `init.transition` bundle for the workstream-collision-check gate below: **Section-manifest gate (#2994):** `gsd_run` is already established above (`verify_completion` step) — fetch the dedicated `init.transition` bundle for the workstream-collision-check gate below:
```bash ```bash
INIT_TRANSITION=$(gsd_run query init.transition) INIT_TRANSITION=$(gsd_run query init.transition)

View File

@@ -570,7 +570,7 @@ If execution verification is waiting only on human UAT and this session recorded
```bash ```bash
PHASE_DIR=$(printf '%s' "$INIT" | jq -r '.phase_dir // empty') PHASE_DIR=$(printf '%s' "$INIT" | jq -r '.phase_dir // empty')
VERIFICATION_FILE=$(ls "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1) VERIFICATION_FILE=$(gsd_run query verification.resolve-file "$PHASE_DIR" --raw 2>/dev/null)
VERIFICATION_STATUS=$(gsd_run query verification.status "$PHASE_DIR" 2>/dev/null) VERIFICATION_STATUS=$(gsd_run query verification.status "$PHASE_DIR" 2>/dev/null)
VERIFICATION_STATUS_VALUE=$(printf '%s' "$VERIFICATION_STATUS" | jq -r '.status // empty' 2>/dev/null || echo "") VERIFICATION_STATUS_VALUE=$(printf '%s' "$VERIFICATION_STATUS" | jq -r '.status // empty' 2>/dev/null || echo "")
PHASE_VERIFICATION_STATUS="$VERIFICATION_STATUS_VALUE" PHASE_VERIFICATION_STATUS="$VERIFICATION_STATUS_VALUE"

View File

@@ -60,6 +60,9 @@ import { clampPercent } from './phase-lifecycle.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import planScanMod = require('./plan-scan.cjs'); import planScanMod = require('./plan-scan.cjs');
const { scanPhasePlans } = planScanMod; const { scanPhasePlans } = planScanMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
import verificationMod = require('./verification.cjs');
const { resolveVerificationFile } = verificationMod;
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -159,7 +162,15 @@ function determinePhaseStatus(plans: number, summaries: number, phaseDir: string
// summaries >= plans — check verification // summaries >= plans — check verification
try { try {
const files = fs.readdirSync(phaseDir); const files = fs.readdirSync(phaseDir);
const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md')); // #3473 F2: routed through the shared resolver (readdir order is
// filesystem-dependent, so the prior hand-rolled `.find()` could pick
// either file when a phase held both a canonical report and an ad-hoc
// `-CORRECTION-VERIFICATION.md` worksheet — see #3357).
// #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.
const phaseToken = extractPhaseToken(path.basename(phaseDir));
const verificationFile = resolveVerificationFile(files, { allowBare: true, phaseToken });
if (verificationFile) { if (verificationFile) {
const verificationFilePath = path.join(phaseDir, verificationFile); const verificationFilePath = path.join(phaseDir, verificationFile);
const content = platformReadSync(verificationFilePath) || ''; const content = platformReadSync(verificationFilePath) || '';

View File

@@ -89,7 +89,7 @@ const {
extractCurrentMilestone, extractCurrentMilestone,
} = roadmapParser; } = roadmapParser;
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils; const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId } = phaseId; const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken } = phaseId;
const { pruneOrphanedWorktrees } = worktreeSafety; const { pruneOrphanedWorktrees } = worktreeSafety;
const { const {
@@ -103,7 +103,7 @@ const {
const { determinePhaseStatus } = commandsMod; const { determinePhaseStatus } = commandsMod;
const { extractFrontmatter } = frontmatterMod; const { extractFrontmatter } = frontmatterMod;
const { isPhaseComplete } = verificationMod; const { isPhaseComplete, resolveVerificationFile } = verificationMod;
const { evaluateUatPassed } = uatPredicateMod; const { evaluateUatPassed } = uatPredicateMod;
const { resolveLoopHooks } = loopResolverMod; const { resolveLoopHooks } = loopResolverMod;
const { loadRegistry } = capabilityLoaderMod; const { loadRegistry } = capabilityLoaderMod;
@@ -1144,9 +1144,17 @@ function cmdInitPlanPhase(
if (researchFile) { if (researchFile) {
result['research_path'] = toPosixPath(path.join(phaseDirFull, researchFile)); result['research_path'] = toPosixPath(path.join(phaseDirFull, researchFile));
} }
const verificationFile = files.find( // #3473 F2: routed through the shared resolver — readdir order is
(f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md', // filesystem-dependent, so the prior hand-rolled `.find()` could pick
); // either file when a phase held both a canonical report and an ad-hoc
// `-CORRECTION-VERIFICATION.md` worksheet (#3357).
// #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.
const verificationFile = resolveVerificationFile(files, {
allowBare: true,
phaseToken: extractPhaseToken(path.basename(phaseDirFull)),
});
if (verificationFile) { if (verificationFile) {
result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile)); result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile));
} }
@@ -1969,9 +1977,17 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
if (researchFile) { if (researchFile) {
result['research_path'] = toPosixPath(path.join(phaseDirFull, researchFile)); result['research_path'] = toPosixPath(path.join(phaseDirFull, researchFile));
} }
const verificationFile = files.find( // #3473 F2: routed through the shared resolver — readdir order is
(f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md', // filesystem-dependent, so the prior hand-rolled `.find()` could pick
); // either file when a phase held both a canonical report and an ad-hoc
// `-CORRECTION-VERIFICATION.md` worksheet (#3357).
// #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.
const verificationFile = resolveVerificationFile(files, {
allowBare: true,
phaseToken: extractPhaseToken(path.basename(phaseDirFull)),
});
if (verificationFile) { if (verificationFile) {
result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile)); result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile));
} }

View File

@@ -17,6 +17,7 @@ const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
interface VerificationModule { interface VerificationModule {
cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void; cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void;
cmdVerificationResolveFile(cwd: string, phaseDirArg: string | undefined, raw: boolean): void;
} }
interface RouteVerificationCommandOptions { interface RouteVerificationCommandOptions {
@@ -29,7 +30,7 @@ interface RouteVerificationCommandOptions {
// ─── Implementation ─────────────────────────────────────────────────────────── // ─── Implementation ───────────────────────────────────────────────────────────
const VERIFICATION_SUBCOMMANDS = ['status']; const VERIFICATION_SUBCOMMANDS = ['status', 'resolve-file'];
function routeVerificationCommand({ function routeVerificationCommand({
verification, verification,
@@ -47,6 +48,7 @@ function routeVerificationCommand({
`Unknown verification subcommand. Available: ${available.join(', ')}`, `Unknown verification subcommand. Available: ${available.join(', ')}`,
handlers: { handlers: {
status: () => verification.cmdVerificationStatus(cwd, args[2], raw), status: () => verification.cmdVerificationStatus(cwd, args[2], raw),
'resolve-file': () => verification.cmdVerificationResolveFile(cwd, args[2], raw),
}, },
}); });
} }

View File

@@ -282,6 +282,88 @@ function missingResult(runtime: string, phaseArg: string): VerificationStatusRes
}; };
} }
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`) have 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
* the two callers in THIS module. Set `true` only from a call site whose
* pre-fix behavior already accepted a bare match.
*/
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 all dashed candidates (the original pre-#3357
* behavior), never to null.
*/
phaseToken?: string;
}
/**
* 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:
* 1. `options.phaseToken` given and `<phaseToken>-VERIFICATION.md` is among
* the candidates — that exact file always wins. This is THIS phase's own
* report; no other candidate (canonically-shaped or not, whichever
* phase's token it carries) can outrank it (#3492).
* 2. Fallback — no exact phase-token match (or no token given): alphabetically
* first of ALL `*-VERIFICATION.md` entries, unchanged from the original
* pre-#3357 behavior. Load-bearing: a phase whose only report is
* non-canonically named must keep resolving to it, not to null — this
* fix must not turn "found a report" into "found nothing" for anyone.
* 3. `options.allowBare` only — a bare `VERIFICATION.md`, 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 only when neither (1) nor (2) found
* any dashed candidate at all.
*
* 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 resolveVerificationFile(
entries: string[],
options: ResolveVerificationFileOptions = {},
): string | null {
const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort();
if (candidates.length > 0) {
if (options.phaseToken) {
const thisPhaseFile = `${options.phaseToken}-VERIFICATION.md`;
if (candidates.includes(thisPhaseFile)) return thisPhaseFile;
}
return candidates[0];
}
if (options.allowBare && entries.includes('VERIFICATION.md')) return 'VERIFICATION.md';
return null;
}
// ─── Public API ─────────────────────────────────────────────────────────────── // ─── Public API ───────────────────────────────────────────────────────────────
interface ReadVerificationStatusOptions { interface ReadVerificationStatusOptions {
@@ -337,7 +419,11 @@ function findStaleVerificationSummary(
// this function only reports what it actually knows. // this function only reports what it actually knows.
try { try {
const phaseFiles = fsImpl.readdirSync(phaseDir); const phaseFiles = fsImpl.readdirSync(phaseDir);
const verificationFile = phaseFiles.filter((f) => f.endsWith('-VERIFICATION.md')).sort()[0]; // #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.
const phaseToken = extractPhaseToken(path.basename(phaseDir));
const verificationFile = resolveVerificationFile(phaseFiles, { phaseToken });
if (!verificationFile) return { determined: true, stale: false }; if (!verificationFile) return { determined: true, stale: false };
const summaryFiles = (scanPhasePlans(phaseDir) as { summaryFiles: string[] }).summaryFiles const summaryFiles = (scanPhasePlans(phaseDir) as { summaryFiles: string[] }).summaryFiles
@@ -378,7 +464,9 @@ function findStaleVerificationSummary(
* phaseDir and return the routing result. * phaseDir and return the routing result.
* *
* Behavior: * Behavior:
* 1. Find the first file matching `*-VERIFICATION.md` (sorted, take first). * 1. Find the phase's verification report via `resolveVerificationFile`
* (canonical `<phase-token>-VERIFICATION.md` preferred; falls back to the
* alphabetically-first `*-VERIFICATION.md` when none is canonical — #3357).
* If none → status 'missing'. * If none → status 'missing'.
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter * 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0). * parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
@@ -422,8 +510,11 @@ function readVerificationStatus(
let verificationFile: string | null = null; let verificationFile: string | null = null;
try { try {
const entries = fsImpl.readdirSync(phaseDir); const entries = fsImpl.readdirSync(phaseDir);
const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort(); // #3492: pin selection to THIS phase's own token (already derived above
verificationFile = candidates.length > 0 ? candidates[0] : null; // for the routed command argument) so a stray cross-phase or
// sentinel-numbered canonically-shaped file cannot outrank this phase's
// own (possibly non-canonical) report.
verificationFile = resolveVerificationFile(entries, { phaseToken });
} catch { } catch {
// Directory unreadable → treat as missing // Directory unreadable → treat as missing
verificationFile = null; verificationFile = null;
@@ -600,12 +691,53 @@ function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw
output(result, raw); 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=$(gsd_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 phaseToken = extractPhaseToken(path.basename(phaseDir));
const verificationFile = resolveVerificationFile(entries, { allowBare: true, phaseToken });
if (verificationFile) {
verificationPath = path.join(phaseDir, verificationFile);
}
} catch {
verificationPath = '';
}
output({ verification_file: verificationPath }, raw, verificationPath);
}
export = { export = {
VERIFIER_STATUSES, VERIFIER_STATUSES,
VERIFICATION_ROUTING_TABLE, VERIFICATION_ROUTING_TABLE,
defaultPhaseCleanCommitTimesMs, defaultPhaseCleanCommitTimesMs,
resolveVerificationFile,
findStaleVerificationSummary, findStaleVerificationSummary,
readVerificationStatus, readVerificationStatus,
isPhaseComplete, isPhaseComplete,
cmdVerificationStatus, cmdVerificationStatus,
cmdVerificationResolveFile,
}; };

View File

@@ -2215,6 +2215,31 @@ describe('stats command', () => {
assert.strictEqual(stats.plan_percent, 67); assert.strictEqual(stats.plan_percent, 67);
}); });
// #3473 F2 (companion to #3357): determinePhaseStatus now resolves its
// *-VERIFICATION.md via the shared resolveVerificationFile resolver instead
// of a hand-rolled `.find()` over unsorted readdir() order. Before this fix,
// which of a canonical report and an ad-hoc `-CORRECTION-VERIFICATION.md`
// worksheet "won" was filesystem-dependent; the canonical report must now
// win deterministically regardless of directory-listing order.
test('#3473 F2: phase status resolves the canonical report over a -CORRECTION- worksheet, not readdir order', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '03-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '03-01-SUMMARY.md'), '# Summary');
// The ad-hoc worksheet reports gaps_found; if it won the pick, the phase
// would read 'Executed', not 'Complete'.
fs.writeFileSync(path.join(p1, '03-CORRECTION-VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Correction worksheet');
fs.writeFileSync(path.join(p1, '03-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
const phase = stats.phases.find(p => p.number === '03');
assert.ok(phase, 'phase 03 must be present in stats output');
assert.strictEqual(phase.status, 'Complete', 'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet');
});
test('counts requirements from REQUIREMENTS.md', () => { test('counts requirements from REQUIREMENTS.md', () => {
fs.writeFileSync( fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),

View File

@@ -1,8 +0,0 @@
{
"version": 1,
"paths": {
"transition.md": {
"reason": "#1526 (delegate, user decision 2026-08-13): added a `post_completion_mode` step at the top of <process> so transition.md is reusable when invoked by execute-phase AFTER phase.complete + verification already ran. The mode documents two invocations — standalone (run all steps) and post-completion delegation (SKIP verify_completion + update_roadmap_and_state to avoid a double phase.complete; RUN cleanup_handoff; BEGIN at evolve_project). Growth (~1.4 KB) is this one new step; no other step changed. (execute-phase.md, also touched by this PR, shrank slightly — its inline update_project_md + offer_next were replaced by a shorter delegation step — so it needs no ack.)"
}
}
}

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"paths": {
"transition.md": {
"reason": "#3357: verify_completion now resolves THIS phase's own verification report through the shared `verification.resolve-file` seam (src/verification.cts resolveVerificationFile) instead of a blind `*-VERIFICATION.md` glob feeding awk directly, so a stray ad-hoc worksheet (e.g. `03-CORRECTION-VERIFICATION.md`) can no longer alphabetically outrank the real report. Growth is +632 bytes (23,247 -> 23,879): the `gsd_run query verification.resolve-file` call plus its comment, and the FNR-vs-NR frontmatter re-arm fix (with rationale comment) for correctness across the resolver's single-path result. The canonical runtime-launcher preamble was relocated (not duplicated) from `update_roadmap_and_state` up to `verify_completion` since that step now needs `gsd_run` earlier in document order; net preamble count is unchanged at exactly one."
},
"verify-work.md": {
"reason": "#3357: the `03-VERIFICATION.md` staleness check now resolves the phase's own report via `gsd_run query verification.resolve-file \"$PHASE_DIR\" --raw` instead of `ls \"${PHASE_DIR}\"/*-VERIFICATION.md | head -1`, routing through the same shared resolver seam as transition.md so both call sites agree on which file is canonical. Growth is +13 bytes (38,983 -> 38,996), the delta between the old ls/head-1 pipeline and the gsd_run call."
}
}
}

View File

@@ -1,28 +0,0 @@
{
"version": 1,
"paths": {
"ai-integration-phase.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"diagnose-issues.md": "#3423 (epic #1891 F8): +6 bytes are the <files_to_read> -> <required_reading> tag rename (2 tag tokens, +3 bytes each); no prose added or rewritten. No tier crossed.",
"eval-review.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"new-milestone.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"quick.md": "#3423 (epic #1891 F8): +12 bytes are the <files_to_read> -> <required_reading> tag rename (4 tag tokens, +3 bytes each); no prose added or rewritten. No tier crossed.",
"secure-phase.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"ui-review.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"validate-phase.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"verify-work.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
}
}
}

View File

@@ -120,6 +120,29 @@ describe('init commands', () => {
assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md')); assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'));
}); });
// #3473 F2 (companion to #3357): init plan-phase's verification_path
// projector now resolves via the shared resolveVerificationFile resolver
// instead of a hand-rolled `.find()` over unsorted readdir() order. The
// canonical report must win over an ad-hoc -CORRECTION- worksheet
// deterministically, regardless of directory-listing order.
test('#3473 F2: init plan-phase resolves the canonical report over a -CORRECTION- worksheet', () => {
seedPhase(tmpDir, '03-api', {
'03-CORRECTION-VERIFICATION.md': '# Ad-hoc correction worksheet',
'03-VERIFICATION.md': '# Verification',
});
writePlanningDocs(tmpDir);
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.verification_path,
absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'),
'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet',
);
});
// #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project // #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project
// code, so a foreign-prefixed workstream/task id like "MEM-01" collapsed to // code, so a foreign-prefixed workstream/task id like "MEM-01" collapsed to
// "01" and resolved to the unrelated numeric Phase 01. init plan-phase must // "01" and resolved to the unrelated numeric Phase 01. init plan-phase must
@@ -362,6 +385,27 @@ describe('init commands', () => {
assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md')); assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'));
}); });
// #3473 F2 (companion to #3357): init phase-op's verification_path projector
// — the second of the two now-fixed init.cts sites — same regression as
// the plan-phase test above.
test('#3473 F2: init phase-op resolves the canonical report over a -CORRECTION- worksheet', () => {
seedPhase(tmpDir, '03-api', {
'03-CORRECTION-VERIFICATION.md': '# Ad-hoc correction worksheet',
'03-VERIFICATION.md': '# Verification',
});
writePlanningDocs(tmpDir);
const result = runGsdTools('init phase-op 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.verification_path,
absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'),
'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet',
);
});
test('init plan-phase detects has_reviews and reviews_path when REVIEWS.md exists', () => { test('init plan-phase detects has_reviews and reviews_path when REVIEWS.md exists', () => {
seedPhase(tmpDir, '03-api', { seedPhase(tmpDir, '03-api', {
'03-REVIEWS.md': '# Cross-AI Reviews', '03-REVIEWS.md': '# Cross-AI Reviews',

View File

@@ -14,8 +14,20 @@
* 8. CRLF line endings in frontmatter * 8. CRLF line endings in frontmatter
* 9. Body-only file (no frontmatter block) → missing * 9. Body-only file (no frontmatter block) → missing
* 10. Nonexistent phase directory → missing * 10. Nonexistent phase directory → missing
* 11. Multiple *-VERIFICATION.md files → first by sort * 11. Multiple *-VERIFICATION.md files, none matching the phase's own token →
* alphabetically-first FALLBACK wins (the phase-pinned rule's #2 tier —
* see #3492 below for the primary, phase-pinned tier)
* 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation) * 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation)
* 13. #3357/#3492: `<phase-token>-VERIFICATION.md` resolution — resolveVerificationFile
* unit coverage plus behavioral tests through readVerificationStatus and
* findStaleVerificationSummary. THE CONTRACT (#3492): a candidate whose
* name exactly matches THIS phase's own token always wins, even over a
* different phase's canonically-shaped file; alphabetical-first among all
* dashed candidates is only the fallback when no exact match exists. The
* resolveVerificationFile unit tests are the reliable anchors for this —
* the readVerificationStatus/findStaleVerificationSummary behavioral tests
* are illustrative (their outcome also depends on directory-basename
* token derivation, not exercised in isolation there).
* *
* PORTABILITY: pure JS — no shell-outs, no bash fences. * PORTABILITY: pure JS — no shell-outs, no bash fences.
* Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE. * Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.
@@ -35,7 +47,9 @@ const {
VERIFIER_STATUSES, VERIFIER_STATUSES,
VERIFICATION_ROUTING_TABLE, VERIFICATION_ROUTING_TABLE,
defaultPhaseCleanCommitTimesMs, defaultPhaseCleanCommitTimesMs,
resolveVerificationFile,
readVerificationStatus, readVerificationStatus,
findStaleVerificationSummary,
} = require('../gsd-core/bin/lib/verification.cjs'); } = require('../gsd-core/bin/lib/verification.cjs');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. // #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
@@ -313,8 +327,14 @@ describe('verification-status', () => {
assert.equal(result.next_command, '/gsd-execute-phase'); assert.equal(result.next_command, '/gsd-execute-phase');
}); });
// Multiple *-VERIFICATION.md files → deterministic pick (first by sort) // Multiple *-VERIFICATION.md files, NEITHER matching the phase dir's own
test('multiple *-VERIFICATION.md files in dir → first by sort order wins', () => { // token → deterministic FALLBACK pick (first by sort). This is the #2 tier
// of the #3492 phase-pinned rule, not the contract itself — see the
// `#3357/#3492` describe block below for the primary, phase-pinned tier
// (resolveVerificationFile unit tests are the reliable anchors there).
// mkPhaseDir's random-suffixed basename never matches "01"/"02", so this
// exercises the fallback by construction.
test('multiple *-VERIFICATION.md files, none matching the phase token → alphabetically-first FALLBACK wins', () => {
const dir = mkPhaseDir('multi'); const dir = mkPhaseDir('multi');
try { try {
// Write two files: alphabetically "01-a" comes before "02-b" // Write two files: alphabetically "01-a" comes before "02-b"
@@ -326,7 +346,7 @@ describe('verification-status', () => {
assert.equal( assert.equal(
result.status, result.status,
'passed', 'passed',
'When multiple *-VERIFICATION.md files exist, the first by lexicographic sort must be used', 'With no exact phase-token match, the first by lexicographic sort must be used',
); );
} finally { } finally {
cleanup(dir); cleanup(dir);
@@ -811,6 +831,299 @@ describe('verification-status', () => {
}); });
// ─── #3357/#3492: phase-pinned *-VERIFICATION.md resolution ──────────────────
//
// 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`). The original "alphabetically
// first" pick chose the worksheet ('C' < 'V'), and a worksheet with no
// frontmatter `status:` made the whole phase read `missing` even though a
// passing report sat right next to it (#3357).
//
// #3492 REGRESSION this block anchors: the #3357 fix's first cut preferred
// ANY canonically-shaped `<token>-VERIFICATION.md`, regardless of WHOSE token
// it carried — so a stray cross-phase or sentinel-numbered canonical file
// (`999-VERIFICATION.md`) could outrank the querying phase's own (possibly
// non-canonical) report. THE CONTRACT (verified against the built lib):
// ['12-review-VERIFICATION.md', '999-VERIFICATION.md'] resolves to
// '12-review-VERIFICATION.md' for phase token '12' (was '999-…').
// ['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'] resolves to
// '04-VERIFICATION.md' for phase token '04' (was '03-CORRECTION-…').
// resolveVerificationFile is the single resolver findStaleVerificationSummary,
// readVerificationStatus, commands.cts's determinePhaseStatus, and both
// init.cts verification_path projectors all call, every one pinned to its own
// phaseDir's token (#3473 F2 / #3492).
//
// These resolveVerificationFile unit tests are the RELIABLE ANCHORS for the
// phase-pinned rule (a real `phaseToken` string, no filesystem/readdir order
// involved). The readVerificationStatus/findStaleVerificationSummary
// behavioral tests further down are illustrative only — their outcome
// additionally depends on the temp directory's basename tokenizing the way
// the test expects.
describe('#3357/#3492: phase-pinned *-VERIFICATION.md resolution when multiple candidates exist', () => {
test('#3492 regression counterexample 1: a sentinel-numbered stray file does not outrank this phase\'s own non-canonical report', () => {
assert.equal(
resolveVerificationFile(
['12-review-VERIFICATION.md', '999-VERIFICATION.md'],
{ phaseToken: '12-review' },
),
'12-review-VERIFICATION.md',
'this phase (token "12-review") owns 12-review-VERIFICATION.md; 999-VERIFICATION.md is a different phase and must not win',
);
});
test('#3492 regression counterexample 2: a cross-phase CORRECTION worksheet does not outrank this phase\'s own canonical report', () => {
assert.equal(
resolveVerificationFile(
['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'],
{ phaseToken: '04' },
),
'04-VERIFICATION.md',
'this phase (token "04") owns 04-VERIFICATION.md; the 03-CORRECTION worksheet belongs to a different phase',
);
});
test('exact phase-token match wins over an ad-hoc -CORRECTION- worksheet for the SAME phase', () => {
assert.equal(
resolveVerificationFile(
['03-CORRECTION-VERIFICATION.md', '03-VERIFICATION.md'],
{ phaseToken: '03' },
),
'03-VERIFICATION.md',
'the phase\'s own 03-VERIFICATION.md must win over its CORRECTION worksheet, not lose alphabetically',
);
});
test('order-independence: same candidates reversed → same answer', () => {
assert.equal(
resolveVerificationFile(
['03-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md'],
{ phaseToken: '03' },
),
'03-VERIFICATION.md',
'input order must not change which file is selected',
);
});
test('decimal phase token: 35.1-VERIFICATION.md wins over a -CORRECTION- sibling', () => {
assert.equal(
resolveVerificationFile(
['35.1-CORRECTION-VERIFICATION.md', '35.1-VERIFICATION.md'],
{ phaseToken: '35.1' },
),
'35.1-VERIFICATION.md',
);
});
test('letter-suffixed phase token: 03A-VERIFICATION.md wins over a -CORRECTION- sibling', () => {
assert.equal(
resolveVerificationFile(
['03A-CORRECTION-VERIFICATION.md', '03A-VERIFICATION.md'],
{ phaseToken: '03A' },
),
'03A-VERIFICATION.md',
);
});
test('multi-canonical tiebreak: no exact phase-token match among several canonically-shaped candidates → alphabetically first', () => {
// Neither candidate's token is "50" — this is the (b) fallback tier, and
// it must stay a plain alphabetical pick (not a second, separate
// "canonical-shaped" preference — that concept no longer exists; #3492
// removed it because it was exactly the regression mechanism above).
assert.equal(
resolveVerificationFile(
['12-VERIFICATION.md', '999-VERIFICATION.md'],
{ phaseToken: '50' },
),
'12-VERIFICATION.md',
'"12-VERIFICATION.md" sorts before "999-VERIFICATION.md" and neither matches phase token "50"',
);
});
test('fallback: only a non-canonical file present → that file is still returned', () => {
// Load-bearing: a phase whose only report is non-canonically named must
// keep resolving to it, not to null — even when the phase token is known
// and does not exactly match.
assert.equal(
resolveVerificationFile(['01-review-VERIFICATION.md'], { phaseToken: '01' }),
'01-review-VERIFICATION.md',
);
});
test('fallback determinism: several non-canonical files, no phase token given → alphabetically first (unchanged)', () => {
assert.equal(
resolveVerificationFile(['02-b-VERIFICATION.md', '01-a-VERIFICATION.md']),
'01-a-VERIFICATION.md',
);
});
test('no phaseToken and no exact match → falls back to alphabetically-first, never null, when candidates exist', () => {
// #3492: an undeliverable/absent phase token must degrade to the original
// pre-#3357 behavior (alphabetically-first), not to null.
assert.equal(
resolveVerificationFile(['999-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md']),
'03-CORRECTION-VERIFICATION.md',
'with no phaseToken, plain alphabetical order decides — "03-…" sorts before "999-…"',
);
});
test('no matches → null', () => {
assert.equal(resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md'], { phaseToken: '03' }), null);
});
test('unrelated files are not miscounted as candidates', () => {
// 03-PLAN.md / 03-SUMMARY.md never end in "-VERIFICATION.md". A bare
// "VERIFICATION.md" (no leading phase-token dash) is also never a
// candidate — it fails the very `.endsWith('-VERIFICATION.md')` filter
// that builds the candidate list in the first place (the string is one
// character too short to end with a leading-dash suffix).
assert.equal(
resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md', 'VERIFICATION.md'], { phaseToken: '03' }),
null,
'a bare VERIFICATION.md is never a dashed candidate',
);
});
test('behavioral (readVerificationStatus): a phase with both its own report and a cross-phase stray reports the OWN report\'s status, not the stray\'s', () => {
// The directory basename is "03-canonical-test" so extractPhaseToken
// derives token "03" — the exact same derivation readVerificationStatus
// performs internally, so this exercises the real production call path
// (not just the pure resolver), pinned to counterexample 2's shape.
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-parent-'));
const dir = path.join(baseDir, '03-canonical-test');
fs.mkdirSync(dir);
try {
// A stray cross-phase canonical file with a DIFFERENT status — must not
// be picked for THIS (token "03") phase.
writeVerificationMd(dir, '99-VERIFICATION.md', 'gaps_found');
// This phase's own (non-canonical, ad-hoc) report — must win.
writeVerificationMd(dir, '03-CORRECTION-VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir);
assert.equal(
result.status,
'passed',
'the phase must report its OWN report\'s status, not a cross-phase stray\'s',
);
} finally {
cleanup(baseDir);
}
});
test('behavioral (readVerificationStatus): a phase with both its own canonical report and an ad-hoc worksheet reports the canonical report\'s status, not missing (#3357 original regression)', () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3357-parent-'));
const dir = path.join(baseDir, '03-canonical-test');
fs.mkdirSync(dir);
try {
// The ad-hoc worksheet has no frontmatter `status:` at all — this is
// the exact original #3357 failure mode: 'C' < 'V' picked this file
// first and the phase read 'missing' despite the passing report sitting
// right next to it.
fs.writeFileSync(
path.join(dir, '03-CORRECTION-VERIFICATION.md'),
'# Ad-hoc correction worksheet\n\nNo frontmatter status here.\n',
);
writeVerificationMd(dir, '03-VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir);
assert.equal(
result.status,
'passed',
'the phase must report the canonical report\'s status, not missing',
);
} finally {
cleanup(baseDir);
}
});
test('behavioral (findStaleVerificationSummary): staleness is checked against THIS phase\'s own report, not a cross-phase stray', () => {
// A stray cross-phase file ("99-VERIFICATION.md") is alphabetically AFTER
// this phase's own "03-VERIFICATION.md", so this also demonstrates the
// pin is not merely riding on alphabetical luck.
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-stale-parent-'));
const dir = path.join(baseDir, '03-stale-pin-test');
fs.mkdirSync(dir);
try {
writeVerificationMd(dir, '03-VERIFICATION.md', 'passed');
writeVerificationMd(dir, '99-VERIFICATION.md', 'passed');
setMtime(path.join(dir, '03-VERIFICATION.md'), '2020-01-01T00:00:00Z');
setMtime(path.join(dir, '99-VERIFICATION.md'), '2020-01-01T00:00:00Z');
// Root-style summary placement (mirrors the #2348 fixtures above) —
// scanPhasePlans's nested-layout matcher requires `SUMMARY-<NN>...md`
// inside a `plans/` subdir; a root-named `03-01-SUMMARY.md` dropped into
// `plans/` matches neither isRootSummaryFile (wrong directory) nor
// isNestedSummaryFile (wrong filename shape), so summaryFiles reads
// empty and the phase is never stale — not what this test means to
// exercise.
const summaryPath = path.join(dir, '03-01-SUMMARY.md');
fs.writeFileSync(summaryPath, '# summary\n');
setMtime(summaryPath, '2021-01-01T00:00:00Z');
// Force the mtime path (no git clock) by injecting an empty resolver —
// mirrors the existing #2348 test pattern elsewhere in this file.
const result = findStaleVerificationSummary(dir, fs, () => new Map());
assert.equal(result.determined, true);
assert.equal(result.stale, true, 'the phase\'s own 03-VERIFICATION.md is older than its summary');
assert.equal(
result.verificationFile,
'03-VERIFICATION.md',
'staleness must be computed against the phase\'s own report, not the cross-phase 99-VERIFICATION.md stray',
);
} finally {
cleanup(baseDir);
}
});
});
// ─── #3473 F2: resolveVerificationFile allowBare option ──────────────────────
//
// commands.cts (determinePhaseStatus) and two verification_path projectors in
// init.cts each hand-rolled a fourth variant of this same selection: they
// additionally accept a BARE `VERIFICATION.md`, which this module's own two
// callers (findStaleVerificationSummary, readVerificationStatus) never have.
// `allowBare` threads that one behavioral difference through the single
// resolver instead of leaving a fourth hand-rolled implementation behind
// (#3473 F2). A bare match is ranked BELOW any dashed candidate — canonical
// or not — because a dashed file names its phase and a bare one does not.
describe('#3473 F2: resolveVerificationFile allowBare option', () => {
test('allowBare defaults to false — a bare-only list returns null without the option', () => {
assert.equal(resolveVerificationFile(['VERIFICATION.md']), null);
});
test('allowBare:true, bare-only candidate → bare file returned', () => {
assert.equal(
resolveVerificationFile(['VERIFICATION.md'], { allowBare: true }),
'VERIFICATION.md',
);
});
test('allowBare:true, bare + non-canonical dashed → the dashed fallback wins', () => {
assert.equal(
resolveVerificationFile(
['VERIFICATION.md', '01-review-VERIFICATION.md'],
{ allowBare: true },
),
'01-review-VERIFICATION.md',
'a dashed non-canonical file names its phase and must win over a bare match',
);
});
test('allowBare:true, bare + canonical → the canonical file wins', () => {
assert.equal(
resolveVerificationFile(
['VERIFICATION.md', '03-VERIFICATION.md'],
{ allowBare: true },
),
'03-VERIFICATION.md',
);
});
});
// ─── #3057 B3: findStaleVerificationSummary — indeterminate vs not-stale ───── // ─── #3057 B3: findStaleVerificationSummary — indeterminate vs not-stale ─────
// //
// The pre-fix catch-all returned `null` on ANY fs / scanPhasePlans / clock // The pre-fix catch-all returned `null` on ANY fs / scanPhasePlans / clock