feat(review): the review is a gate - verdict in the file, CC engine, project memory

Ordre 5 (wave 1, P11 + P5), review_version 1.1.

- K7: `verdict: ALLOW | WARN | BLOCK` is required in review.md (strict
  error, soft warning for 1.0 reviews). New CLI lib/review/verdict-gate.mjs:
  0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide (never green); a verdict
  greener than the file's own BLOCKERs or JSON block is exit 2. Realpath
  main guard (the old file:// guard exits 0 under a symlink - measured).
- No finding is struck for length: succinctness trims only MINOR/SUGGESTION
  text (catalogue tier), keeps BLOCKER/MAJOR whole, counts trims; the
  coordinator lists what it declined to judge.
- Claude Code's /code-review and /security-review run as the engine via the
  Skill tool (never --fix/--comment/--post/ultra); lib/review/engine-findings.mjs
  turns their findings into a reviewer payload; two catalogue keys
  (CODE_REVIEW_DEFECT, SECURITY_REVIEW_VULNERABILITY), catalogue 12 -> 14.
  An engine that did not run forbids ALLOW.
- Every BLOCKER is written as a numbered `### Step R<n>` with its source
  finding (strict error REVIEW_MISSING_REMEDIATION_STEP).
- P5: lib/review/recurrence.mjs - a finding class in two review.md files
  (current + git history, no new store) becomes one proposal, routed by the
  catalogue's memory_route; written only between Voyage markers in
  .claude/rules/voyage-review-lessons.md under a size budget, CLAUDE.md gets
  a pointer only; the operator approves every line (Phase 8.7).
- Workflow substrate: catalogue enum in step; a missing reviewer or engine
  forbids ALLOW there too (it returned ALLOW on zero findings before).
- Fixtures review-run-A/B said WARN over a BLOCKER; now BLOCK.

Borrowed and credited in code: Superpowers (declined-to-judge), ai-native-sdlc
(cap only nits; mistake/fix/check), Spec Kit (managed block; converge steps),
Pocock (retro routing), Kiro (verdict file + gate; evidence per lesson),
Cherny (engine -> brief control order; review -> memory; budget).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 19:45:52 +02:00
commit d88245c06e
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
28 changed files with 2052 additions and 110 deletions

View file

@ -6,8 +6,15 @@
// tolerated silently. Strict-key checks are intentionally avoided so new
// optional fields (jf. the source_findings precedent) can be added without
// a review_version bump.
//
// K7 (ordre 5, review_version 1.1): `verdict: ALLOW | WARN | BLOCK` is REQUIRED.
// Strict (write-time) mode rejects a review without it; soft (read-time) mode
// warns, so a 1.0 review can still seed a plan (N-1 window, Handover 6). The
// gate that decides on the verdict is lib/review/verdict-gate.mjs, which never
// reads a missing verdict as ALLOW.
import { readFileSync, existsSync } from 'node:fs';
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
@ -23,8 +30,42 @@ export const REVIEW_REQUIRED_FRONTMATTER = [
'findings',
];
export const REVIEW_BODY_SECTIONS = ['Executive Summary', 'Coverage', 'Remediation Summary'];
export const VERDICT_VALUES = Object.freeze(['ALLOW', 'WARN', 'BLOCK']);
const HEX_ID_RE = /^[0-9a-f]{40}$/;
const JSON_FENCE_GLOBAL = /```json[ \t]*\r?\n([\s\S]*?)```/gi;
/** IDs of BLOCKER findings in the trailing JSON block ([] when absent/unparseable). */
function blockerIdsFromBody(body) {
let last = null;
let m;
JSON_FENCE_GLOBAL.lastIndex = 0;
while ((m = JSON_FENCE_GLOBAL.exec(body)) !== null) last = m[1];
if (last === null) return [];
try {
const payload = JSON.parse(last);
return (Array.isArray(payload?.findings) ? payload.findings : [])
.filter((f) => f?.severity === 'BLOCKER' && typeof f.id === 'string')
.map((f) => f.id);
} catch {
return [];
}
}
/** Finding IDs named by `Source finding:` lines under `## Remediation Steps` step headings. */
function remediationStepSources(body) {
const start = body.search(/^##\s+Remediation Steps\b/m);
if (start === -1) return new Set();
const rest = body.slice(start).replace(/^##\s+Remediation Steps[^\n]*\n/, '');
const next = rest.search(/^##\s+/m);
const section = next === -1 ? rest : rest.slice(0, next);
const ids = new Set();
for (const step of section.split(/^###\s+Step\s+R\d+\b/m).slice(1)) {
const m = step.match(/Source finding:\**\s*`?([0-9a-f]{40})`?/);
if (m) ids.add(m[1]);
}
return ids;
}
export function validateReviewContent(text, opts = {}) {
const strict = opts.strict !== false;
@ -66,6 +107,41 @@ export function validateReviewContent(text, opts = {}) {
}
}
if (!('verdict' in fm) || fm.verdict === null || fm.verdict === '') {
const issueObj = issue(
'REVIEW_MISSING_VERDICT',
'Required frontmatter field missing: verdict (ALLOW | WARN | BLOCK)',
'Copy the coordinator verdict from the trailing JSON block into the frontmatter.',
);
if (strict) errors.push(issueObj);
else warnings.push(issueObj);
} else if (!VERDICT_VALUES.includes(fm.verdict)) {
errors.push(issue(
'REVIEW_BAD_VERDICT',
`frontmatter.verdict must be one of ${VERDICT_VALUES.join('|')}, got ${JSON.stringify(fm.verdict)}`,
));
}
// Every BLOCKER is written as a numbered remediation step that Handover 6 can
// append to a plan unchanged: `### Step R<n>: …` with `Source finding: <id>`.
// Borrowed: review gaps become appended, numbered tasks with a source-ref,
// never rewriting earlier ones (Spec Kit `templates/commands/converge.md:156-163`, MIT).
const blockerIds = blockerIdsFromBody(body);
if (blockerIds.length > 0) {
const steps = remediationStepSources(body);
for (const id of blockerIds) {
if (!steps.has(id)) {
const issueObj = issue(
'REVIEW_MISSING_REMEDIATION_STEP',
`BLOCKER ${id} has no "### Step R<n>" with "Source finding: ${id}" under ## Remediation Steps`,
'Write one numbered remediation step per BLOCKER (Handover 6).',
);
if (strict) errors.push(issueObj);
else warnings.push(issueObj);
}
}
}
for (const section of REVIEW_BODY_SECTIONS) {
const re = new RegExp(`^##\\s+${section}\\b`, 'm');
if (!re.test(body)) {
@ -94,7 +170,15 @@ export function validateReview(filePath, opts = {}) {
return { ...r, parsed: { ...r.parsed, filePath } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const filePath = args.find(a => !a.startsWith('--'));