fix: close what an independent review found in the trial 2 fixes

- The pre-compact hook read only a checkpoint that started with `git commit`,
  so every plan in the new `git add … && git commit` form rebuilt nothing, and
  an empty match also skipped the session-state refresh. It now finds the
  commit anywhere in the span and always refreshes; first test for the hook.
- A sub-bullet that carries its own command stays a criterion (folding it
  left the command unrun); the shallowest bullet sets the base indent.
- Only a MINOR/SUGGESTION /code-review finding outside the range is released
  from forbidding ALLOW: a MAJOR one may be a break cited at an unchanged
  caller.
- `reuse` is an improvement category; the session-spec template stages too;
  a negated bash 3.2 constraint, an env prefix and `bash -c` are handled.
- Stale comment, Declined-to-Judge wording and rule-key counts brought in line.

Tests fail on 7c11c2a first (9 of 24).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-08 15:21:56 +02:00
commit b0d96a2d53
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
13 changed files with 194 additions and 54 deletions

View file

@ -50,7 +50,7 @@ You will receive a prompt containing:
review: `claude-code-review` (`/code-review`) and `claude-security-review` review: `claude-code-review` (`/code-review`) and `claude-security-review`
(`/security-review`, not in `quick`). Engine findings carry the catalogue (`/security-review`, not in `quick`). Engine findings carry the catalogue
keys `CODE_REVIEW_DEFECT` / `CODE_REVIEW_IMPROVEMENT` (MINOR: simplification, keys `CODE_REVIEW_DEFECT` / `CODE_REVIEW_IMPROVEMENT` (MINOR: simplification,
efficiency, style, readability) / `SECURITY_REVIEW_VULNERABILITY` (or a efficiency, reuse, style, readability) / `SECURITY_REVIEW_VULNERABILITY` (or a
narrower key) and are treated exactly like any reviewer's findings. narrower key) and are treated exactly like any reviewer's findings.
- **Expected reviewers** — the names that were due, engines included, and - **Expected reviewers** — the names that were due, engines included, and
each engine recorded as not run with its reason. A name that did not report each engine recorded as not run with its reason. A name that did not report
@ -58,9 +58,10 @@ You will receive a prompt containing:
- **Engine findings outside the review scope** — the `out_of_scope` list from - **Engine findings outside the review scope** — the `out_of_scope` list from
`lib/review/engine-findings.mjs`. List each one under `## Declined to Judge` `lib/review/engine-findings.mjs`. List each one under `## Declined to Judge`
(`engine:out-of-scope`): a real claim on a file this review did not cover. (`engine:out-of-scope`): a real claim on a file this review did not cover.
One from `/security-review` is `unverified` and forbids `ALLOW` (its base is It is `unverified` and forbids `ALLOW` — except a MINOR or SUGGESTION from
wider than this review). One from `/code-review` does not: it was given `/code-review` (given exactly this range): that one is declined but does not
exactly this range, so it does not hold `ALLOW` back. hold `ALLOW` back. A MAJOR one still does: a change that breaks an
unchanged caller is cited at the caller.
- **Triage map** — `{file → deep-review|summary-only|skip, reason}` from - **Triage map** — `{file → deep-review|summary-only|skip, reason}` from
the /trekreview triage gate. the /trekreview triage gate.
- **Brief metadata** — `task`, `slug`, `project_dir`, `brief_path` from - **Brief metadata** — `task`, `slug`, `project_dir`, `brief_path` from
@ -278,8 +279,8 @@ the parser reads; a bare `findings:` is null and fails validation.
`[unverified]`. Unverified lines come first: they are the ones that `[unverified]`. Unverified lines come first: they are the ones that
withheld `ALLOW`. withheld `ALLOW`.
9. `## Declined to Judge` — REQUIRED. One line per finding you set aside 9. `## Declined to Judge` — REQUIRED. One line per finding you set aside
without ruling on it (every `[unverified]` one: `file:line rule_key — without ruling on it (every `[unverified]` one, and every
reason`), then one line `Trimmed: N` (MINOR/SUGGESTION text cut, findings out-of-scope engine finding: `file:line rule_key — reason`), then one line `Trimmed: N` (MINOR/SUGGESTION text cut, findings
kept). Write `None.` plus `Trimmed: 0` when empty. Borrowed: "Declined to kept). Write `None.` plus `Trimmed: 0` when empty. Borrowed: "Declined to
judge … nothing you set aside is dropped silently" (Superpowers judge … nothing you set aside is dropped silently" (Superpowers
`skills/requesting-code-review/code-reviewer.md:43-48`, MIT). `skills/requesting-code-review/code-reviewer.md:43-48`, MIT).

