merge: release 6.4.0 (operator decision 53)

This commit is contained in:
Kjell Tore Guttormsen 2026-10-17 00:22:03 +02:00
commit fb241d5016
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
8 changed files with 116 additions and 61 deletions

View file

@ -1,7 +1,7 @@
{
"name": "voyage",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, pencil-toggle annotation pattern): select text or click any element, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"version": "6.3.1",
"version": "6.4.0",
"author": {
"name": "Kjell Tore Guttormsen"
},

View file

@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased — plan_version 1.9, the trial-4 findings, session-clean tests, the converging critic loop
## v6.4.0 — 2026-10-17 — plan_version 1.9, the trial-4 findings, session-clean tests, the converging critic loop
A new plan version, 1.9, in which every path in a step's `Files:` and on its
`Test first:` `File:` line stands first, in backticks, so the reader no longer
@ -18,7 +18,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 2602 (2600 pass, 0 fail, 2 skipped), measured
by `npm test` on this branch.
by `npm test` on `main` at `4e28dc5`, the tree this release review covered.
Nothing here asks anything of users. Older plans warn, they do not fail: a plan
below 1.9 is read as in 6.3.1, apart from the fixes under Fixed, and gets one
@ -99,9 +99,11 @@ warning per step whose `Files:` names a path that is not backticked.
### Fixed
- **The suite no longer hands the session id to the lock CLI.** `npm test`
preloads a helper that removes `CLAUDE_CODE_SESSION_ID`, and every test that
starts a child process passes an environment without it. A live session's
stop guard is no longer pointed at a temp-repo lock.
preloads a helper that removes `CLAUDE_CODE_SESSION_ID` for the whole run,
and every test that spreads the process environment into a child uses a
shared helper without it. A live session's stop guard is no longer pointed
at a temp-repo lock. A test file run on its own with `node --test` still
passes the id to a child it starts without an `env` (release review).
- **The three `Files:` regressions from 6.3.1's Known issues** give 6.3.0's
answer again: a source file after a described test is kept, a path that a
description only mentions is not read as a file, and a backticked bare name
@ -345,6 +347,24 @@ warning per step whose `Files:` names a path that is not backticked.
leaves the count without being marked resolved. This predates the critic
loop's diff mode (review `4c340d4a`; deferred by operator decision 51,
16.10).
- A near-duplicate merge in plan dedup keeps only the survivor's place, so
rule A can lower a new major in the changed text to minor when its partner
cites an unchanged line, and the round can record PASS. 6.3.1 counted that
merge major (review `ef1633b6`; deferred by operator decision 52, 16.10).
- Below 1.9, regression A's restored reading also takes a plain slash path
that a description only mentions after a comma (`` `tests/a.test.mjs` —
covers the parser, lib/parse.mjs stays as is ``). 6.3.1 read only the test
file; now the step is no longer tests-only, and the validator warns
`PLAN_STEP_PATH_UNTICKED` on the mentioned path (review `38a8ad34`;
deferred by operator decision 52, 16.10).
- The verdict gate's brief-lowering check compares severities in upper case
only, so an engine finding written `"severity": "major"` reads as an
illegal lowering and the gate exits 2 (fail-closed) (review `20b626e2`;
deferred by operator decision 52, 16.10).
- The opt-in workflow review's floor raises an illegally lowered or marked
reviewer finding but keeps its `brief_lowered_from` and `contradicts_brief`,
so the verdict gate still exits 2 on that review (fail-closed) (review
`c614baad`; deferred by operator decision 52, 16.10).
## v6.3.1 — 2026-10-11 — Fixes from the release review of 6.3.0, and two verdict-gate holes closed

View file

