docs(changelog): the narrow remediation of the A/B review, decisions 48 and 50, with door lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-16 16:54:59 +02:00
commit 184fbe4633
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 119 additions and 4 deletions

View file

@ -17,7 +17,7 @@ measured by `npm test` on the third remediation's Step 2 commit
The converging critic loop came after that measurement (decisions 43 and
44). From round 2 the plan gate counts a critic round by rule A, and both
reviewers state rule B. Suite 2592 (2590 pass, 0 fail, 2 skipped), measured
reviewers state rule B. Suite 2602 (2600 pass, 0 fail, 2 skipped), measured
by `npm test` on this branch.
Nothing here asks anything of users. Older plans warn, they do not fail: a plan
@ -45,6 +45,9 @@ warning per step whose `Files:` names a path that is not backticked.
- two-way: rule B and the security key in the reviewers (decision 43) — prose only; a revert restores the old rating.
- two-way: plan dedup passes the reviewers' marks and rule-B fields through, and its two fixes — a revert drops the fields and brings back the line-0 collapse and the crash.
- two-way: the post-compact hook test's timeout (decision 44) — test-side only.
- two-way: the critic-loop matcher needs overlapping texts, and a new finding never takes an old id (operator decision 48) — a revert matches by id or line alone again.
- two-way: plan dedup keeps a lowered_from that is not a severity (operator decision 48) — a revert counts it as no lowering again.
- two-way: plan dedup ranks a merged pair by what rule B counts (operator decision 48) — a revert brings back the order-dependent merge.
### Added
@ -251,6 +254,22 @@ warning per step whose `Files:` names a path that is not backticked.
or with no line no longer collapse into one. An empty `file` or a `line`
that is not a whole number no longer crashes the finding id; the line
becomes null and the finding stands.
- **The critic loop no longer folds a new finding into an old one by its id
or line alone** (reviews `4ab24b07`, `d03835c4`; operator decision 48,
16.10).
- What happened: an equal id or the old finding's line was enough. The new
text was lost, and a new file-level major could count as an old rule-B
minor.
- Now the texts must overlap, a finding with a plan line is never matched
by id, and a new finding never takes an old finding's id.
- **A `lowered_from` that is not a severity now counts as blocker** (reviews
`8782602b`, `ebe164e2`; operator decision 48, 16.10). It was read as no
lowering. A blank one is still no lowering.
- **Plan dedup no longer lets reviewer order lower a blocker** (review
`69c9cebd`; operator decision 48, 16.10).
- A merge keeps the copy rule B counts highest, on the merged keys.
- This also covers a blocker claim merged into a plain major, found during
this round.
### Known issues
@ -286,6 +305,23 @@ warning per step whose `Files:` names a path that is not backticked.
- Accepted deviation: the 1.9 `File:` selector repeats the list-marker set of
`LIST_ITEM` (review `4dce7c74`; operator decision 38, 14.10, decision 27
stands).
- The line diff's LCS table has no size bound. A rewritten gap of tens of
thousands of lines takes seconds to minutes and gigabytes, and can end
outside exit 0/1/2. Real plans measure about 1,200 lines (review
`8a9b5a17`; deferred by operator decision 48, 16.10).
- A previous finding re-reported on its own edited line is counted twice.
Such a re-report of a lowered finding counts at its full severity (review
`8004ea85`; deferred by operator decision 48, 16.10).
- A `line` sent as a numeric string (`"42"`) is read as no line and counted
at full severity. The agent templates give `line` as a number (review
`c257625e`; deferred by operator decision 48, 16.10).
- `npm test` does not run `tests/e2e-critic-diff-mode.sh`. It runs under
`## Verification` and in `/trekreview`'s success-criteria check (review
`818b1346`; deferred by operator decision 48, 16.10).
- The matcher's residual (review `4ab24b07`; operator decision 50, 16.10): a
previous finding marked resolved by every raiser and re-reported in other
words (content overlap below 0.7) on an unchanged line is a new finding,
which rule A can lower to minor.
## v6.3.1 — 2026-10-11 — Fixes from the release review of 6.3.0, and two verdict-gate holes closed

View file