View file

@ -308,7 +308,7 @@ later in this conversation. Do not start Phase 6 before both engines have
delivered or have been recorded as not run. For each engine that delivered, delivered or have been recorded as not run. For each engine that delivered,
transcribe every finding — file, line (0 when none), the one-sentence summary, transcribe every finding — file, line (0 when none), the one-sentence summary,
the failure scenario, the category if given (it decides the tier: the failure scenario, the category if given (it decides the tier:
`simplification`, `efficiency`, `style` and `readability` enter as MINOR), the `simplification`, `efficiency`, `reuse`, `style` and `readability` enter as MINOR), the
fix the engine proposes (`fix`) only when its text states one — never write one fix the engine proposes (`fix`) only when its text states one — never write one
the engine did not give — and a narrower catalogue `rule_key` only when one the engine did not give — and a narrower catalogue `rule_key` only when one
plainly fits — into plainly fits — into
@ -329,11 +329,11 @@ Exit 0 prints a reviewer payload (`claude-code-review` /
files outside the review's SHA range (`/security-review` diffs against files outside the review's SHA range (`/security-review` diffs against
origin's default branch, which can be wider). Pass `out_of_scope` to the origin's default branch, which can be wider). Pass `out_of_scope` to the
coordinator as well: each one is listed under `## Declined to Judge` coordinator as well: each one is listed under `## Declined to Judge`
(`engine:out-of-scope`) and in Coverage — never silently dropped. One from (`engine:out-of-scope`) and in Coverage — never silently dropped. It is
`/security-review` is `unverified` and forbids `ALLOW`: its base is wider than `unverified` and forbids `ALLOW` — with one exception: a MINOR or SUGGESTION
this review and nobody here judged it. One from `/code-review` does not hold from `/code-review`, which was given exactly this range, is an improvement to
`ALLOW` back: that engine was given exactly this range, so a citation outside code this review did not change and does not hold `ALLOW` back. A MAJOR one
it is a remark about code this review did not change. Absolute paths under the repo root are made relative still does: a change that breaks an unchanged caller is cited at the caller. Absolute paths under the repo root are made relative
before the scope match (`--repo-root`). Exit 1 = the transcription is before the scope match (`--repo-root`). Exit 1 = the transcription is
malformed, or the scope list is empty: fix it from the engine's own text (or malformed, or the scope list is empty: fix it from the engine's own text (or
the scope from Phase 3) and re-run; never feed a payload that did not pass. the scope from Phase 3) and re-run; never feed a payload that did not pass.

View file

@ -423,7 +423,7 @@ Optional but standard sections: `## Findings (BLOCKER)`, `## Findings (MAJOR)`,
Since 1.1 (ordre 5): Since 1.1 (ordre 5):
- `## Remediation Steps` — required at `review_version` ≥ 1.1 when there is a BLOCKER: one `### Step R<n>` per BLOCKER carrying `Source finding: <id>` (strict error `REVIEW_MISSING_REMEDIATION_STEP`, soft warning). Steps are appended and numbered, never reworded across iterations (Spec Kit `/converge`). - `## Remediation Steps` — required at `review_version` ≥ 1.1 when there is a BLOCKER: one `### Step R<n>` per BLOCKER carrying `Source finding: <id>` (strict error `REVIEW_MISSING_REMEDIATION_STEP`, soft warning). Steps are appended and numbered, never reworded across iterations (Spec Kit `/converge`).
- `## 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. - `## 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, style or readability) and `SECURITY_REVIEW_VULNERABILITY` (BLOCKER), or a narrower key, with `reviewer` `claude-code-review` / `claude-security-review`. - 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`.
**Validation strategy:** **Validation strategy:**
@ -440,7 +440,7 @@ Since 1.1 (ordre 5):
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. 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). 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.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.
**Failure modes:** **Failure modes:**
- `REVIEW_NOT_FOUND` → consumer halts with usage message - `REVIEW_NOT_FOUND` → consumer halts with usage message

