* fix: require fresh phase verification before transition * no-mistakes(review): Fix canonical verification closeout gates * no-mistakes(review): Fix verify-work frontmatter promotion command * no-mistakes(review): Fix stale verification gates * no-mistakes(review): Fix canonical verification routing gates * no-mistakes(review): Fix verification dependency and runtime routing gates * no-mistakes(review): Block stale verification bypasses * fix: handle large init manager outputs in verification workflows * chore: update changeset pr number * fix(verify-work): use fresh verification.status for stale gate The stale check after UAT used phase_completion.verification_status from session-start INIT while human_needed promotion already queried fresh verification.status. Align the stale gate with the canonical query so mid-session verification refresh is not ignored. * fix(init): skip roadmap-checked phases when selecting next_phase Roadmap-only phases without a disk directory were still promoted to next_phase when their checkbox was already checked. Exclude checkboxComplete phases so progress routing does not point at work the roadmap already marks done. * fix: gaps_found not overridden by stale, transition uses canonical verification - verification.cts: check gaps_found before stale so gap-closure routing is not masked by a newer summary mtime - phase.cts: remove redundant findStaleVerificationSummary — readVerificationStatus already handles stale detection - transition.md: replace raw grep on file content with verification.status query to avoid false-positive blocks from body text matching * ci: retrigger tests after rebase * fix(transition): replace gsd_run advisory check with awk frontmatter extraction The runtime launcher is not defined until the update_roadmap_and_state step bash block (~line 165). The early verify_completion block used gsd_run to query verification.status, which violated the runtime-launcher-parity test: 'preamble appears AFTER the first gsd_run reference'. Replace the gsd_run call with an awk-based frontmatter extractor that reads only the status: field between the two --- fences. This avoids both the preamble-ordering constraint and the original false-positive grep bug where body text like 'previous_status: gaps_found' would match a full-text regex. The phase.complete gate at update_roadmap_and_state is the canonical enforcement point; this early check is advisory only. Also update workflow-size-baseline.json for the updated transition.md size. Fixes: runtime-launcher-parity test (B) Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix: re-check verification under planning lock in phase complete Move readVerificationStatus into withPlanningLock so stale verification cannot slip through when a SUMMARY.md is written between the gate and the roadmap/state mutation. Return the blocked status from the lock callback and emit the error after release to avoid leaving .lock behind. * fix(transition): gate on canonical verification.status including stale Replace awk frontmatter read with verification.status query so transition blocks when summaries are newer than VERIFICATION.md, matching phase.complete and other workflows (autonomous, progress, verify-work). * Fix workflow verification gates for yolo transition and stale routing Require VERIFY_STATUS passed before yolo/interactive transition advance. Route stale verification recovery to verify-work, matching canonical projection. * fix(transition): use verification.status query for stale-aware advisory check The awk-based check read raw frontmatter status: passed, which misses the stale case where summaries are newer than the VERIFICATION.md file even though the frontmatter still says passed. The stale status is computed from file modification times, not stored in frontmatter. Move the preamble to the verify_completion bash block (the first block with a gsd_run call) so gsd_run query verification.status can be used for the advisory check. This gives the full readVerificationStatus logic including mtime-based staleness detection, matching the enforcement gate at phase.complete. Capture full JSON (VERIFY_JSON) so next_action can be included in the advisory output alongside the status. Also update workflow-size-baseline.json for the updated transition.md size. Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * ci: trigger test matrix for 525b946 Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(transition): restore awk frontmatter extraction for pre-shim verification check The gsd_run launcher shim is not defined until line ~163 of transition.md, so the verification debt check at line ~80 cannot use gsd_run. Restore the awk-based frontmatter extraction that correctly reads status without needing the runtime, and restore the shim at its proper location before phase.complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(#1522): clarify transition verification gate wording * fix(#1522): update transition workflow size baseline * fix(#1522): update workflow-size-baseline after rebase onto next Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(#1522): guard findStaleVerificationSummary FS calls + thread opts.fs seam (review) Address review blocker B1 on #1548: findStaleVerificationSummary ran fs.readdirSync and two fs.statSync calls unguarded between readVerificationStatus's try/catch sections, so a TOCTOU race (a SUMMARY listed by scanPhasePlans then removed before statSync) or any FS error threw uncaught into callers NOT under the planning lock (init.manager / init.progress / uat-predicate). Wrap the body in try/catch degrading to 'not stale', and thread the injectable opts.fs seam (add statSync to FsLike, pass fsImpl from the caller) for parity with readVerificationStatus's no-throw contract and testability. Also adds the Verification Module glossary entry to CONTEXT.md (review B3). --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
/**
|
|
* 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
|
|
* gsd-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.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
// 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');
|
|
|
|
const { output, error } = io;
|
|
const { extractPhaseToken } = phaseId;
|
|
const { extractFrontmatter } = frontmatterMod;
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
/** The set of status values that the gsd-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 (gsd-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.
|
|
*/
|
|
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.",
|
|
next_command: '',
|
|
},
|
|
stale: {
|
|
status: 'stale',
|
|
next_action: 'Verification is stale. Re-run verify-work before transition.',
|
|
next_command: '',
|
|
},
|
|
// 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. Re-run execute-phase.',
|
|
next_command: '/gsd:execute-phase',
|
|
},
|
|
// 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: '/gsd:execute-phase',
|
|
},
|
|
};
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
interface FsLike {
|
|
readdirSync(dir: string): string[];
|
|
readFileSync(filePath: string, encoding: 'utf-8'): string;
|
|
statSync(filePath: string): { mtimeMs: number };
|
|
}
|
|
|
|
interface StaleVerificationInfo {
|
|
verificationFile: string;
|
|
summaryFile: string;
|
|
}
|
|
|
|
/**
|
|
* 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(): VerificationStatusResult {
|
|
const route = VERIFICATION_ROUTING_TABLE['missing'];
|
|
return {
|
|
status: route.status,
|
|
next_action: route.next_action,
|
|
next_command: route.next_command,
|
|
};
|
|
}
|
|
|
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
interface ReadVerificationStatusOptions {
|
|
fs?: FsLike;
|
|
}
|
|
|
|
interface VerificationStatusResult {
|
|
status: string;
|
|
next_action: string;
|
|
next_command: string;
|
|
}
|
|
|
|
function findStaleVerificationSummary(phaseDir: string, fsImpl: FsLike = fs): StaleVerificationInfo | null {
|
|
// FS errors (TOCTOU: a SUMMARY listed by scanPhasePlans then removed before statSync;
|
|
// unreadable dir; broken symlink; file->dir swap) must degrade to "not stale" 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.)
|
|
try {
|
|
const phaseFiles = fsImpl.readdirSync(phaseDir);
|
|
const verificationFile = phaseFiles.filter((f) => f.endsWith('-VERIFICATION.md')).sort()[0];
|
|
if (!verificationFile) return null;
|
|
|
|
const verificationMtimeMs = fsImpl.statSync(path.join(phaseDir, verificationFile)).mtimeMs;
|
|
let newestStaleSummary: { summaryFile: string; mtimeMs: number } | null = null;
|
|
const summaryFiles = (scanPhasePlans(phaseDir) as { summaryFiles: string[] }).summaryFiles;
|
|
for (const summaryFile of summaryFiles.sort()) {
|
|
const summaryMtimeMs = fsImpl.statSync(path.join(phaseDir, summaryFile)).mtimeMs;
|
|
if (summaryMtimeMs <= verificationMtimeMs) continue;
|
|
if (!newestStaleSummary || summaryMtimeMs > newestStaleSummary.mtimeMs) {
|
|
newestStaleSummary = { summaryFile, mtimeMs: summaryMtimeMs };
|
|
}
|
|
}
|
|
|
|
if (!newestStaleSummary) return null;
|
|
return {
|
|
verificationFile,
|
|
summaryFile: newestStaleSummary.summaryFile,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the verification status from the first `*-VERIFICATION.md` file in
|
|
* phaseDir and return the routing result.
|
|
*
|
|
* Behavior:
|
|
* 1. Find the first file matching `*-VERIFICATION.md` (sorted, take first).
|
|
* 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'.
|
|
*
|
|
* @param phaseDir - Absolute path to the phase directory.
|
|
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
|
|
*/
|
|
function readVerificationStatus(
|
|
phaseDir: string,
|
|
opts: ReadVerificationStatusOptions = {},
|
|
): VerificationStatusResult {
|
|
const fsImpl: FsLike = opts.fs ?? fs;
|
|
|
|
// Phase token for the gaps_found command
|
|
const baseName = path.basename(phaseDir);
|
|
const phaseToken = extractPhaseToken(baseName);
|
|
const phaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
|
|
|
|
// 1. Find *-VERIFICATION.md
|
|
let verificationFile: string | null = null;
|
|
try {
|
|
const entries = fsImpl.readdirSync(phaseDir);
|
|
const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort();
|
|
verificationFile = candidates.length > 0 ? candidates[0] : null;
|
|
} catch {
|
|
// Directory unreadable → treat as missing
|
|
verificationFile = null;
|
|
}
|
|
|
|
if (!verificationFile) {
|
|
return missingResult();
|
|
}
|
|
|
|
// 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;
|
|
try {
|
|
const content = fsImpl.readFileSync(filePath, 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
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();
|
|
}
|
|
|
|
// 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: `/gsd:plan-phase ${phaseNumber} --gaps`,
|
|
};
|
|
}
|
|
|
|
const staleVerification = findStaleVerificationSummary(phaseDir, fsImpl);
|
|
if (staleVerification) {
|
|
const entry = VERIFICATION_ROUTING_TABLE['stale'];
|
|
return {
|
|
status: entry.status,
|
|
next_action: entry.next_action,
|
|
next_command: `/gsd:verify-work ${phaseNumber}`,
|
|
};
|
|
}
|
|
|
|
// 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: entry.next_command,
|
|
};
|
|
}
|
|
|
|
// Unknown value
|
|
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
|
|
return {
|
|
status: unknownRoute.status,
|
|
next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`,
|
|
next_command: unknownRoute.next_command,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
output(result, raw);
|
|
}
|
|
|
|
export = {
|
|
VERIFIER_STATUSES,
|
|
VERIFICATION_ROUTING_TABLE,
|
|
findStaleVerificationSummary,
|
|
readVerificationStatus,
|
|
cmdVerificationStatus,
|
|
};
|