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 a3fa5de42a
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", "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.", "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": { "author": {
"name": "Kjell Tore Guttormsen" "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/). 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 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 `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 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 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 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 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 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 ### Fixed
- **The suite no longer hands the session id to the lock CLI.** `npm test` - **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 preloads a helper that removes `CLAUDE_CODE_SESSION_ID` for the whole run,
starts a child process passes an environment without it. A live session's and every test that spreads the process environment into a child uses a
stop guard is no longer pointed at a temp-repo lock. 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 - **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 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 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 leaves the count without being marked resolved. This predates the critic
loop's diff mode (review `4c340d4a`; deferred by operator decision 51, loop's diff mode (review `4c340d4a`; deferred by operator decision 51,
16.10). 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 ## 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. 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) ![License](https://img.shields.io/badge/license-MIT-green)
![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple) ![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple)

4
package-lock.json generated
View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "voyage", "name": "voyage",
"version": "6.3.1", "version": "6.4.0",
"private": true, "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.", "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", "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 { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs'; 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 ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf-8'); 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)), []); 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 // Step 14: the CHANGELOG has the 1.9 delivery's section, with door lines the
// lines the release check accepts. The release check is imported as a module // release check accepts. The section is found by its title, before and after the
// namespace, so a missing parseDoors export fails an assertion, not the load. // 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 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() { // The delivery section's body.
const lines = read('CHANGELOG.md').split('\n'); const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
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 Unreleased section's body, after checking it is the first section. test('CHANGELOG: the plan_version 1.9 delivery section stands exactly once', () => {
function unreleasedBody() { assert.match(deliverySection(read('CHANGELOG.md')).heading, SECTION_HEADING);
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: 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'); assert.equal(typeof releaseCheck.parseDoors, 'function', 'scripts/release-check.mjs exports parseDoors');
const body = unreleasedBody(); const body = unreleasedBody();
const d = releaseCheck.parseDoors(body); 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.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.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')), 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))}`); `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 subsection = (body, name) => {
const lines = body.split('\n'); const lines = body.split('\n');
const start = lines.findIndex((l) => l.replace(/\s+$/, '') === `### ${name}`); 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; let end = lines.length;
for (let i = start + 1; i < lines.length; i++) { for (let i = start + 1; i < lines.length; i++) {
if (/^###? /.test(lines[i])) { end = i; break; } if (/^###? /.test(lines[i])) { end = i; break; }
@ -337,7 +348,7 @@ const subsection = (body, name) => {
}; };
const knownIssues = (body) => subsection(body, 'Known issues'); 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 body = unreleasedBody();
const known = knownIssues(body); const known = knownIssues(body);
assert.match(known, /session split/, `Known issues name the session split: ${JSON.stringify(known)}`); 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)}`); `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(); const body = unreleasedBody();
assert.doesNotMatch(body, /on\s+a\s+severity\s+tie/); 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', () => { test('CHANGELOG: the section names 08deae68, edf2ec56 and operator decision 27', () => {
const body = unreleasedBody(); const body = unreleasedBody();
assert.ok(body.includes('08deae68'), 'the Unreleased section names 08deae68'); assert.ok(body.includes('08deae68'), 'the delivery section names 08deae68');
assert.ok(body.includes('edf2ec56'), 'the Unreleased section names edf2ec56'); assert.ok(body.includes('edf2ec56'), 'the delivery section names edf2ec56');
assert.match(body, /\boperator decision 27\b/, 'the Unreleased section names operator decision 27'); assert.match(body, /\boperator decision 27\b/, 'the delivery section names operator decision 27');
}); });
test('CHANGELOG: the b4fa1927 bullet follows operator decision 29', () => { 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)}`); 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 body = unreleasedBody();
const flat = oneSpace(body); const flat = oneSpace(body);
for (const phrase of ['reruns red proof once with `plan_tests`', 'reruns the same way', 'the list red proof accepted']) { 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 { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { RULE_A, RULE_B, DIFF_FIELDS } from '../../lib/review/critic-diff.mjs'; 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 ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = rel => readFileSync(join(ROOT, rel), 'utf-8'); 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 // 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 // CHANGELOG's delivery section records it, Phase 9 says what a merge keeps, and
// keeps, and Handover 4 states the matcher's overlap rule and the distinct id. // Handover 4 states the matcher's overlap rule and the distinct id. The section
// The release check is imported as a module namespace, so a missing parseDoors // is found by its title, before and after the release commit
// export fails an assertion, not the load. // (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 releaseCheck = await import('../../scripts/release-check.mjs');
// The body of the open `## Unreleased` section, up to the next `## ` heading. // The delivery section's body, up to the next `## ` heading.
function unreleasedBody() { const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
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. // A `### <name>` body of the delivery section, up to the next heading.
function subsection(body, name) { function subsection(body, name) {
const lines = body.split('\n'); const lines = body.split('\n');
const start = lines.findIndex(l => l.replace(/\s+$/, '') === `### ${name}`); 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; let end = lines.length;
for (let i = start + 1; i < lines.length; i++) { for (let i = start + 1; i < lines.length; i++) {
if (/^###? /.test(lines[i])) { end = i; break; } 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'); assert.equal(typeof releaseCheck.parseDoors, 'function', 'scripts/release-check.mjs exports parseDoors');
const body = unreleasedBody(); const body = unreleasedBody();
const d = releaseCheck.parseDoors(body); 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: ..."'); 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))); const doors48 = d.entries.filter(e => /operator\s+decision\s+48\b/.test(flat(e.text)));
assert.ok(doors48.length >= 3, assert.ok(doors48.length >= 3,