View file

@ -8,7 +8,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/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/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 (14 keys) + Phase 9 inline dedup helpers (v3.4.0) - `lib/review/{rule-catalogue,plan-review-dedup}.mjs` — version-pinned rule catalogue (15 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.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/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (v3.4.0) - `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/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`)

View file

@ -63,7 +63,10 @@ function readPlanCheckpointPatterns(planPath) {
const text = readFileSync(planPath, 'utf-8'); const text = readFileSync(planPath, 'utf-8');
const map = new Map(); const map = new Map();
const stepRe = /^### Step (\d+):/gm; const stepRe = /^### Step (\d+):/gm;
const checkpointRe = /\*\*Checkpoint:\*\*\s+`git commit -m "([^"]+)"`/; // The commit may follow a `git add … &&` inside the same span (plan template
// since lekerepo trial 2, 2026-10-08); the old pattern matched only a span
// that STARTED with `git commit` and so read every new plan as empty.
const checkpointRe = /\*\*Checkpoint:\*\*\s+`[^`]*?\bgit commit -m "([^"]+)"[^`]*`/;
const headings = []; const headings = [];
let m; let m;
while ((m = stepRe.exec(text)) !== null) { while ((m = stepRe.exec(text)) !== null) {
@ -150,14 +153,12 @@ for (const { projDir, progPath, planPath } of progressFiles) {
const repoRoot = repoRootOf(projDir); const repoRoot = repoRootOf(projDir);
if (!repoRoot) continue; if (!repoRoot) continue;
// No recognisable checkpoint or no start sha means nothing to derive, but the
// session-state refresh below still runs: it is independent of the step.
const plan = readPlanCheckpointPatterns(planPath); const plan = readPlanCheckpointPatterns(planPath);
if (plan.size === 0) continue;
const sessionStart = progress.session_start_sha; const sessionStart = progress.session_start_sha;
if (!sessionStart) continue; const commits = plan.size > 0 && sessionStart ? gitLog(repoRoot, sessionStart) : [];
const derivedStep = commits.length > 0 ? deriveCurrentStep(progress, plan, commits) : null;
const commits = gitLog(repoRoot, sessionStart);
const derivedStep = deriveCurrentStep(progress, plan, commits);
if (derivedStep !== null && derivedStep > (progress.current_step || 0)) { if (derivedStep !== null && derivedStep > (progress.current_step || 0)) {
progress.current_step = derivedStep; progress.current_step = derivedStep;

View file

@ -388,14 +388,17 @@ export function runContract(reviewerPayloads, options = {}) {
// Engine findings on files outside the review's SHA range (engine-findings.mjs // Engine findings on files outside the review's SHA range (engine-findings.mjs
// `out_of_scope`). /security-review diffs against origin's default branch, // `out_of_scope`). /security-review diffs against origin's default branch,
// wider than this review: a real claim nobody here judged, so it forbids // wider than this review: a real claim nobody here judged, so it forbids
// ALLOW. /code-review was given exactly this range, so its citation outside // ALLOW. /code-review was given exactly this range: its MINOR or SUGGESTION
// it is about code the review did not change: declined, listed, never // finding outside it (an improvement to code the review did not change) is
// dropped — but it does not lock the verdict on WARN (voyage-lekerepo // declined and listed, never dropped, but does not hold ALLOW back
// trial 2, 2026-10-08: one remark on a frozen file held ALLOW back for good). // (voyage-lekerepo trial 2, 2026-10-08). A MAJOR+ one still forbids ALLOW:
// a changed signature that breaks an unchanged caller is cited AT the
// unchanged file (independent review, 2026-10-08).
const outOfScope = (options.engineOutOfScope ?? []) const outOfScope = (options.engineOutOfScope ?? [])
.map((f) => ({ ...f, suppressed_reason: 'engine:out-of-scope' })); .map((f) => ({ ...f, suppressed_reason: 'engine:out-of-scope' }));
const outOfScopeBlocking = outOfScope.filter((f) => f.engine !== 'code-review'); const minorOnly = (f) => f.engine === 'code-review' && (f.severity === 'MINOR' || f.severity === 'SUGGESTION');
const outOfScopeNoted = outOfScope.filter((f) => f.engine === 'code-review'); const outOfScopeBlocking = outOfScope.filter((f) => !minorOnly(f));
const outOfScopeNoted = outOfScope.filter(minorOnly);
const unverified = [...judged.unverified, ...reasoned.unverified, ...outOfScopeBlocking]; const unverified = [...judged.unverified, ...reasoned.unverified, ...outOfScopeBlocking];
// What the coordinator set aside without ruling on it, one line each, so a // What the coordinator set aside without ruling on it, one line each, so a
// reader sees every finding that did not reach a verdict tier. Borrowed: // reader sees every finding that did not reach a verdict tier. Borrowed:

View file

@ -33,10 +33,11 @@
// - File paths are made repo-relative (an absolute path under `repoRoot` // - File paths are made repo-relative (an absolute path under `repoRoot`
// loses that prefix) before they are compared with the scope. // loses that prefix) before they are compared with the scope.
// - A finding on a file outside the review scope is NOT dropped silently: it // - A finding on a file outside the review scope is NOT dropped silently: it
// lands in `out_of_scope`, which the coordinator counts as unverified // lands in `out_of_scope`, which the coordinator lists under Declined to
// (`engine:out-of-scope`) — it forbids ALLOW and is listed under // Judge (`engine:out-of-scope`). It forbids ALLOW unless it is a MINOR or
// Declined to Judge (`/security-review` diffs against origin's default // SUGGESTION from /code-review (coordinator-contract.mjs runContract);
// branch, which can be wider than the review's SHA range). // `/security-review` diffs against origin's default branch, which can be
// wider than the review's SHA range.
// - An empty scope list is an error, never "everything is out of scope". // - An empty scope list is an error, never "everything is out of scope".
// //
// CLI: node lib/review/engine-findings.mjs [--scope <file-list.txt>] [--repo-root <dir>] <engine-findings.json> // CLI: node lib/review/engine-findings.mjs [--scope <file-list.txt>] [--repo-root <dir>] <engine-findings.json>
@ -79,7 +80,7 @@ function parseLine(v) {
const tier = (key) => SEVERITY_VALUES.indexOf(getRule(key).severity); const tier = (key) => SEVERITY_VALUES.indexOf(getRule(key).severity);
/** /code-review categories that name an improvement, not a defect. */ /** /code-review categories that name an improvement, not a defect. */
export const IMPROVEMENT_CATEGORIES = Object.freeze(new Set(['simplification', 'efficiency', 'style', 'readability'])); export const IMPROVEMENT_CATEGORIES = Object.freeze(new Set(['simplification', 'efficiency', 'reuse', 'style', 'readability']));
/** /**
* Convert one engine's transcribed findings into a coordinator payload. * Convert one engine's transcribed findings into a coordinator payload.

View file

@ -8,8 +8,8 @@
// Code's own `/code-review` and `/security-review` enter the coordinator under // Code's own `/code-review` and `/security-review` enter the coordinator under
// them when no narrower key fits (wrap, not a new engine). A third engine key, // them when no narrower key fits (wrap, not a new engine). A third engine key,
// CODE_REVIEW_IMPROVEMENT (MINOR), takes a /code-review finding whose category // CODE_REVIEW_IMPROVEMENT (MINOR), takes a /code-review finding whose category
// is an improvement rather than a defect (simplification, efficiency, style, // is an improvement rather than a defect (simplification, efficiency, reuse,
// readability): the second voyage-lekerepo trial (2026-10-08) saw a pure // style, readability): the second voyage-lekerepo trial (2026-10-08) saw a pure
// simplification proposal enter as MAJOR and drive the verdict. // simplification proposal enter as MAJOR and drive the verdict.
// //
// `memory_route` decides where a finding class that recurs across two // `memory_route` decides where a finding class that recurs across two
@ -128,7 +128,7 @@ export const RULE_CATALOGUE = Object.freeze([
rule_key: 'CODE_REVIEW_IMPROVEMENT', rule_key: 'CODE_REVIEW_IMPROVEMENT',
severity: 'MINOR', severity: 'MINOR',
category: 'maintenance', category: 'maintenance',
description: "An improvement (simplification, efficiency, style, readability) reported by Claude Code's /code-review, not a defect.", description: "An improvement (simplification, efficiency, reuse, style, readability) reported by Claude Code's /code-review, not a defect.",
memory_route: null, memory_route: null,
}), }),
Object.freeze({ Object.freeze({

View file

@ -274,7 +274,9 @@ export function validateBriefContent(text, opts = {}) {
// on a Mac with a newer bash installed that is 5.x, so the constraint is // on a Mac with a newer bash installed that is 5.x, so the constraint is
// never measured (voyage-lekerepo trial 2, 2026-10-08). // never measured (voyage-lekerepo trial 2, 2026-10-08).
const constraints = (extractSection(body, 'Constraints') ?? '') + (extractSection(body, 'Non-Functional Requirements') ?? ''); const constraints = (extractSection(body, 'Constraints') ?? '') + (extractSection(body, 'Non-Functional Requirements') ?? '');
const needsBash32 = /\bbash\s*3\.2\b/i.test(constraints); // Line by line, and a line that negates it ("not required") does not count.
const needsBash32 = constraints.split('\n').some((l) =>
/\bbash\s*3\.2\b/i.test(l) && !/\b(?:not|no longer|never|no need)\b/i.test(l));
for (const c of parseSuccessCriteria(body)) { for (const c of parseSuccessCriteria(body)) {
if (c.subpoints && !c.command) { if (c.subpoints && !c.command) {
warnings.push(issue( warnings.push(issue(
@ -285,11 +287,15 @@ export function validateBriefContent(text, opts = {}) {
)); ));
} }
if (!c.command) continue; if (!c.command) continue;
if (needsBash32 && /^bash\s/.test(c.command)) { const bare = c.command.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '');
if (needsBash32 && /^bash\s/.test(bare)) {
const script = bare.split(/\s+/)[1] ?? '';
warnings.push(issue( warnings.push(issue(
'BRIEF_SC_BASH_NOT_PINNED', 'BRIEF_SC_BASH_NOT_PINNED',
`Success criterion ${c.label} runs \`${c.command}\`, but the brief requires bash 3.2: \`bash\` is whichever bash comes first on PATH, which may be newer`, `Success criterion ${c.label} runs \`${c.command}\`, but the brief requires bash 3.2: \`bash\` is whichever bash comes first on PATH, which may be newer`,
`Write it as \`/bin/bash ${c.command.replace(/^bash\s+/, '')}\` (/bin/bash tests/<script>.sh) so the check runs under the system bash.`, script.startsWith('tests/')
? `Write it as \`/bin/bash ${bare.replace(/^bash\s+/, '')}\` (/bin/bash tests/<script>.sh) so the check runs under the system bash.`
: 'Put the check in a script under tests/ and run it as `/bin/bash tests/<script>.sh`, so it runs under the system bash.',
)); ));
} }
const r = runnableCheck(c, { cwd: opts.cwd }); const r = runnableCheck(c, { cwd: opts.cwd });

View file

@ -164,11 +164,15 @@ function parseSection(markdown, heading, prefix) {
const criteria = []; const criteria = [];
const push = (text, command, reason) => const push = (text, command, reason) =>
criteria.push({ label: `${prefix}${criteria.length + 1}`, text, command, reason }); criteria.push({ label: `${prefix}${criteria.length + 1}`, text, command, reason });
// A bullet indented deeper than the section's first bullet is a sub-point of // A bullet indented deeper than the section's shallowest bullet, carrying no
// the criterion above it, never a criterion of its own: one criterion with // command of its own, is a sub-point of the criterion above it, never a
// ten sub-points became SC4..SC13, each `not-a-command` (voyage-lekerepo // criterion of its own: one criterion with ten sub-points became SC4..SC13,
// trial 2, 2026-10-08). The parent counts them in `subpoints`. // each `not-a-command` (voyage-lekerepo trial 2, 2026-10-08). The parent
let baseIndent = null; // counts them in `subpoints`. A sub-bullet that DOES carry a command stays a
// criterion: folding it would leave its command unrun (independent review).
const indentOf = (text) => text.match(/^\s*/)[0].replace(/\t/g, ' ').length;
const bulletIndents = lines.filter((l) => !l.fenced && !l.marker && BULLET.test(l.text)).map((l) => indentOf(l.text));
const baseIndent = bulletIndents.length ? Math.min(...bulletIndents) : 0;
let lastBullet = null; let lastBullet = null;
for (const line of lines) { for (const line of lines) {
if (line.marker) continue; if (line.marker) continue;
@ -181,17 +185,16 @@ function parseSection(markdown, heading, prefix) {
} }
const bullet = line.text.match(BULLET); const bullet = line.text.match(BULLET);
if (!bullet) continue; if (!bullet) continue;
const indent = line.text.match(/^\s*/)[0].replace(/\t/g, ' ').length; const nested = indentOf(line.text) > baseIndent;
if (baseIndent === null) baseIndent = indent;
if (indent > baseIndent && lastBullet) {
lastBullet.subpoints = (lastBullet.subpoints ?? 0) + 1;
continue;
}
const text = bullet[1].trim(); const text = bullet[1].trim();
if (text === '') continue; if (text === '') continue;
const { command, reason } = firstCommand(text); const { command, reason } = firstCommand(text);
if (nested && lastBullet && !command) {
lastBullet.subpoints = (lastBullet.subpoints ?? 0) + 1;
continue;
}
push(text, command, reason); push(text, command, reason);
lastBullet = criteria[criteria.length - 1]; if (!nested) lastBullet = criteria[criteria.length - 1];
} }
return criteria; return criteria;
} }

View file

@ -83,7 +83,7 @@ bypass Step 0 (use only for offline/air-gapped testing).
- **Test first:** {test file, what it verifies, pattern to follow} - **Test first:** {test file, what it verifies, pattern to follow}
- **Verify:** `{exact command}` → expected: `{output}` - **Verify:** `{exact command}` → expected: `{output}`
- **On failure:** {revert | retry | skip | escalate} — {specific instructions} - **On failure:** {revert | retry | skip | escalate} — {specific instructions}
- **Checkpoint:** `git commit -m "{message}"` - **Checkpoint:** `git add {the step's Files and its test files} && git commit -m "{message}"`
- **Manifest:** - **Manifest:**
```yaml ```yaml
manifest: manifest:

View file

@ -0,0 +1,72 @@
// tests/hooks/pre-compact-flush.test.mjs
// The PreCompact hook rebuilds progress.json from git and refreshes the
// session state before compaction. An independent review (2026-10-08) found
// that the checkpoint form `git add … && git commit -m "…"` (plan template,
// lekerepo trial 2 point A7) matched nothing, and that an empty match skipped
// the session-state refresh too. No test covered the hook until now.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, '..', '..', 'hooks', 'scripts', 'pre-compact-flush.mjs');
function git(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
function project(checkpoint, { stateStatus = 'in_progress' } = {}) {
const repo = mkdtempSync(join(tmpdir(), 'voyage-precompact-'));
git(repo, 'init', '-q');
git(repo, 'config', 'user.email', 't@example.invalid');
git(repo, 'config', 'user.name', 'test');
writeFileSync(join(repo, 'a.txt'), 'a\n');
git(repo, 'add', 'a.txt');
git(repo, 'commit', '-q', '-m', 'base');
const base = git(repo, 'rev-parse', 'HEAD');
writeFileSync(join(repo, 'a.txt'), 'b\n');
git(repo, 'add', 'a.txt');
git(repo, 'commit', '-q', '-m', 'feat(a): change a');
const dir = join(repo, '.claude', 'projects', 'p');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'plan.md'), ['# Plan', '', '### Step 1: Change a', '', `- **Checkpoint:** ${checkpoint}`, ''].join('\n'));
writeFileSync(join(dir, 'progress.json'), JSON.stringify({ status: 'in_progress', current_step: 0, steps: {}, session_start_sha: base }));
writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify({ status: stateStatus, updated_at: '2000-01-01T00:00:00.000Z' }));
return { repo, dir };
}
function run(repo) {
return spawnSync(process.execPath, [HOOK], { input: '{}', encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: repo } });
}
const progress = (dir) => JSON.parse(readFileSync(join(dir, 'progress.json'), 'utf8'));
const state = (dir) => JSON.parse(readFileSync(join(dir, '.session-state.local.json'), 'utf8'));
for (const [name, cp] of [
['the old form', '`git commit -m "feat(a): change a"`'],
['the template form with git add', '`git add a.txt && git commit -m "feat(a): change a"`'],
]) {
test(`pre-compact: ${name} -> progress.json is rebuilt from git`, () => {
const { repo, dir } = project(cp);
try {
assert.equal(run(repo).status, 0);
assert.equal(progress(dir).current_step, 1);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
}
test('pre-compact: a plan with no recognisable checkpoint still refreshes the session state', () => {
const { repo, dir } = project('`make commit`');
try {
assert.equal(run(repo).status, 0);
assert.equal(progress(dir).current_step, 0);
assert.notEqual(state(dir).updated_at, '2000-01-01T00:00:00.000Z');
} finally { rmSync(repo, { recursive: true, force: true }); }
});

View file

@ -72,14 +72,21 @@ test('A2: /trekreview asks the transcription for the engine\'s fix text and forb
// --- A3. an out-of-range /code-review finding does not lock the verdict ----- // --- A3. an out-of-range /code-review finding does not lock the verdict -----
test('A3: a /code-review finding outside the exact range it was given is declined, and does not hold ALLOW back', () => { test('A3: a small /code-review finding outside the exact range it was given is declined, and does not hold ALLOW back', () => {
const out = { ...engineFinding({ file: 'tests/cli.test.mjs', line: 1 }), engine: 'code-review', rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'No CLI test for --sep' }; const out = { ...engineFinding({ file: 'src/util.mjs', line: 4 }), engine: 'code-review', rule_key: 'CODE_REVIEW_IMPROVEMENT', severity: 'MINOR', title: 'Simplify the helper' };
const c = runContract([], { engineOutOfScope: [out] }); const c = runContract([], { engineOutOfScope: [out] });
assert.equal(c.verdict, 'ALLOW', JSON.stringify(c.allow_blocked_by)); assert.equal(c.verdict, 'ALLOW', JSON.stringify(c.allow_blocked_by));
assert.equal(c.declined_to_judge.length, 1); assert.equal(c.declined_to_judge.length, 1);
assert.equal(c.declined_to_judge[0].reason, 'engine:out-of-scope'); assert.equal(c.declined_to_judge[0].reason, 'engine:out-of-scope');
}); });
test('A3 (independent review): a MAJOR /code-review defect cited at an unchanged file still forbids ALLOW — a cross-file break is cited there', () => {
const out = { ...engineFinding({ file: 'tests/cli.test.mjs', line: 1 }), engine: 'code-review', rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'No CLI test for --sep' };
const c = runContract([], { engineOutOfScope: [out] });
assert.notEqual(c.verdict, 'ALLOW');
assert.equal(c.declined_to_judge.length, 1);
});
test('A3: a /security-review finding outside the range still forbids ALLOW (its base is wider and not ours)', () => { test('A3: a /security-review finding outside the range still forbids ALLOW (its base is wider and not ours)', () => {
const out = { ...engineFinding({ file: 'lib/auth.mjs' }), engine: 'security-review', rule_key: 'SECURITY_REVIEW_VULNERABILITY', severity: 'BLOCKER', title: 'Token logged' }; const out = { ...engineFinding({ file: 'lib/auth.mjs' }), engine: 'security-review', rule_key: 'SECURITY_REVIEW_VULNERABILITY', severity: 'BLOCKER', title: 'Token logged' };
const c = runContract([], { engineOutOfScope: [out] }); const c = runContract([], { engineOutOfScope: [out] });
@ -170,3 +177,49 @@ test('A7: every checkpoint the plan template and /trekplan\'s example show stage
assert.ok(lines.length >= 3, `found ${lines.length}`); assert.ok(lines.length >= 3, `found ${lines.length}`);
for (const l of lines) assert.match(l, /`git add [^`]+ && git commit -m /, l); for (const l of lines) assert.match(l, /`git add [^`]+ && git commit -m /, l);
}); });
// --- the independent review of these fixes (2026-10-08) ----------------------
test('A1 (review): a `reuse` finding is an improvement too', () => {
const r = toReviewerPayload({ engine: 'code-review', findings: [engineFinding({ category: 'Reuse ' })] });
assert.equal(r.payload.findings[0].severity, 'MINOR');
});
test('A5 (review): a sub-bullet that carries its own command stays a criterion; only command-less sub-points fold', () => {
const sc = parseSuccessCriteria(brief(['## Success Criteria', '',
'- Behaviour:', ' - `npm test` exits 0', ' - `bash tests/e2e.sh` exits 0', ' - reads well', '']));
assert.deepEqual(sc.map((c) => c.command), [null, 'npm test', 'bash tests/e2e.sh']);
assert.equal(sc[0].subpoints, 1);
});
test('A5 (review): an indented first bullet does not turn later top-level bullets into sub-points', () => {
const sc = parseSuccessCriteria(brief(['## Success Criteria', '',
' - The first, indented by accident.', '- The second:', ' - a sub-point of the second', '- `npm test` exits 0.', '']));
assert.equal(sc.length, 3, JSON.stringify(sc.map((c) => c.text)));
assert.equal(sc[1].subpoints, 1);
});
test('A6 (review): a negated bash 3.2 constraint, an env prefix and `bash -c` are handled', () => {
const neg = validateBriefContent(brief(['## Constraints', '', '- bash 3.2 is not required here.', '',
'## Success Criteria', '', '- `bash tests/contract.sh` exits 0.', '']));
assert.ok(!codes(neg.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'), 'negated constraint');
const env = validateBriefContent(brief([...BASH32, '## Success Criteria', '', '- `LC_ALL=C bash tests/contract.sh` exits 0.', '']));
assert.ok(codes(env.warnings).includes('BRIEF_SC_BASH_NOT_PINNED'), 'env-prefixed bash');
const dashC = validateBriefContent(brief([...BASH32, '## Success Criteria', '', '- `bash -c true` exits 0.', '']));
for (const w of dashC.warnings.filter((x) => x.code === 'BRIEF_SC_BASH_NOT_PINNED')) {
assert.doesNotMatch(w.hint, /\/bin\/bash -c/, 'never suggests a form the runner refuses');
}
});
test('A7 (review): the session-spec template stages before it commits too', () => {
const l = read('templates/session-spec-template.md').split('\n').filter((x) => /\*\*Checkpoint:\*\*/.test(x) && /`git [^`]*commit -m /.test(x));
assert.ok(l.length >= 1);
for (const x of l) assert.match(x, /`git add [^`]+ && git commit -m /, x);
});
test('docs (review): the counts and the Declined-to-Judge rule match the code', () => {
assert.match(read('docs/architecture.md'), /rule catalogue \(15 keys\)/);
assert.match(read('docs/HANDOVER-CONTRACTS.md'), /three engine rule keys/);
assert.doesNotMatch(read('lib/review/engine-findings.mjs'), /it forbids ALLOW and is listed under/);
assert.match(read('agents/review-coordinator.md'), /every `\[unverified\]` one, and every\s+out-of-scope engine finding/);
});