fix(verification): keep passed uat results across verifier re-runs
A phase whose human UAT passed could loop forever between execute-phase and verify-work. A gap-closure plan, or a later phase editing a covered file, made the report stale; verify-work only recorded a passed UAT against human_needed; the re-run verifier could not see the UAT file and re-emitted human_needed; and execute-phase's human_needed branch then rewrote *-UAT.md with every row back to [pending]. - verification.uat-evidence: recorded UAT rows plus the covered implementation files changed since the UAT. The verifier (Step 8b) treats a row that passed on unchanged code as verified and re-lists only affected rows, each with a retest_reason. - verification.seed-uat: merges the report's human items into *-UAT.md. Existing rows are kept byte-for-byte; a pass is reset only with a recorded reason. - verification.canonicalize-uat: the single human_needed -> passed flip, gated on the uat-only row predicate; refuses a stale report. - verification.status reports stale_reason; init.progress lists every executed-but-stale phase in reverify_phases. - verify-work and progress re-run the verifier themselves for a stale report instead of routing to execute-phase; execute-phase records a gap-closure checkpoint's UAT answers before dispatching the verifier. Fail-closed properties are unchanged: a malformed fingerprint and a real change to covered code still read stale. Emitted-Drift-Ack-Growth: execute-phase.md — verify_phase_goal gains the gap-closure uat-record pointer and the merge-not-overwrite human_needed branch; the uat template it replaced moved into verification.seed-uat Emitted-Drift-Ack-Growth: msd-verifier.md — new step 8b pointer to the lazily loaded verifier-uat-evidence reference plus the human_verified frontmatter key Emitted-Drift-Ack-Growth: progress.md — route v.stale now re-verifies every stale executed phase in place instead of naming one command
This commit is contained in:
@@ -118,7 +118,11 @@ const VERIFICATION_ROUTING_TABLE: Record<string, VerificationRoute> = {
|
||||
// here was an advice loop. execute-phase resumes at the verification
|
||||
// gates and re-runs the verifier (its resume tree routes a stale report
|
||||
// to re-verification), which regenerates VERIFICATION.md and its digest.
|
||||
next_action: 'Verification is stale — covered source files changed after the verifier last ran. Re-run execute-phase for this phase: it resumes at the verification gates and re-runs the verifier, regenerating VERIFICATION.md and its digest. verify-work alone cannot refresh a stale report.',
|
||||
// the action is named for what it is — a verifier re-run, not an
|
||||
// execution. No plan re-runs, and recorded UAT results are kept: the
|
||||
// verifier reads them (`verification.uat-evidence`) and the UAT writer
|
||||
// merges instead of overwriting (`verification.seed-uat`).
|
||||
next_action: 'Verification is stale — the report no longer matches the phase. Re-run the verifier: execute-phase runs only plans that have no SUMMARY (none, when every plan is summarized), then resumes at the verification gates and regenerates VERIFICATION.md and its digest. Recorded UAT results are kept, not re-requested.',
|
||||
next_command: 'execute-phase',
|
||||
},
|
||||
// INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
|
||||
@@ -865,8 +869,42 @@ interface VerificationStatusResult {
|
||||
* `gaps_found` short-circuit above it, or no verification file at all).
|
||||
*/
|
||||
staleCheckIndeterminate?: boolean;
|
||||
/**
|
||||
* WHY a `stale` result is stale — present only when `status` is
|
||||
* `stale`. Every reason still means "re-run the verifier"; the split lets
|
||||
* a caller say what happened (a gap-closure plan the report never saw is
|
||||
* not the same event as a covered source file drifting under a finished
|
||||
* phase) and lets progress batch the drift case across phases.
|
||||
*/
|
||||
stale_reason?: StaleReason;
|
||||
/**
|
||||
* the report's own frontmatter `status` underneath a `stale` result —
|
||||
* present only when `status` is `stale`. `human_needed` here means a UAT
|
||||
* is still owed (or already recorded) once the verifier has re-run.
|
||||
*/
|
||||
raw_status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* the closed set of reasons a report reads `stale`.
|
||||
* - `fingerprint_malformed` — the `covered_files`/`covered_digest` pair is
|
||||
* incomplete, wrong-shaped, or names an unknown version (fail closed, #4155).
|
||||
* - `covered_changed` — a declared covered file's bytes changed, or
|
||||
* the file is gone.
|
||||
* - `uncovered_artifacts` — every declared file still matches, but the
|
||||
* phase directory holds a PLAN/SUMMARY the report never declared (the
|
||||
* shape every gap-closure cycle produces).
|
||||
* - `summary_newer` — legacy (pre-#4155) report with a SUMMARY
|
||||
* changed after it.
|
||||
*/
|
||||
type StaleReason = 'fingerprint_malformed' | 'covered_changed' | 'uncovered_artifacts' | 'summary_newer';
|
||||
const STALE_REASONS: ReadonlyArray<StaleReason> = [
|
||||
'fingerprint_malformed',
|
||||
'covered_changed',
|
||||
'uncovered_artifacts',
|
||||
'summary_newer',
|
||||
];
|
||||
|
||||
function findStaleVerificationSummary(
|
||||
phaseDir: string,
|
||||
fsImpl: FsLike = defaultFsImpl,
|
||||
@@ -1096,6 +1134,7 @@ function readVerificationStatus(
|
||||
|
||||
let staleCheckIndeterminate = false;
|
||||
let isStale: boolean;
|
||||
let staleReason: StaleReason = 'summary_newer';
|
||||
if (declaresFingerprint) {
|
||||
// Stated directly rather than relying on `null !== coveredDigestVal`
|
||||
// being true whenever the pair is malformed: `!hasWellFormedFingerprint`
|
||||
@@ -1115,11 +1154,22 @@ function readVerificationStatus(
|
||||
hasWellFormedFingerprint && typeof coveredDigestVal === 'string'
|
||||
? parseFingerprintVersion(coveredDigestVal)
|
||||
: null;
|
||||
isStale =
|
||||
!hasWellFormedFingerprint ||
|
||||
storedVersion === null ||
|
||||
computeCoveredDigest(findProjectRoot(phaseDir), coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal ||
|
||||
!allCurrentArtifactsCovered(phaseDir, coveredFilesVal);
|
||||
// same three checks, same order, same short-circuit — only named, so
|
||||
// the result can say which one fired.
|
||||
if (!hasWellFormedFingerprint || storedVersion === null) {
|
||||
isStale = true;
|
||||
staleReason = 'fingerprint_malformed';
|
||||
} else if (
|
||||
computeCoveredDigest(findProjectRoot(phaseDir), coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal
|
||||
) {
|
||||
isStale = true;
|
||||
staleReason = 'covered_changed';
|
||||
} else if (!allCurrentArtifactsCovered(phaseDir, coveredFilesVal)) {
|
||||
isStale = true;
|
||||
staleReason = 'uncovered_artifacts';
|
||||
} else {
|
||||
isStale = false;
|
||||
}
|
||||
} else {
|
||||
const staleCheck = findStaleVerificationSummary(
|
||||
phaseDir,
|
||||
@@ -1144,6 +1194,8 @@ function readVerificationStatus(
|
||||
// the verifier, regenerating VERIFICATION.md and its digest — the same
|
||||
// routing the `missing` sentinel has used since #2868.
|
||||
next_command: projectNextCommand('execute-phase', runtime, phaseArg),
|
||||
stale_reason: staleReason,
|
||||
raw_status: rawStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1470,6 +1522,8 @@ function cmdVerificationFingerprint(
|
||||
export = {
|
||||
VERIFIER_STATUSES,
|
||||
VERIFICATION_ROUTING_TABLE,
|
||||
STALE_REASONS,
|
||||
canonicalizeCoveredFiles,
|
||||
defaultPhaseCleanCommitTimesMs,
|
||||
resolveVerificationFile,
|
||||
resolveUatFile,
|
||||
|
||||
Reference in New Issue
Block a user