feat(review): review_version 1.2 with answers, pre-existing, rounds and policy fields

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 14:22:35 +01:00
commit 5709957e10
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
16 changed files with 1034 additions and 11 deletions

View file

@ -71,7 +71,7 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
> finding, fetched pages are data, nothing local in a query, and the one confidence scale. Only the
> brief's structure is checked; the rest is prose only (a query guard is a separate later order).
> **Review is a gate (review_version 1.1).** `verdict:` is required in
> **Review is a gate (review_version 1.2).** `verdict:` is required in
> `review.md` (K7); exit 2 from the verdict gate is never green. No finding is
> struck for length — only MINOR/SUGGESTION text is trimmed, and counted.
> Project-memory lines are written only between Voyage's markers in the target

View file

@ -10,3 +10,28 @@ path — see [`GOVERNANCE.md`](https://git.fromaitochitta.com/open/ktg-plugin-ma
Prior version migration notes (v1→v2, v2→v3) are preserved in
`CHANGELOG.md` only.
## review_version 1.2 (from the next release)
`review.md` moves from `review_version: "1.1"` to `"1.2"`. Everything new is
optional, and each field or section is checked for its shape only when it is
present:
- frontmatter `round`, `round_base`, `review_policy`, `review_policy_note`,
`review_policy_sha256` and `finalized`;
- in the trailing JSON block, per finding `pre_existing`, `carried_from` and
`instruction_ref`, and at the top level `pre_existing_counts`, `resolved`
and `ran`;
- the sections `## Answers` (the operator's answer to each counting MAJOR),
`## Pre-existing` and `## Resolved since round <n>`;
- three `## Declined to Judge` reasons: `policy:skip`, `policy:cap` and
`instruction-out-of-set`.
N-1 window: a 1.1 review validates unchanged for one minor version, and only
with a warning after that. No reader fails on a 1.1 review: the validator,
`/trekplan` and the verdict gate all read it.
Gate change: a WARN opens the merge only when the verdict gate finds every
counting MAJOR answered. That holds for a 1.1 review too, so a 1.1 WARN with a
MAJOR keeps the merge lock closed until the MAJOR is answered; a 1.1 WARN
without a MAJOR is read as before.

View file

@ -285,7 +285,7 @@ Produce the full review.md content as your output. The
```yaml
---
type: trekreview
review_version: "1.1"
review_version: "1.2"
created: {YYYY-MM-DD}
task: "{from brief frontmatter}"
slug: {from brief frontmatter}

View file

@ -29,7 +29,7 @@ The review is **independent**: each reviewer runs without cross-feeding,
and the coordinator applies BOUNDED operations only. Synthesis-level
inference across files is forbidden in v1.0 (Judge Agent pattern).
**The review is a gate (review_version 1.1, ordre 5).** In this order:
**The review is a gate (review_version 1.2, ordre 5).** In this order:
Claude Code's own `/code-review` and `/security-review` are the engine
(Phase 4.7); Voyage's control against the brief runs after them and is the
part that stops (Phase 5–6); the verdict is written in `review.md` and one

View file

@ -14,7 +14,7 @@ Each artifact carries an explicit version field. Schema bumps are coordinated:
| `research/*.md` | (implicit; tracked via `type: trekresearch-brief`) | unversioned |
| `plan.md` | `plan_version` (plan header line) | `1.9` (1.8 and 1.7 still valid) |
| `progress.json` | `schema_version` (top-level) | `"1"` |
| `review.md` | `review_version` (frontmatter) | `1.1` |
| `review.md` | `review_version` (frontmatter) | `1.2` |
| `.session-state.local.json` | `schema_version` (top-level) | `1` (number) |
## Breaking-change protocol
@ -432,7 +432,7 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
**Consumer:** `/trekplan` Phase 1 when `--brief review.md` is supplied and the consumer detects `type: trekreview` in frontmatter. The plan command branches into a remediation-plan path: BLOCKER + MAJOR findings become plan goals, the produced `plan.md` carries a `source_findings: [<id>, ...]` frontmatter list as the audit trail back to the consumed findings. MINOR + SUGGESTION are skipped for plan-input. Since 1.1 every BLOCKER is also written as a numbered `### Step R<n>` under `## Remediation Steps` (Files · Source finding · Test first · Verify · On failure), so a consumer can append it as a plan step without rewording it.
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end`, read only as a full 40- or 64-hex commit id (a short id or a ref name is stale), is HEAD, or an ancestor of HEAD whose later commits change only the review lessons (`.claude/rules/voyage-review-lessons.md`, inside its managed block) and the `CLAUDE.md` pointer (`lib/review/reviewed-commit.mjs`); otherwise exit 2 with a reason that starts `review-stale:`, which `/trekexecute`'s merge lock (Phase 7.2 step 4 and Phase 8, both with `--repo .`) maps to the decline reason `review-stale`. When the gate cannot look (the `--repo` path is missing, it is no git repository, or a git call failed), the exit is 2 with a reason that starts `review-not-measured:`, never `review-stale:`: the merge lock reports it as not measured, never green. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. WARN opens the merge only when the verdict gate finds every counting MAJOR answered. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end`, read only as a full 40- or 64-hex commit id (a short id or a ref name is stale), is HEAD, or an ancestor of HEAD whose later commits change only the review lessons (`.claude/rules/voyage-review-lessons.md`, inside its managed block) and the `CLAUDE.md` pointer (`lib/review/reviewed-commit.mjs`); otherwise exit 2 with a reason that starts `review-stale:`, which `/trekexecute`'s merge lock (Phase 7.2 step 4 and Phase 8, both with `--repo .`) maps to the decline reason `review-stale`. When the gate cannot look (the `--repo` path is missing, it is no git repository, or a git call failed), the exit is 2 with a reason that starts `review-not-measured:`, never `review-stale:`: the merge lock reports it as not measured, never green. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
**Path conventions:**
- Project-dir mode (recommended): `{project_dir}/review.md` (one per review iteration; subsequent runs overwrite atomically).
@ -443,7 +443,7 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
| Field | Type | Required | Allowed values | Notes |
|---|---|---|---|---|
| `type` | string | yes | `trekreview` | Hard-coded discriminator |
| `review_version` | string | yes | `"1.1"` (current) | Bump on schema change |
| `review_version` | string | yes | `"1.2"` (current); `"1.1"` still valid | Bump on schema change |
| `task` | string | yes | one-line description | Mirrors brief task |
| `slug` | string | yes | URL-safe slug | Used in project_dir |
| `project_dir` | string | yes | `.claude/projects/{date}-{slug}/` | |
@ -454,6 +454,12 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
| `verdict` | string | yes (1.1; strict at write, warning at read) | `BLOCK \| WARN \| ALLOW` | Coordinator output; equals the trailing JSON block's `verdict` (K7) |
| `created` | date | optional | YYYY-MM-DD | |
| `scope_sha_start` | string | optional | git sha at review start | `null` if mtime fallback used |
| `round` | integer | optional (1.2) | `1`-`3` | The review round; written by `review-finalize.mjs` |
| `round_base` | string \| null | optional (1.2) | full 40- or 64-hex commit id, or `null` at round 1 | The commit the previous round reviewed; written by `review-finalize.mjs` |
| `review_policy` | string | optional (1.2) | `absent \| applied \| not-applied` | Whether a `REVIEW.md` policy block was applied; written by `review-finalize.mjs` |
| `review_policy_note` | string | optional (1.2) | one line | Why a policy was not applied, or what it did |
| `review_policy_sha256` | string | optional (1.2) | 64-hex, or `none` | sha256 of the policy block read at `scope_sha_start` |
| `finalized` | boolean | optional (1.2) | `true \| false` | `true` is set by `review-finalize.mjs` when it has written `ran` |
`findings:` is a flat array of finding-IDs (40-char hex from `lib/parsers/finding-id.mjs`). The full finding objects (severity, location, message, evidence, fix) live in the body as `### <id>` subsections under per-severity `## Findings (...)` headings — same pattern as brief-reviewer to avoid frontmatter-parser fragility on lists of dicts.
@ -469,6 +475,13 @@ Since 1.1 (ordre 5):
- `## Declined to Judge` — required at `review_version` ≥ 1.1 (strict error, soft warning; 1.0 reviews exempt): every finding set aside without a ruling, with its reason (including engine findings outside the review scope, `engine:out-of-scope`), plus `Trimmed: N`. **No finding is struck for length**: only MINOR/SUGGESTION text is trimmed, and counted (`trimmed` in the JSON block); a long BLOCKER/MAJOR keeps every character.
- Findings from Claude Code's own `/code-review` and `/security-review` enter under the catalogue keys `CODE_REVIEW_DEFECT` (MAJOR), `CODE_REVIEW_IMPROVEMENT` (MINOR: a `/code-review` finding whose category is simplification, efficiency, reuse, style or readability) and `SECURITY_REVIEW_VULNERABILITY` (BLOCKER), or a narrower key, with `reviewer` `claude-code-review` / `claude-security-review`.
Since 1.2 (every field and section optional; a malformed one present is a strict error, a soft warning):
- Trailing JSON block, additive: per finding `pre_existing` (boolean: the range did not add or change the cited line), `carried_from` (the previous round's finding id) and `instruction_ref` (the repo path of the instruction file a CLAUDE.md-key finding cites); top level `pre_existing_counts` (`{SEVERITY: n}`), `resolved` (the previous round's finding ids no longer raised) and `ran` (`{engines: [code-review|security-review], reviewers: [string]}`, written with `finalized: true`). Codes `REVIEW_PRE_EXISTING_MALFORMED`, `REVIEW_ROUND_MALFORMED`, `REVIEW_POLICY_MALFORMED`.
- `## Pre-existing` — the titles and counts of the findings marked `pre_existing`; written by `review-finalize.mjs`.
- `## Resolved since round <n>` — the previous round's findings this round no longer raises; written by `review-finalize.mjs`.
- `## Answers` — the operator's answer to each counting MAJOR, written by `/trekreview` Phase 8.8 after `## Remediation Steps` and before the trailing JSON block: a pipe table with the header exactly `| Finding | Answer | Reason | Record | Carried from |`; per row a 40-hex finding id (optionally `#n`), `accepted` or `fixed`, a reason (non-empty when `accepted`), the 64-hex hash of the K2 `review` record, and `-` or `review-round-<run12>-<n>.md#<id>`; at most one row per finding. A MAJOR answered `fixed` needs its `### Step R<n>` with `Source finding: <id>` under `## Remediation Steps`. Code `REVIEW_ANSWERS_MALFORMED`.
- `## Declined to Judge` gains three reasons: `policy:skip` and `policy:cap` (a MINOR or SUGGESTION finding the `REVIEW.md` policy skipped or capped) and `instruction-out-of-set` (a CLAUDE.md-key finding that cites a file outside the tracked instruction set).
**The brief-conflict mark (fields `engine`, `contradicts_brief`, `brief_lowered_from`).**
Each finding may carry three more fields, in its body field list and in the trailing JSON block:
`engine` (`code-review` | `security-review`, on a finding that came from an engine),
@ -499,11 +512,12 @@ A plan made from the review skips a lowered finding: it is MINOR, and `/trekplan
| Verdict present | strict: error · soft: warning | `verdict` ∈ `ALLOW \| WARN \| BLOCK`; an out-of-enum value is an error in both modes |
| Remediation step per BLOCKER | strict: error · soft: warning | `### Step R<n>` with `Source finding: <id>` under `## Remediation Steps` |
| Body sections | strict only | `Executive Summary`, `Coverage`, `Remediation Summary` |
| 1.2 field shapes | strict: error · soft: warning | at `review_version` ≥ 1.2, each optional field and the `## Answers` table present has its shape; a MAJOR answered `fixed` has its `### Step R<n>` |
| Version format | every read | `review_version` matches `N.M`; warning otherwise |
The validator (`lib/validators/review-validator.mjs`) exposes the same CLI as the others: `node lib/validators/review-validator.mjs --json <review.md>`. Strict mode is the default; `--soft` downgrades section-missing errors to warnings. `/trekreview` Phase 8 runs `--strict`. `/trekplan` Phase 1 (when consuming `--brief review.md`) runs `--soft` so a partially-valid review can still seed a plan.
**Versioning:** current is `1.1` (ordre 5: `verdict` required, remediation steps, Declined to Judge, two engine rule keys; a third, `CODE_REVIEW_IMPROVEMENT`, since lekerepo trial 2 — three engine rule keys). N-1 window: a `1.0` review without `verdict` still validates in soft mode (warning) and still seeds a plan; it fails strict mode and the verdict gate (exit 2), because a missing verdict is never read as ALLOW. Future schema changes follow the breaking-change protocol above.
**Versioning:** current is `1.2`. From 1.1 to 1.2 only optional fields and sections were added (the round, policy and `finalized` frontmatter fields, the `pre_existing`, `carried_from`, `instruction_ref`, `pre_existing_counts`, `resolved` and `ran` JSON fields, `## Answers`, `## Pre-existing` and `## Resolved since round <n>`), so a 1.1 review still validates, and it never opens the merge lock with an unanswered MAJOR, because the verdict gate checks the answers of a 1.1 WARN as it does a 1.2 one. 1.1 (ordre 5) made `verdict` required and added remediation steps, Declined to Judge and two engine rule keys; a third, `CODE_REVIEW_IMPROVEMENT`, came with lekerepo trial 2 — three engine rule keys. N-1 window: a `1.1` review validates unchanged for one minor version, and only with a warning after that; no reader fails on it. A `1.0` review without `verdict` still validates in soft mode (warning) and still seeds a plan; it fails strict mode and the verdict gate (exit 2), because a missing verdict is never read as ALLOW. Future schema changes follow the breaking-change protocol above.
**Failure modes:**
- `REVIEW_NOT_FOUND` → consumer halts with usage message
@ -518,6 +532,10 @@ The validator (`lib/validators/review-validator.mjs`) exposes the same CLI as th
- `REVIEW_MISSING_VERDICT` → strict halt; soft-mode warning (1.0 reviews)
- `REVIEW_BAD_VERDICT` → halt; verdict outside `ALLOW | WARN | BLOCK`
- `REVIEW_MISSING_REMEDIATION_STEP` → strict halt; soft-mode warning
- `REVIEW_ANSWERS_MALFORMED` → strict halt; soft-mode warning (1.2: the `## Answers` table, or a MAJOR answered `fixed` without its R-step)
- `REVIEW_POLICY_MALFORMED` → strict halt; soft-mode warning (1.2: `review_policy`, `review_policy_sha256`, `instruction_ref`)
- `REVIEW_ROUND_MALFORMED` → strict halt; soft-mode warning (1.2: `round`, `round_base`, `finalized`, `resolved`, `ran`, `carried_from`)
- `REVIEW_PRE_EXISTING_MALFORMED` → strict halt; soft-mode warning (1.2: `pre_existing`, `pre_existing_counts`)
---

View file

@ -9,7 +9,7 @@ Imported from `CLAUDE.md` via pointer.
- `lib/util/{frontmatter,result,atomic-write,autonomy-gate}.mjs` — shared YAML-frontmatter parser + Result helpers + `atomicWriteJson(path, obj)` for tmp+rename writes + autonomy-gate state machine (v3.4.0)
- `lib/parsers/{plan-schema,manifest-yaml,project-discovery,arg-parser,bash-normalize,jaccard,finding-id}.mjs` — pure parsers (no I/O), unit-tested. `manifest-yaml` extended in v3.4.0 with additive `skip_commit_check` + `memory_write` flags (forward-compat: unknown keys ignored)
- `lib/review/{rule-catalogue,plan-review-dedup}.mjs` — version-pinned rule catalogue (17 keys) + Phase 9 inline dedup helpers (v3.4.0)
- `lib/review/{verdict-gate,engine-findings,recurrence}.mjs` — the review gate (K7: 0 ALLOW/WARN · 1 BLOCK · 2 never green), Claude Code `/code-review` + `/security-review` findings → reviewer payload, recurring finding classes → operator-approved project-memory lines (review_version 1.1); `lib/review/fill-finding-ids.mjs` computes the coordinator's `@@ID_<n>@@` placeholders over `(file, line, rule_key)`; `lib/validators/brief-review-stamp.mjs` lets `/trekplan` skip a brief review `/trekbrief` already ran on the same text
- `lib/review/{verdict-gate,engine-findings,recurrence}.mjs` — the review gate (K7: 0 ALLOW/WARN · 1 BLOCK · 2 never green), Claude Code `/code-review` + `/security-review` findings → reviewer payload, recurring finding classes → operator-approved project-memory lines (review_version 1.2); `lib/review/fill-finding-ids.mjs` computes the coordinator's `@@ID_<n>@@` placeholders over `(file, line, rule_key)`; `lib/validators/brief-review-stamp.mjs` lets `/trekplan` skip a brief review `/trekbrief` already ran on the same text
- `lib/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (v3.4.0)
- `lib/validators/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`)
- `lib/validators/architecture-discovery.mjs` — drift-WARN external-contract discovery for `architecture/overview.md`

View file

@ -12,6 +12,10 @@
// 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.
//
// review_version 1.2: optional round, policy, pre-existing, ran and `## Answers`
// fields; each one present is checked for shape (`review12Issues`). A 1.1
// review is read as before (N-1 window).
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
@ -67,6 +71,175 @@ function remediationStepSources(body) {
return ids;
}
// ---- review_version 1.2 shapes ------------------------------------------------
// Codes: REVIEW_ANSWERS_MALFORMED (the `## Answers` table, and a MAJOR answered
// `fixed` without its R-step), REVIEW_POLICY_MALFORMED (`review_policy`,
// `review_policy_sha256`, a finding's `instruction_ref`), REVIEW_ROUND_MALFORMED
// (`round`, `round_base`, `finalized`, `resolved`, `ran`, a finding's
// `carried_from`) and REVIEW_PRE_EXISTING_MALFORMED (a finding's `pre_existing`,
// `pre_existing_counts`).
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const SHA256_RE = /^[0-9a-f]{64}$/;
const SEVERITIES = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
const REVIEW_POLICY_VALUES = ['absent', 'applied', 'not-applied'];
const RAN_ENGINES = ['code-review', 'security-review'];
const ANSWERS_HEADER = '| Finding | Answer | Reason | Record | Carried from |';
const ANSWER_FINDING_RE = /^([0-9a-f]{40})(?:#\d+)?$/;
const CARRIED_FROM_RE = /^review-round-[0-9a-f]{12}-\d+\.md#[0-9a-f]{40}$/;
const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
/** The lines of the `## Answers` section (heading excluded), up to the next `## ` heading or a fence; null when absent. */
function answersSectionLines(body) {
const lines = body.split('\n');
const at = lines.findIndex((l) => /^##\s+Answers\s*$/.test(l));
if (at === -1) return null;
const out = [];
for (const l of lines.slice(at + 1)) {
if (/^##\s/.test(l) || /^\s{0,3}(`{3,}|~{3,})/.test(l)) break;
out.push(l);
}
return out;
}
/**
* Parse the `## Answers` table. Returns `{rows, problems}`; rows hold
* `{id, answer, reason, record, carriedFrom}`. An absent section, or one with
* no table, has no rows.
*/
function parseAnswersTable(body) {
const section = answersSectionLines(body);
const rows = [];
const problems = [];
if (section === null) return { rows, problems };
const table = section.map((l) => l.trim()).filter((l) => l.startsWith('|'));
if (table.length === 0) return { rows, problems };
if (table[0] !== ANSWERS_HEADER) {
problems.push(`## Answers header must be exactly "${ANSWERS_HEADER}", got ${JSON.stringify(table[0])}`);
return { rows, problems };
}
if (table.length < 2 || !/^\|(?:\s*:?-{3,}:?\s*\|){5}$/.test(table[1])) {
problems.push('## Answers table has no separator line of five columns under its header');
return { rows, problems };
}
const seen = new Set();
table.slice(2).forEach((line, i) => {
const where = `## Answers row ${i + 1}`;
if (!line.endsWith('|')) { problems.push(`${where} does not end with "|"`); return; }
const cells = line.slice(1, -1).split('|').map((c) => c.trim());
if (cells.length !== 5) { problems.push(`${where} has ${cells.length} cells, not 5`); return; }
const [finding, answer, reason, record, carriedFrom] = cells;
const m = finding.match(ANSWER_FINDING_RE);
if (!m) problems.push(`${where}: Finding ${JSON.stringify(finding)} is not a 40-char hex id (optionally #n)`);
if (answer !== 'accepted' && answer !== 'fixed') {
problems.push(`${where}: Answer must be accepted|fixed, got ${JSON.stringify(answer)}`);
}
if (answer === 'accepted' && reason === '') problems.push(`${where}: an accepted answer needs a non-empty Reason`);
if (!SHA256_RE.test(record)) problems.push(`${where}: Record ${JSON.stringify(record)} is not a 64-char hex record hash`);
if (carriedFrom !== '-' && !CARRIED_FROM_RE.test(carriedFrom)) {
problems.push(`${where}: Carried from must be "-" or review-round-<run12>-<n>.md#<id>, got ${JSON.stringify(carriedFrom)}`);
}
if (m) {
if (seen.has(m[1])) problems.push(`${where}: a second row for finding ${m[1]} (at most one row per finding)`);
seen.add(m[1]);
rows.push({ id: m[1], answer, reason, record, carriedFrom });
}
});
return { rows, problems };
}
/** The parsed trailing JSON block, or null when absent or unparseable (other checks report those). */
function findingsPayload(body) {
const last = extractFindingsBlock(body);
if (last === null) return null;
try { return JSON.parse(last); } catch { return null; }
}
/** Issues for the review_version 1.2 fields that are present. Absent fields are never an issue. */
function review12Issues(fm, body) {
const out = [];
const round = (msg) => out.push(issue('REVIEW_ROUND_MALFORMED', msg));
const policy = (msg) => out.push(issue('REVIEW_POLICY_MALFORMED', msg));
const pre = (msg) => out.push(issue('REVIEW_PRE_EXISTING_MALFORMED', msg));
const answers = (msg) => out.push(issue('REVIEW_ANSWERS_MALFORMED', msg));
// Frontmatter.
if ('round' in fm && !(Number.isInteger(fm.round) && fm.round >= 1 && fm.round <= 3)) {
round(`frontmatter.round must be an integer 1-3, got ${JSON.stringify(fm.round)}`);
}
if ('round_base' in fm && fm.round_base !== null
&& !(typeof fm.round_base === 'string' && FULL_SHA_RE.test(fm.round_base))) {
round(`frontmatter.round_base must be a full 40- or 64-hex commit id or null, got ${JSON.stringify(fm.round_base)}`);
}
if ('finalized' in fm && typeof fm.finalized !== 'boolean') {
round(`frontmatter.finalized must be a boolean, got ${JSON.stringify(fm.finalized)}`);
}
if ('review_policy' in fm && !REVIEW_POLICY_VALUES.includes(fm.review_policy)) {
policy(`frontmatter.review_policy must be one of ${REVIEW_POLICY_VALUES.join('|')}, got ${JSON.stringify(fm.review_policy)}`);
}
if ('review_policy_sha256' in fm
&& !(fm.review_policy_sha256 === 'none' || (typeof fm.review_policy_sha256 === 'string' && SHA256_RE.test(fm.review_policy_sha256)))) {
policy(`frontmatter.review_policy_sha256 must be 64-char hex or none, got ${JSON.stringify(fm.review_policy_sha256)}`);
}
// Trailing JSON block.
const payload = findingsPayload(body);
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
findings.forEach((f, i) => {
if (!isPlainObject(f)) return;
if ('pre_existing' in f && typeof f.pre_existing !== 'boolean') {
pre(`findings[${i}].pre_existing must be a boolean, got ${JSON.stringify(f.pre_existing)}`);
}
if ('carried_from' in f && !(typeof f.carried_from === 'string' && HEX_ID_RE.test(f.carried_from))) {
round(`findings[${i}].carried_from must be a 40-char hex finding id, got ${JSON.stringify(f.carried_from)}`);
}
if ('instruction_ref' in f && typeof f.instruction_ref !== 'string') {
policy(`findings[${i}].instruction_ref must be a string, got ${JSON.stringify(f.instruction_ref)}`);
}
});
if (isPlainObject(payload)) {
if ('pre_existing_counts' in payload) {
const c = payload.pre_existing_counts;
const okCounts = isPlainObject(c) && Object.entries(c)
.every(([k, n]) => SEVERITIES.includes(k) && Number.isInteger(n) && n >= 0);
if (!okCounts) pre(`pre_existing_counts must map ${SEVERITIES.join('|')} to counts ≥ 0, got ${JSON.stringify(c)}`);
}
if ('resolved' in payload) {
const r = payload.resolved;
if (!(Array.isArray(r) && r.every((id) => typeof id === 'string' && HEX_ID_RE.test(id)))) {
round(`resolved must be an array of 40-char hex finding ids, got ${JSON.stringify(r)}`);
}
}
if ('ran' in payload) {
const r = payload.ran;
const okRan = isPlainObject(r)
&& Array.isArray(r.engines) && r.engines.every((e) => RAN_ENGINES.includes(e))
&& Array.isArray(r.reviewers) && r.reviewers.every((s) => typeof s === 'string');
if (!okRan) {
round(`ran must be {engines: subset of ${RAN_ENGINES.join('|')}, reviewers: [string]}, got ${JSON.stringify(r)}`);
}
}
}
// ## Answers.
const { rows, problems } = parseAnswersTable(body);
for (const p of problems) answers(p);
const majors = new Set(findings
.filter((f) => isPlainObject(f) && typeof f.id === 'string' && String(f.severity).toUpperCase() === 'MAJOR')
.map((f) => f.id));
const fixedMajors = rows.filter((r) => r.answer === 'fixed' && majors.has(r.id));
if (fixedMajors.length > 0) {
const steps = remediationStepSources(body);
for (const r of fixedMajors) {
if (!steps.has(r.id)) {
answers(`MAJOR ${r.id} is answered fixed but no "### Step R<n>" under ## Remediation Steps has "Source finding: ${r.id}"`);
}
}
}
return out;
}
export function validateReviewContent(text, opts = {}) {
const strict = opts.strict !== false;
const doc = parseDocument(text);
@ -152,6 +325,16 @@ export function validateReviewContent(text, opts = {}) {
else warnings.push(issueObj);
}
// 1.2+: every new field is optional; a malformed one present is an error
// (strict) or a warning (soft). A 1.1 file is read as before (N-1 window).
const atLeast12 = v && (Number(v[1]) > 1 || (Number(v[1]) === 1 && Number(v[2]) >= 2));
if (atLeast12) {
for (const issueObj of review12Issues(fm, body)) {
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)) {

View file

@ -1,6 +1,6 @@
---
type: trekreview
review_version: "1.1"
review_version: "1.2"
created: {YYYY-MM-DD}
task: "{Task description from brief.md}"
slug: {project-slug}
@ -10,6 +10,13 @@ scope_sha_start: {sha-from-progress.json/session_start_sha-OR-null-if-mtime-fall
scope_sha_end: {sha-of-HEAD-at-review-time}
reviewed_files_count: {N}
verdict: BLOCK
# Optional (review_version 1.2): written by review-finalize.mjs, never by the coordinator.
round: 1
round_base: null
review_policy: absent
review_policy_note: "no REVIEW.md policy block at scope_sha_start"
review_policy_sha256: none
finalized: true
findings:
- 0123456789abcdef0123456789abcdef01234567
- fedcba9876543210fedcba9876543210fedcba98
@ -95,6 +102,18 @@ should look at first.
bug but worth noting for v1.1 cleanup.
- recommended_action: Track in TODO.md if not already.
## Pre-existing
<!-- Optional (review_version 1.2). Written by review-finalize.mjs: the titles and counts of the findings on lines this range did not add or change (`pre_existing: true`). -->
None.
## Resolved since round {n}
<!-- Optional (review_version 1.2), from round 2. Written by review-finalize.mjs: the previous round's findings this round no longer raises. -->
None.
## Declined to Judge
None.
@ -132,10 +151,20 @@ becomes the step intent; each BLOCKER's `## Remediation Steps` entry is
ready to append as a plan step). MINOR + SUGGESTION are skipped for
plan-input.
## Answers
<!-- Optional (review_version 1.2). Written by /trekreview Phase 8.8 (review-answers.mjs record), one row per counting MAJOR the operator answered: the 40-hex finding id, accepted or fixed, the reason, the 64-hex K2 record hash, and - or review-round-<run12>-<n>.md#<id>. -->
| Finding | Answer | Reason | Record | Carried from |
|---|---|---|---|---|
```json
{
"verdict": "BLOCK",
"counts": { "BLOCKER": 1, "MAJOR": 1, "MINOR": 1, "SUGGESTION": 1 },
"pre_existing_counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
"resolved": [],
"ran": { "engines": ["code-review", "security-review"], "reviewers": ["brief-conformance-reviewer", "code-correctness-reviewer"] },
"allow_blocked_by": [],
"declined_to_judge": [],
"trimmed": 0,

View file

@ -0,0 +1,74 @@
---
type: trekreview
review_version: "1.1"
created: 2026-10-30
task: "Add a retry to the HTTP client"
slug: http-retry
project_dir: .claude/projects/2026-10-30-http-retry/
brief_path: .claude/projects/2026-10-30-http-retry/brief.md
scope_sha_start: "9c7415404313b37eb41902654837df5c93d2b5d7"
scope_sha_end: "801dd95f7372095cc06b9a6a1984f86038c5886b"
reviewed_files_count: 2
verdict: WARN
findings:
- 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
---
# Review: Add a retry to the HTTP client
## Executive Summary
Verdict: WARN. One MAJOR finding: the retry loop has no upper bound on its total wait.
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| lib/http.mjs | deep-review | risk |
| lib/config.mjs | summary-only | default |
## Findings (MAJOR)
### 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/http.mjs:41`
- **Title:** The retry loop has no total time limit
- **Recommended action:** Stop retrying once the total wait passes the configured limit.
## Declined to Judge
None.
Trimmed: 0
## Remediation Summary
One MAJOR finding; no BLOCKER, so no remediation step is required.
```json
{
"verdict": "WARN",
"counts": {
"BLOCKER": 0,
"MAJOR": 1,
"MINOR": 0,
"SUGGESTION": 0
},
"allow_blocked_by": [],
"findings": [
{
"id": "77bc675af2ef7b4524b6a21b6fb23190bb56a32f",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/http.mjs",
"line": 41,
"title": "The retry loop has no total time limit",
"detail": "A server that keeps answering 503 keeps the caller waiting without end.",
"recommended_action": "Stop retrying once the total wait passes the configured limit.",
"engine": "code-review",
"reviewer": "claude-code-review"
}
]
}
```

View file

@ -0,0 +1,154 @@
---
type: trekreview
review_version: "1.2"
created: 2026-11-02
task: "Add a retry to the HTTP client"
slug: http-retry
project_dir: .claude/projects/2026-10-30-http-retry/
brief_path: .claude/projects/2026-10-30-http-retry/brief.md
scope_sha_start: "9c7415404313b37eb41902654837df5c93d2b5d7"
scope_sha_end: "801dd95f7372095cc06b9a6a1984f86038c5886b"
reviewed_files_count: 2
verdict: WARN
round: 1
round_base: null
review_policy: applied
review_policy_note: "max-minor 10, two skip paths"
review_policy_sha256: "823412d1eacb67956220e532959f0104603057c88704863ca38e7cd188fda812"
finalized: true
findings:
- 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
- e1fa7656c24e23e316f96169884da5f15059beb9
- bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
---
# Review: Add a retry to the HTTP client
## Executive Summary
Verdict: WARN. Two MAJOR findings on lines this range changed, one answered `accepted` and one `fixed`; one
pre-existing MAJOR on a line the range did not change.
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| lib/http.mjs | deep-review | risk |
| lib/config.mjs | summary-only | default |
## Findings (MAJOR)
### 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/http.mjs:41`
- **Title:** The retry loop has no total time limit
- **Recommended action:** Stop retrying once the total wait passes the configured limit.
### e1fa7656c24e23e316f96169884da5f15059beb9
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/http.mjs:58`
- **Title:** A 429 answer is retried without reading Retry-After
- **Recommended action:** Wait at least the Retry-After value before the next attempt.
### bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/config.mjs:12`
- **Title:** The timeout default is read as seconds and used as milliseconds
- **Pre-existing:** yes (the range did not add or change this line)
## Pre-existing
| Severity | Count |
|----------|-------|
| MAJOR | 1 |
- The timeout default is read as seconds and used as milliseconds (`lib/config.mjs:12`)
## Declined to Judge
None.
Trimmed: 0
## Remediation Summary
Two counting MAJOR findings, both answered; no BLOCKER.
## Remediation Steps
### Step R1: Honour Retry-After on a 429 answer
- **Files:** `lib/http.mjs`
- **Source finding:** `e1fa7656c24e23e316f96169884da5f15059beb9`
- **Test first:** a 429 with `Retry-After: 2` waits at least two seconds before the next attempt
- **Verify:** re-run /trekreview → finding `e1fa7656c24e23e316f96169884da5f15059beb9` absent
- **On failure:** revert
## Answers
| Finding | Answer | Reason | Record | Carried from |
|---|---|---|---|---|
| 77bc675af2ef7b4524b6a21b6fb23190bb56a32f | maybe | The caller sets its own deadline; a total limit here is a follow-up. | c97c121a37e664a6ccbe874f9b0afb22e21661305772e0c2939e61ddfdb74924 | - |
| e1fa7656c24e23e316f96169884da5f15059beb9 | fixed | Fixed by Step R1. | 191904440c3a567c054e56ee1831204c422553732b255ad562f7034e54543554 | - |
```json
{
"verdict": "WARN",
"counts": {
"BLOCKER": 0,
"MAJOR": 3,
"MINOR": 0,
"SUGGESTION": 0
},
"pre_existing_counts": {"BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0},
"resolved": [],
"ran": {
"engines": ["code-review", "security-review"],
"reviewers": ["brief-conformance-reviewer", "code-correctness-reviewer"]
},
"allow_blocked_by": [],
"findings": [
{
"id": "77bc675af2ef7b4524b6a21b6fb23190bb56a32f",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/http.mjs",
"line": 41,
"title": "The retry loop has no total time limit",
"detail": "A server that keeps answering 503 keeps the caller waiting without end.",
"recommended_action": "Stop retrying once the total wait passes the configured limit.",
"engine": "code-review",
"reviewer": "claude-code-review",
"pre_existing": false
},
{
"id": "e1fa7656c24e23e316f96169884da5f15059beb9",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/http.mjs",
"line": 58,
"title": "A 429 answer is retried without reading Retry-After",
"detail": "The client retries a 429 at once, so a rate-limited server is hit again before it allows it.",
"recommended_action": "Wait at least the Retry-After value before the next attempt.",
"engine": "code-review",
"reviewer": "claude-code-review"
},
{
"id": "bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/config.mjs",
"line": 12,
"title": "The timeout default is read as seconds and used as milliseconds",
"detail": "The default of 30 becomes a 30 ms timeout.",
"recommended_action": "Multiply the default by 1000 where it is read.",
"engine": "code-review",
"reviewer": "claude-code-review",
"pre_existing": true
}
]
}
```

View file

@ -0,0 +1,154 @@
---
type: trekreview
review_version: "1.2"
created: 2026-11-02
task: "Add a retry to the HTTP client"
slug: http-retry
project_dir: .claude/projects/2026-10-30-http-retry/
brief_path: .claude/projects/2026-10-30-http-retry/brief.md
scope_sha_start: "9c7415404313b37eb41902654837df5c93d2b5d7"
scope_sha_end: "801dd95f7372095cc06b9a6a1984f86038c5886b"
reviewed_files_count: 2
verdict: WARN
round: 1
round_base: null
review_policy: applied
review_policy_note: "max-minor 10, two skip paths"
review_policy_sha256: xyz
finalized: true
findings:
- 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
- e1fa7656c24e23e316f96169884da5f15059beb9
- bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
---
# Review: Add a retry to the HTTP client
## Executive Summary
Verdict: WARN. Two MAJOR findings on lines this range changed, one answered `accepted` and one `fixed`; one
pre-existing MAJOR on a line the range did not change.
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| lib/http.mjs | deep-review | risk |
| lib/config.mjs | summary-only | default |
## Findings (MAJOR)
### 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/http.mjs:41`
- **Title:** The retry loop has no total time limit
- **Recommended action:** Stop retrying once the total wait passes the configured limit.
### e1fa7656c24e23e316f96169884da5f15059beb9
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/http.mjs:58`
- **Title:** A 429 answer is retried without reading Retry-After
- **Recommended action:** Wait at least the Retry-After value before the next attempt.
### bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/config.mjs:12`
- **Title:** The timeout default is read as seconds and used as milliseconds
- **Pre-existing:** yes (the range did not add or change this line)
## Pre-existing
| Severity | Count |
|----------|-------|
| MAJOR | 1 |
- The timeout default is read as seconds and used as milliseconds (`lib/config.mjs:12`)
## Declined to Judge
None.
Trimmed: 0
## Remediation Summary
Two counting MAJOR findings, both answered; no BLOCKER.
## Remediation Steps
### Step R1: Honour Retry-After on a 429 answer
- **Files:** `lib/http.mjs`
- **Source finding:** `e1fa7656c24e23e316f96169884da5f15059beb9`
- **Test first:** a 429 with `Retry-After: 2` waits at least two seconds before the next attempt
- **Verify:** re-run /trekreview → finding `e1fa7656c24e23e316f96169884da5f15059beb9` absent
- **On failure:** revert
## Answers
| Finding | Answer | Reason | Record | Carried from |
|---|---|---|---|---|
| 77bc675af2ef7b4524b6a21b6fb23190bb56a32f | accepted | The caller sets its own deadline; a total limit here is a follow-up. | c97c121a37e664a6ccbe874f9b0afb22e21661305772e0c2939e61ddfdb74924 | - |
| e1fa7656c24e23e316f96169884da5f15059beb9 | fixed | Fixed by Step R1. | 191904440c3a567c054e56ee1831204c422553732b255ad562f7034e54543554 | - |
```json
{
"verdict": "WARN",
"counts": {
"BLOCKER": 0,
"MAJOR": 3,
"MINOR": 0,
"SUGGESTION": 0
},
"pre_existing_counts": {"BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0},
"resolved": [],
"ran": {
"engines": ["code-review", "security-review"],
"reviewers": ["brief-conformance-reviewer", "code-correctness-reviewer"]
},
"allow_blocked_by": [],
"findings": [
{
"id": "77bc675af2ef7b4524b6a21b6fb23190bb56a32f",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/http.mjs",
"line": 41,
"title": "The retry loop has no total time limit",
"detail": "A server that keeps answering 503 keeps the caller waiting without end.",
"recommended_action": "Stop retrying once the total wait passes the configured limit.",
"engine": "code-review",
"reviewer": "claude-code-review",
"pre_existing": false
},
{
"id": "e1fa7656c24e23e316f96169884da5f15059beb9",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/http.mjs",
"line": 58,
"title": "A 429 answer is retried without reading Retry-After",
"detail": "The client retries a 429 at once, so a rate-limited server is hit again before it allows it.",
"recommended_action": "Wait at least the Retry-After value before the next attempt.",
"engine": "code-review",
"reviewer": "claude-code-review"
},
{
"id": "bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/config.mjs",
"line": 12,
"title": "The timeout default is read as seconds and used as milliseconds",
"detail": "The default of 30 becomes a 30 ms timeout.",
"recommended_action": "Multiply the default by 1000 where it is read.",
"engine": "code-review",
"reviewer": "claude-code-review",
"pre_existing": true
}
]
}
```

View file

@ -0,0 +1,154 @@
---
type: trekreview
review_version: "1.2"
created: 2026-11-02
task: "Add a retry to the HTTP client"
slug: http-retry
project_dir: .claude/projects/2026-10-30-http-retry/
brief_path: .claude/projects/2026-10-30-http-retry/brief.md
scope_sha_start: "9c7415404313b37eb41902654837df5c93d2b5d7"
scope_sha_end: "801dd95f7372095cc06b9a6a1984f86038c5886b"
reviewed_files_count: 2
verdict: WARN
round: 1
round_base: null
review_policy: applied
review_policy_note: "max-minor 10, two skip paths"
review_policy_sha256: "823412d1eacb67956220e532959f0104603057c88704863ca38e7cd188fda812"
finalized: true
findings:
- 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
- e1fa7656c24e23e316f96169884da5f15059beb9
- bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
---
# Review: Add a retry to the HTTP client
## Executive Summary
Verdict: WARN. Two MAJOR findings on lines this range changed, one answered `accepted` and one `fixed`; one
pre-existing MAJOR on a line the range did not change.
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| lib/http.mjs | deep-review | risk |
| lib/config.mjs | summary-only | default |
## Findings (MAJOR)
### 77bc675af2ef7b4524b6a21b6fb23190bb56a32f
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/http.mjs:41`
- **Title:** The retry loop has no total time limit
- **Recommended action:** Stop retrying once the total wait passes the configured limit.
### e1fa7656c24e23e316f96169884da5f15059beb9
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/http.mjs:58`
- **Title:** A 429 answer is retried without reading Retry-After
- **Recommended action:** Wait at least the Retry-After value before the next attempt.
### bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb
- **Rule:** `CODE_REVIEW_DEFECT`
- **Location:** `lib/config.mjs:12`
- **Title:** The timeout default is read as seconds and used as milliseconds
- **Pre-existing:** yes (the range did not add or change this line)
## Pre-existing
| Severity | Count |
|----------|-------|
| MAJOR | 1 |
- The timeout default is read as seconds and used as milliseconds (`lib/config.mjs:12`)
## Declined to Judge
None.
Trimmed: 0
## Remediation Summary
Two counting MAJOR findings, both answered; no BLOCKER.
## Remediation Steps
### Step R1: Honour Retry-After on a 429 answer
- **Files:** `lib/http.mjs`
- **Source finding:** `e1fa7656c24e23e316f96169884da5f15059beb9`
- **Test first:** a 429 with `Retry-After: 2` waits at least two seconds before the next attempt
- **Verify:** re-run /trekreview → finding `e1fa7656c24e23e316f96169884da5f15059beb9` absent
- **On failure:** revert
## Answers
| Finding | Answer | Reason | Record | Carried from |
|---|---|---|---|---|
| 77bc675af2ef7b4524b6a21b6fb23190bb56a32f | accepted | The caller sets its own deadline; a total limit here is a follow-up. | c97c121a37e664a6ccbe874f9b0afb22e21661305772e0c2939e61ddfdb74924 | - |
| e1fa7656c24e23e316f96169884da5f15059beb9 | fixed | Fixed by Step R1. | 191904440c3a567c054e56ee1831204c422553732b255ad562f7034e54543554 | - |
```json
{
"verdict": "WARN",
"counts": {
"BLOCKER": 0,
"MAJOR": 3,
"MINOR": 0,
"SUGGESTION": 0
},
"pre_existing_counts": {"BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0},
"resolved": [],
"ran": {
"engines": ["code-review", "security-review"],
"reviewers": ["brief-conformance-reviewer", "code-correctness-reviewer"]
},
"allow_blocked_by": [],
"findings": [
{
"id": "77bc675af2ef7b4524b6a21b6fb23190bb56a32f",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/http.mjs",
"line": 41,
"title": "The retry loop has no total time limit",
"detail": "A server that keeps answering 503 keeps the caller waiting without end.",
"recommended_action": "Stop retrying once the total wait passes the configured limit.",
"engine": "code-review",
"reviewer": "claude-code-review",
"pre_existing": false
},
{
"id": "e1fa7656c24e23e316f96169884da5f15059beb9",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/http.mjs",
"line": 58,
"title": "A 429 answer is retried without reading Retry-After",
"detail": "The client retries a 429 at once, so a rate-limited server is hit again before it allows it.",
"recommended_action": "Wait at least the Retry-After value before the next attempt.",
"engine": "code-review",
"reviewer": "claude-code-review"
},
{
"id": "bd517c3b965addbf8db5fdc6c04478e3e0f3c6cb",
"severity": "MAJOR",
"rule_key": "CODE_REVIEW_DEFECT",
"file": "lib/config.mjs",
"line": 12,
"title": "The timeout default is read as seconds and used as milliseconds",
"detail": "The default of 30 becomes a 30 ms timeout.",
"recommended_action": "Multiply the default by 1000 where it is read.",
"engine": "code-review",
"reviewer": "claude-code-review",
"pre_existing": true
}
]
}
```

View file

@ -126,7 +126,7 @@ test('[M4] Handover 6 names the review-not-measured reason', () => {
test('[M4] Handover 6 lists review_version 1.1 as current', () => {
const row = fieldRow('review_version');
assert.match(row, /"1\.1"`? \(current\)/, `the review_version row does not list 1.1 as current: ${row}`);
assert.match(row, /"1\.2"`? \(current\)/, `the review_version row does not list 1.2 as current: ${row}`);
assert.doesNotMatch(row, /"1\.0"`? \(current\)/, `the review_version row still lists 1.0 as current: ${row}`);
});

View file

@ -0,0 +1,116 @@
// tests/lib/fix-round-4-5b2-b-prose.test.mjs
// Prose pins for fix round 4.5b2, part B (plan
// .claude/projects/2026-10-31-review-gate-4-5b2): the parts of the round that
// are text a session reads. The tag in brackets is the part-B id from the
// round's plan; each tagged test fails on an assertion at the step's base.
// V12 review_version 1.2 is current: Handover 6's table and field row name
// it, no line of docs/HANDOVER-CONTRACTS.md still names 1.1 as current,
// the Versioning paragraph says what 1.1 -> 1.2 changed, MIGRATION.md
// holds the 1.2 section with its N-1 window, and every other place that
// states the current review_version says 1.2.
// The helpers below are copied, never imported from an earlier prose test.
// Fix the SOURCE a pin guards, not the pin.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const readCache = new Map();
/** A repo file's text; the file must exist. Read from disk once per run. */
// Copied from tests/lib/fix-round-4-5b2-a-prose.test.mjs (read).
function read(rel) {
if (!readCache.has(rel)) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
}
return readCache.get(rel);
}
/** Whitespace (line breaks too) collapsed to one space. */
// Copied from tests/lib/fix-round-4-5b2-a-prose.test.mjs (flat).
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** The text from `heading` up to the next `## ` heading; the heading must exist. */
// Copied from tests/lib/review-prose-pins.test.mjs (section).
function section(text, heading) {
const start = text.indexOf(heading);
assert.ok(start >= 0, `heading missing: ${heading}`);
const next = text.indexOf('\n## ', start + heading.length);
return text.slice(start, next === -1 ? undefined : next);
}
/** Every sentence of `text` (flattened). */
const sentences = (text) => flat(text).split(/(?<=\.)\s+(?=[A-Z`"*(])/);
const CONTRACTS = 'docs/HANDOVER-CONTRACTS.md';
// --- V12: review_version 1.2 is current ------------------------------------------
test('[V12] docs/HANDOVER-CONTRACTS.md names 1.2 as current in the table and the row, and no line names 1.1 as current', () => {
const text = read(CONTRACTS);
const tableRows = text.split('\n').filter((l) => l.startsWith('| `review.md` | `review_version`'));
assert.equal(tableRows.length, 1, `the versioning table has one review.md row; found ${tableRows.length}`);
assert.match(tableRows[0], /\|\s*`1\.2`\s*\|\s*$/, `the versioning table's review.md row says 1.2 is current: ${tableRows[0]}`);
const h6 = section(text, '## Handover 6');
const fieldRows = h6.split('\n').filter((l) => l.startsWith('| `review_version` |'));
assert.equal(fieldRows.length, 1, `Handover 6 has one review_version row; found ${fieldRows.length}`);
assert.match(fieldRows[0], /"1\.2"`? \(current\)/, `the review_version row lists 1.2 as current: ${fieldRows[0]}`);
const stale = text.split('\n').filter((l) =>
/1\.1"?`?\s*\(current\)/.test(l)
|| /current is `?1\.1\b/.test(l)
|| (l.startsWith('| `review.md` | `review_version`') && /`1\.1`/.test(l)));
assert.deepEqual(stale, [], `no line of ${CONTRACTS} may name review_version 1.1 as current`);
});
test('[V12] the Handover 6 Versioning paragraph has the 1.1 -> 1.2 sentence', () => {
const h6 = section(read(CONTRACTS), '## Handover 6');
const paras = h6.split('\n').filter((l) => l.startsWith('**Versioning:**'));
assert.equal(paras.length, 1, `Handover 6 has one Versioning paragraph; found ${paras.length}`);
const para = flat(paras[0]);
assert.match(para, /current is `1\.2`/, `the Versioning paragraph says current is 1.2: ${para}`);
const hits = sentences(para).filter((s) => /1\.1/.test(s) && /1\.2/.test(s));
assert.ok(hits.length > 0, `no sentence of the Versioning paragraph names both 1.1 and 1.2: ${para}`);
assert.ok(hits.some((s) => /still validates/.test(s)),
`the 1.1 -> 1.2 sentence says a 1.1 review still validates: ${hits.join(' | ')}`);
assert.ok(hits.some((s) => /never opens/.test(s) && /unanswered MAJOR/.test(s) && /lock/.test(s)),
`the 1.1 -> 1.2 sentence says a 1.1 review never opens the lock with an unanswered MAJOR: ${hits.join(' | ')}`);
});
test('[V12] MIGRATION.md has the review_version 1.2 section with its N-1 window', () => {
const text = read('MIGRATION.md');
const heads = text.split('\n').filter((l) => /^## review_version 1\.2\b/.test(l));
assert.deepEqual(heads, ['## review_version 1.2 (from the next release)'],
'MIGRATION.md has exactly one "## review_version 1.2 (from the next release)" section');
const body = flat(section(text, heads[0]));
assert.match(body, /\bN-1\b/, `the 1.2 section names the N-1 window: ${body}`);
assert.match(body, /\b1\.1\b/, `the 1.2 section says what happens to a 1.1 review: ${body}`);
});
test('[V12] every current review_version statement names 1.2', () => {
const tpl = read('templates/trekreview-template.md');
assert.match(tpl.split('\n')[2], /^review_version: "1\.2"$/, 'the template\'s frontmatter says review_version "1.2"');
assert.ok(!tpl.includes('review_version: "1.1"'), 'the template no longer writes review_version "1.1"');
const coord = read('agents/review-coordinator.md');
assert.ok(coord.includes('review_version: "1.2"'), 'the coordinator writes review_version "1.2"');
assert.ok(!coord.includes('review_version: "1.1"'), 'the coordinator no longer writes review_version "1.1"');
const cmd = read('commands/trekreview.md');
assert.match(cmd, /\*\*The review is a gate \(review_version 1\.2,/, '/trekreview says the review is a gate at review_version 1.2');
assert.doesNotMatch(cmd, /\(review_version 1\.1,/, '/trekreview no longer says review_version 1.1 is current');
const claude = read('CLAUDE.md');
assert.ok(claude.includes('**Review is a gate (review_version 1.2).**'), 'CLAUDE.md says the review gate is review_version 1.2');
assert.ok(!claude.includes('(review_version 1.1)'), 'CLAUDE.md no longer names review_version 1.1');
const arch = read('docs/architecture.md');
assert.ok(arch.includes('review_version 1.2'), 'docs/architecture.md names review_version 1.2');
assert.ok(!arch.includes('review_version 1.1'), 'docs/architecture.md no longer names review_version 1.1');
});

View file

@ -76,7 +76,7 @@ test('coordinator — writes ## Declined to Judge with the trim count', () => {
// ---- K7: the verdict in the file, and the gate -------------------------------
test('coordinator — review_version 1.1 frontmatter carries verdict', () => {
assert.ok(COORD.includes('review_version: "1.1"'));
assert.ok(COORD.includes('review_version: "1.2"'));
assert.match(COORD, /\nverdict: \{BLOCK \| WARN \| ALLOW\}\n/);
});

View file

@ -0,0 +1,116 @@
// tests/lib/review-validator-1-2.test.mjs
// Fix round 4.5b2 part B, Step 2 (tag V12): review_version 1.2 validates, and
// a 1.1 review still does (N-1 window). Every new 1.2 field is optional; a
// malformed one present is a strict error (a soft warning in --soft) with one
// of four codes. Each [V12] case fails at its base on an assertion: the
// fixtures are new, so each is read only after `assert.ok(existsSync(p))`,
// and the base validator knows no 1.2 field, so a malformed one passes there.
// The kept behaviour (a 1.1 review validates, unknown keys are tolerated)
// sits inside the tagged tests as "Control:" assertions.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateReviewContent } from '../../lib/validators/review-validator.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const FIXTURES = 'tests/fixtures/review-version';
/** A fixture's text; the fixture must exist (they are new in this step). */
function fixture(name) {
const p = join(ROOT, FIXTURES, name);
assert.ok(existsSync(p), `missing fixture ${FIXTURES}/${name}`);
return readFileSync(p, 'utf-8');
}
const codes = (list) => list.map((i) => i.code);
// The ids the 1.2 fixture uses (written down here, independent of the validator).
const FIXED_ID = 'e1fa7656c24e23e316f96169884da5f15059beb9';
/** `text` with `from` replaced once; `from` must be in it, or the mutation proves nothing. */
function mutate(text, from, to) {
assert.ok(text.includes(from), `mutation source missing from the fixture: ${JSON.stringify(from)}`);
return text.replace(from, to);
}
test('[V12] the 1.2 fixture validates strictly and the 1.1 fixture validates', () => {
const v12 = fixture('review-1-2.md');
assert.match(v12, /^review_version: "1\.2"$/m, 'the 1.2 fixture declares review_version "1.2"');
const r = validateReviewContent(v12, { strict: true });
assert.equal(r.valid, true, `the 1.2 fixture is valid in strict mode: ${JSON.stringify(r.errors)}`);
assert.deepEqual(r.errors, []);
// The 1.2 fields of the fixture are read, not merely tolerated: the same file
// with an out-of-range round is refused.
const badRound = mutate(v12, '\nround: 1\n', '\nround: 4\n');
const rb = validateReviewContent(badRound, { strict: true });
assert.equal(rb.valid, false, 'round: 4 is outside 1-3 and must be refused at 1.2');
assert.ok(codes(rb.errors).includes('REVIEW_ROUND_MALFORMED'), JSON.stringify(rb.errors));
// Control: a 1.1 review validates as before (N-1 window), strict and soft.
const v11 = fixture('review-1-1.md');
assert.match(v11, /^review_version: "1\.1"$/m, 'the 1.1 fixture declares review_version "1.1"');
assert.match(v11, /^verdict: WARN$/m, 'the 1.1 fixture is a WARN review');
const r11 = validateReviewContent(v11, { strict: true });
assert.equal(r11.valid, true, `the 1.1 fixture is valid in strict mode: ${JSON.stringify(r11.errors)}`);
assert.equal(validateReviewContent(v11, { strict: false }).valid, true, 'the 1.1 fixture is valid in soft mode');
// Control: at 1.1 the new keys are unknown keys, and unknown keys stay tolerated.
const v11Extra = mutate(v11, '\nverdict: WARN\n', '\nverdict: WARN\nround: 9\nreview_policy_sha256: xyz\n');
assert.equal(validateReviewContent(v11Extra, { strict: true }).valid, true,
'a 1.1 review is not held to the 1.2 field shapes');
});
test('[V12] the bad-answers and bad-policy fixtures fail with their codes', () => {
const badAnswers = fixture('review-1-2-bad-answers.md');
assert.match(badAnswers, /^\| [0-9a-f]{40} \| maybe \|/m, 'the bad-answers fixture has a row answered "maybe"');
const ra = validateReviewContent(badAnswers, { strict: true });
assert.equal(ra.valid, false, 'an ## Answers row with answer "maybe" is refused in strict mode');
assert.ok(codes(ra.errors).includes('REVIEW_ANSWERS_MALFORMED'), JSON.stringify(ra.errors));
const sa = validateReviewContent(badAnswers, { strict: false });
assert.equal(sa.valid, true, 'soft mode reads it with a warning, not an error');
assert.ok(codes(sa.warnings).includes('REVIEW_ANSWERS_MALFORMED'), JSON.stringify(sa.warnings));
const badPolicy = fixture('review-1-2-bad-policy.md');
assert.match(badPolicy, /^review_policy_sha256: xyz$/m, 'the bad-policy fixture has review_policy_sha256: xyz');
const rp = validateReviewContent(badPolicy, { strict: true });
assert.equal(rp.valid, false, 'review_policy_sha256: xyz is refused in strict mode');
assert.ok(codes(rp.errors).includes('REVIEW_POLICY_MALFORMED'), JSON.stringify(rp.errors));
const sp = validateReviewContent(badPolicy, { strict: false });
assert.equal(sp.valid, true, 'soft mode reads it with a warning, not an error');
assert.ok(codes(sp.warnings).includes('REVIEW_POLICY_MALFORMED'), JSON.stringify(sp.warnings));
});
test('[V12] a `fixed` answer without its R-step fails', () => {
const v12 = fixture('review-1-2.md');
assert.match(v12, new RegExp(`^\\| ${FIXED_ID} \\| fixed \\|`, 'm'), 'the 1.2 fixture answers one MAJOR "fixed"');
// Remove the one ### Step R<n> that names the fixed finding.
const step = v12.match(/^### Step R1\b[\s\S]*?(?=^## )/m);
assert.ok(step && step[0].includes(FIXED_ID), 'the 1.2 fixture has a ### Step R1 naming the fixed finding');
const noStep = v12.replace(step[0], '');
const r = validateReviewContent(noStep, { strict: true });
assert.equal(r.valid, false, 'a MAJOR answered "fixed" without a ### Step R<n> naming it is refused');
const hit = r.errors.find((e) => e.code === 'REVIEW_ANSWERS_MALFORMED' && e.message.includes(FIXED_ID));
assert.ok(hit, `the error names the fixed finding: ${JSON.stringify(r.errors)}`);
});
test('[V12] a malformed round, pre-existing or ran field at 1.2 fails with its code', () => {
const v12 = fixture('review-1-2.md');
const cases = [
['round_base not full hex', '\nround_base: null\n', '\nround_base: abc123\n', 'REVIEW_ROUND_MALFORMED'],
['finalized not boolean', '\nfinalized: true\n', '\nfinalized: yes\n', 'REVIEW_ROUND_MALFORMED'],
['pre_existing not boolean', '"pre_existing": true', '"pre_existing": "yes"', 'REVIEW_PRE_EXISTING_MALFORMED'],
['pre_existing_counts not counts', '"pre_existing_counts": {"BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0}',
'"pre_existing_counts": {"MAJOR": "one"}', 'REVIEW_PRE_EXISTING_MALFORMED'],
['ran engine unknown', '"engines": ["code-review", "security-review"]', '"engines": ["code-review", "lint"]', 'REVIEW_ROUND_MALFORMED'],
['review_policy unknown', '\nreview_policy: applied\n', '\nreview_policy: maybe\n', 'REVIEW_POLICY_MALFORMED'],
];
for (const [name, from, to, code] of cases) {
const r = validateReviewContent(mutate(v12, from, to), { strict: true });
assert.equal(r.valid, false, `${name}: refused in strict mode`);
assert.ok(codes(r.errors).includes(code), `${name}: ${code} expected, got ${JSON.stringify(r.errors)}`);
}
});