@ -2,7 +2,7 @@
Contract-driven Claude Code pipeline: brief, research, plan, execute, review. Agent swarms, research triangulation, adversarial review, multi-session resumption.
![Version](https://img.shields.io/badge/version-6.3.1-blue)
![Version](https://img.shields.io/badge/version-6.4.0-blue)
![License](https://img.shields.io/badge/license-MIT-green)
![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple)

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "voyage",
"version": "6.3.1",
"version": "6.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voyage",
"version": "6.3.1",
"version": "6.4.0",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -1,6 +1,6 @@
{
"name": "voyage",
"version": "6.3.1",
"version": "6.4.0",
"private": true,
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, pencil-toggle annotation pattern): select text or click any heading/paragraph/list-item, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"type": "module",

View file

@ -0,0 +1,30 @@
// The CHANGELOG section of the plan_version 1.9 / converging critic loop
// delivery, found by its title. Before the release its heading is
// `## Unreleased — <title>`; the release commit makes it
// `## vX.Y.Z — <date> — <title>` (docs/release-runbook.md § 1.4). The section
// is found wherever it stands, so a later delivery's section above it does not
// move the pins, and it must stand exactly once.
import assert from 'node:assert/strict';
export const SECTION_TITLE = 'plan_version 1.9, the trial-4 findings, session-clean tests, the converging critic loop';
const escaped = SECTION_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const SECTION_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${escaped}$`);
// A heading line as written, without a trailing `\r` or trailing spaces.
const bare = line => line.replace(/\s+$/, '');
// { heading, body } of the delivery's section in CHANGELOG text, up to the
// next `## ` heading. Fails unless exactly one heading matches.
export function deliverySection(text) {
const lines = text.split('\n');
const at = [];
lines.forEach((l, i) => { if (SECTION_HEADING.test(bare(l))) at.push(i); });
assert.equal(at.length, 1, `CHANGELOG.md has the "${SECTION_TITLE}" section exactly once (found ${at.length})`);
const start = at[0];
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^## /.test(lines[i])) { end = i; break; }
}
return { heading: bare(lines[start]), body: lines.slice(start + 1, end).join('\n') };
}

View file

@ -12,6 +12,7 @@ import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
import { SECTION_TITLE, SECTION_HEADING, deliverySection } from '../helpers/changelog-section.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf-8');
@ -285,50 +286,60 @@ test('the template\'s own example steps validate as 1.9 with none of the new pat
assert.deepEqual([...soft.errors, ...soft.warnings].map((i) => i.code).filter((c) => NEW_CODES.includes(c)), []);
});
// Step 14: the CHANGELOG's top section is the Unreleased 1.9 delivery, with door
// lines the release check accepts. The release check is imported as a module
// namespace, so a missing parseDoors export fails an assertion, not the load.
// Step 14: the CHANGELOG has the 1.9 delivery's section, with door lines the
// release check accepts. The section is found by its title, before and after the
// release commit (tests/helpers/changelog-section.mjs). 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');
const UNRELEASED = '## Unreleased — plan_version 1.9, the trial-4 findings, session-clean tests, the converging critic loop';
function firstChangelogSection() {
const lines = read('CHANGELOG.md').split('\n');
const start = lines.findIndex((l) => /^## /.test(l));
assert.ok(start >= 0, 'CHANGELOG.md has no "## " section');
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^## /.test(lines[i])) { end = i; break; }
}
return { heading: lines[start], body: lines.slice(start + 1, end).join('\n') };
}
// The delivery section's body.
const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
// The Unreleased section's body, after checking it is the first section.
function unreleasedBody() {
const { heading, body } = firstChangelogSection();
assert.equal(heading, UNRELEASED);
return body;
}
test('CHANGELOG: the first "## " section is the Unreleased plan_version 1.9 section', () => {
assert.equal(firstChangelogSection().heading, UNRELEASED);
test('CHANGELOG: the plan_version 1.9 delivery section stands exactly once', () => {
assert.match(deliverySection(read('CHANGELOG.md')).heading, SECTION_HEADING);
});
test('CHANGELOG: parseDoors reads the Unreleased section\'s door lines, none bad, one naming plan_version 1.9', () => {
test('CHANGELOG: the section heading is read before and after the release commit, and nothing else', () => {
assert.match(`## Unreleased — ${SECTION_TITLE}`, SECTION_HEADING);
assert.match(`## v6.4.0 — 2026-10-17 — ${SECTION_TITLE}`, SECTION_HEADING);
for (const [heading, why] of [
[`## v6.4.0 — ${SECTION_TITLE}`, 'a release heading without its date'],
[`## Unreleased — 2026-10-17 — ${SECTION_TITLE}`, 'an Unreleased heading with a date'],
[`## v6.4 — 2026-10-17 — ${SECTION_TITLE}`, 'a two-part version'],
[`## v6.4.0 — 2026-10-7 — ${SECTION_TITLE}`, 'a one-digit day'],
[`## 6.4.0 — 2026-10-17 — ${SECTION_TITLE}`, 'a version without its v'],
[`## Unreleased — ${SECTION_TITLE} and more`, 'another title'],
[`## Unreleased — ${SECTION_TITLE.replace('1.9', '1x9')}`, 'the dot in 1.9 is literal'],
]) assert.doesNotMatch(heading, SECTION_HEADING, why);
});
test('CHANGELOG: the section is found below a later section, with a trailing \\r, and exactly once', () => {
const later = `## Unreleased — the next delivery\n\n### Doors\n\n- two-way: x — y.\n\n`;
const ours = `## v6.4.0 — 2026-10-17 — ${SECTION_TITLE}\r\n\nours\n\n## v6.3.1 — 2026-10-11 — older\n\nolder\n`;
const s = deliverySection(later + ours);
assert.equal(s.heading, `## v6.4.0 — 2026-10-17 — ${SECTION_TITLE}`);
assert.equal(s.body.trim(), 'ours');
assert.throws(() => deliverySection(ours + ours), /exactly once \(found 2\)/);
assert.throws(() => deliverySection(later), /exactly once \(found 0\)/);
});
test('CHANGELOG: parseDoors reads the delivery section\'s door lines, none bad, one naming plan_version 1.9', () => {
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.equal(d.found, true, 'the delivery section has a "### Doors" heading');
assert.ok(d.entries.length > 0, 'the Doors list has door lines');
assert.deepEqual(d.bad, [], 'every top-level door line reads "- one-way: ..." or "- two-way: ..."');
assert.ok(d.entries.some((e) => e.text.includes('plan_version 1.9')),
`one door line names plan_version 1.9: ${JSON.stringify(d.entries.map((e) => e.text))}`);
});
// A `### <name>` body of the Unreleased section, up to the next heading.
// A `### <name>` body of the delivery section, up to the next heading.
const 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`);
assert.ok(start >= 0, `the delivery 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; }
@ -337,7 +348,7 @@ const subsection = (body, name) => {
};
const knownIssues = (body) => subsection(body, 'Known issues');
test('CHANGELOG: the Unreleased section\'s Known issues name the session split', () => {
test('CHANGELOG: the delivery section\'s Known issues name the session split', () => {
const body = unreleasedBody();
const known = knownIssues(body);
assert.match(known, /session split/, `Known issues name the session split: ${JSON.stringify(known)}`);
@ -361,7 +372,7 @@ test('CHANGELOG: the door lines name operator decisions 20 and 23, and none says
`no door line still says "every test file on the Test first File line": ${JSON.stringify(texts)}`);
});
test('CHANGELOG: the Unreleased section no longer says "on a severity tie"', () => {
test('CHANGELOG: the delivery section no longer says "on a severity tie"', () => {
const body = unreleasedBody();
assert.doesNotMatch(body, /on\s+a\s+severity\s+tie/);
});
@ -416,9 +427,9 @@ test('CHANGELOG: door lines name operator decisions 29, 30 and 31, none bad, and
test('CHANGELOG: the section names 08deae68, edf2ec56 and operator decision 27', () => {
const body = unreleasedBody();
assert.ok(body.includes('08deae68'), 'the Unreleased section names 08deae68');
assert.ok(body.includes('edf2ec56'), 'the Unreleased section names edf2ec56');
assert.match(body, /\boperator decision 27\b/, 'the Unreleased section names operator decision 27');
assert.ok(body.includes('08deae68'), 'the delivery section names 08deae68');
assert.ok(body.includes('edf2ec56'), 'the delivery section names edf2ec56');
assert.match(body, /\boperator decision 27\b/, 'the delivery section names operator decision 27');
});
test('CHANGELOG: the b4fa1927 bullet follows operator decision 29', () => {
@ -470,11 +481,11 @@ test('CHANGELOG: the cf241526 bullet follows operator decision 30', () => {
assert.doesNotMatch(b, /as\s+decision\s+20\s+sets\s+them/, `the cf241526 bullet no longer says "as decision 20 sets them": ${JSON.stringify(b)}`);
});
test('CHANGELOG: the old rerun wording is gone from the Unreleased section', () => {
test('CHANGELOG: the old rerun wording is gone from the delivery section', () => {
const body = unreleasedBody();
const flat = oneSpace(body);
for (const phrase of ['reruns red proof once with `plan_tests`', 'reruns the same way', 'the list red proof accepted']) {
assert.ok(!flat.includes(phrase), `the Unreleased section no longer says "${phrase}"`);
assert.ok(!flat.includes(phrase), `the delivery section no longer says "${phrase}"`);
}
});

View file

@ -12,6 +12,7 @@ import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { RULE_A, RULE_B, DIFF_FIELDS } from '../../lib/review/critic-diff.mjs';
import { deliverySection } from '../helpers/changelog-section.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
@ -68,29 +69,22 @@ test('Phase 9 no longer says the loop ends on a round with no BLOCKER', () => {
});
// 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.
// CHANGELOG's delivery section records it, Phase 9 says what a merge keeps, and
// Handover 4 states the matcher's overlap rule and the distinct id. The section
// is found by its title, before and after the release commit
// (tests/helpers/changelog-section.mjs). 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');
}
// The delivery section's body, up to the next `## ` heading.
const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
// A `### <name>` body of the Unreleased section, up to the next heading.
// A `### <name>` body of the delivery 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`);
assert.ok(start >= 0, `the delivery 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; }
@ -113,7 +107,7 @@ 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.equal(d.found, true, 'the delivery 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,