@ -1023,8 +1023,8 @@ defect, where the read-only reviewers never wrote the files and the dedup ran on
nothing). The merged array attributes each finding to `[plan-critic, scope-guardian]`
when both reviewers raised the same issue (exact match on `file:line:rule_key`,
Jaccard ≥ 0.7 on text tokens, or — same file and same line — most of the
shorter text's content words in the longer one), and keeps the more severe of the two
severities. Revise the plan once for the merged set, not twice for the
shorter text's content words in the longer one), and keeps the copy rule B counts more severe
(a refused lowering included). Revise the plan once for the merged set, not twice for the
duplicates. Source: research/05 R1 + R2.
**After each round**, by the recorded verdict:

View file

@ -319,7 +319,7 @@ The strongest validation in the entire pipeline. Phase 5.5 (planning-orchestrato
Rule A: from round 2 on, a new major finding that cites a plan line outside the diff since the last reviewed text counts as minor; a blocker, a security finding and a finding with no plan line count at full severity, and a previous finding stays counted until it is marked resolved.
Rule B is stated by the reviewers and recorded by the gate from round 1 (`lowered_by`, `caught_by`); it never lowers a blocker or a security finding. `--diff <plan.md>` prints what the next round reviews: `diff-base: <sha256> (round N)` and each hunk in the plan's full-file line numbers, or `diff-base: none — <reason>; review the whole plan` (exit 0; 2 = unreadable plan or bad arguments). `--record` keeps each recorded round's operative text as a snapshot, `<git dir>/voyage/plan-snapshots/<sha256>.md`, named by its own sha256. git never tracks its own directory, so a snapshot is never committed, whatever the repo's `.gitignore` or `artifact_store` says. A plan in no git repository (plan mode) keeps none (`snapshot: "not kept: no git repository"`), and its next round counts fully. A missing, oversized, misnamed or tampered snapshot, or a plan unchanged since the base, also means full counting, said in one `diff_note` line. `--record`'s JSON line adds `diff_base`, `diff_note`, `lowered` (a number) and `snapshot`. Trust limit: anything that can write the plan can also rewrite the previous verdict's `plan_sha256` and plant a matching snapshot. That was already the limit of the verdict block; rule A raises what such a forgery buys, and an unchanged base lowers nothing.
Rule B is stated by the reviewers and recorded by the gate from round 1 (`lowered_by`, `caught_by`); it never lowers a blocker or a security finding. `--diff <plan.md>` prints what the next round reviews: `diff-base: <sha256> (round N)` and each hunk in the plan's full-file line numbers, or `diff-base: none — <reason>; review the whole plan` (exit 0; 2 = unreadable plan or bad arguments). `--record` keeps each recorded round's operative text as a snapshot, `<git dir>/voyage/plan-snapshots/<sha256>.md`, named by its own sha256. git never tracks its own directory, so a snapshot is never committed, whatever the repo's `.gitignore` or `artifact_store` says. A plan in no git repository (plan mode) keeps none (`snapshot: "not kept: no git repository"`), and its next round counts fully. A missing, oversized, misnamed or tampered snapshot, or a plan unchanged since the base, also means full counting, said in one `diff_note` line. `--record`'s JSON line adds `diff_base`, `diff_note`, `lowered` (a number) and `snapshot`. Trust limit: anything that can write the plan can also rewrite the previous verdict's `plan_sha256` and plant a matching snapshot. That was already the limit of the verdict block; rule A raises what such a forgery buys, and an unchanged base lowers nothing. The gate matches a previous finding only on overlapping text, and a new finding never takes a previous finding's id: a repeated id gets `<rule_key>#n`.
**Verification CLIs that read a plan** (0/1/2, JSON on stdout, 2 is never green): `lib/verification/arch-rules.mjs --repo <dir>` runs the target repo's own dependency-cruiser or import-linter (installs nothing; `--prove --rule <name> --plant <file>=<dest>` shows pass → fail on that rule → pass, refusing a destination that exists or resolves outside the repo); `lib/verification/plan-scope-sweep.mjs --plan <plan.md> --base <ref>` lists changed files no step names (`Files:`, `Test first:` File, manifest `expected_paths`) and no process path covers.

View file

@ -66,3 +66,82 @@ test('Phase 9 names plan-gate --diff and says round 1 is unchanged', () => {
test('Phase 9 no longer says the loop ends on a round with no BLOCKER', () => {
assert.ok(!P9.includes('ends on a round with no BLOCKER'), 'the stale "ends on a round with no BLOCKER" is gone');
});
// The narrow round of the A/B review (operator decisions 48 and 50): the
// CHANGELOG's open Unreleased section records it, Phase 9 says what a merge
// keeps, and Handover 4 states the matcher's overlap rule and the distinct id.
// The release check is imported as a module namespace, so a missing parseDoors
// export fails an assertion, not the load.
const releaseCheck = await import('../../scripts/release-check.mjs');
// The body of the open `## Unreleased` section, up to the next `## ` heading.
function unreleasedBody() {
const lines = read('CHANGELOG.md').split('\n');
const start = lines.findIndex(l => /^## Unreleased\b/.test(l));
assert.ok(start >= 0, 'CHANGELOG.md has a "## Unreleased" section');
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^## /.test(lines[i])) { end = i; break; }
}
return lines.slice(start + 1, end).join('\n');
}
// A `### <name>` body of the Unreleased section, up to the next heading.
function subsection(body, name) {
const lines = body.split('\n');
const start = lines.findIndex(l => l.replace(/\s+$/, '') === `### ${name}`);
assert.ok(start >= 0, `the Unreleased section has a "### ${name}" heading`);
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^###? /.test(lines[i])) { end = i; break; }
}
return lines.slice(start + 1, end).join('\n');
}
// The top-level bullets of a slice: each `- ` line at column 0 with its
// wrapped and nested lines, whitespace read as one space.
function topBullets(slice) {
const out = [];
for (const line of slice.split('\n')) {
if (/^- /.test(line)) out.push(line);
else if (out.length && line.trim() !== '') out[out.length - 1] += `\n${line}`;
}
return out.map(flat);
}
test('CHANGELOG: the narrow round of operator decisions 48 and 50', () => {
assert.equal(typeof releaseCheck.parseDoors, 'function', 'scripts/release-check.mjs exports parseDoors');
const body = unreleasedBody();
const d = releaseCheck.parseDoors(body);
assert.equal(d.found, true, 'the Unreleased section has a "### Doors" heading');
assert.deepEqual(d.bad, [], 'every top-level door line reads "- one-way: ..." or "- two-way: ..."');
const doors48 = d.entries.filter(e => /operator\s+decision\s+48\b/.test(flat(e.text)));
assert.ok(doors48.length >= 3,
`at least three door lines name operator decision 48: ${JSON.stringify(d.entries.map(e => e.text))}`);
const fixed = flat(subsection(body, 'Fixed'));
for (const id of ['4ab24b07', 'd03835c4', '69c9cebd', '8782602b', 'ebe164e2']) {
assert.ok(fixed.includes(id), `the Fixed slice names review ${id}`);
}
const known = topBullets(subsection(body, 'Known issues'));
for (const id of ['8a9b5a17', '8004ea85', 'c257625e', '818b1346']) {
const hits = known.filter(b => b.includes(id));
assert.equal(hits.length, 1, `exactly one Known-issues bullet names ${id}: ${JSON.stringify(hits)}`);
assert.match(hits[0], /operator\s+decision\s+48\b/, `the ${id} Known-issues bullet names operator decision 48: ${JSON.stringify(hits[0])}`);
}
const residual = known.filter(b => b.includes('4ab24b07'));
assert.equal(residual.length, 1, `exactly one Known-issues bullet names 4ab24b07: ${JSON.stringify(residual)}`);
assert.match(residual[0], /operator\s+decision\s+50\b/, `the 4ab24b07 Known-issues bullet names operator decision 50: ${JSON.stringify(residual[0])}`);
});
test('Phase 9 says the merge keeps the copy rule B counts more severe', () => {
assert.ok(P9.includes('keeps the copy rule B counts more severe'), 'Phase 9 says "keeps the copy rule B counts more severe"');
assert.ok(!P9.includes('keeps the more severe of the two severities'), 'the stale "keeps the more severe of the two severities" is gone');
});
test('Handover 4 states the overlap match and the distinct id', () => {
const h4 = flat(handover4());
assert.ok(h4.includes('only on overlapping text'), 'Handover 4 says the gate matches "only on overlapping text"');
assert.ok(h4.includes('`<rule_key>#n`'), 'Handover 4 names `<rule_key>#n`');
});