1883 lines
118 KiB
JavaScript
1883 lines
118 KiB
JavaScript
// tests/lib/fix-round-4-5a-prose.test.mjs
|
|
// Prose pins for fix round 4-5a (the serious findings, plan
|
|
// .claude/projects/2026-10-20-fix-round-serious-findings): the fixes that are
|
|
// text a session or a helper reads. The name is the brief's;
|
|
// lib/util/test-census.mjs counts it as a behaviour test (it does not match
|
|
// PIN_FILE_RE). A test that carries a round id in brackets fails on an
|
|
// assertion at ab6b32c (the round's start point), except DJ's pins: they pass
|
|
// there by design, and tests/fix-round-4-5a-red.sh proves them red by mutation.
|
|
// Untagged tests (prefixes `helpers:`, `step 25:`, `keep:`, `control:`,
|
|
// `arch:`, `e2e script:`, `red script:`, `CHANGELOG:`) are outside the
|
|
// round's id list; a `[M1r]` test carries a 4.5b id and is that round's to
|
|
// judge. Fix round 4.5b edits some pins here in place where its fixes change
|
|
// the text they pin (for example the CHANGELOG siblings entry, K4): each keeps
|
|
// its tag or untagged prefix and still fails at ab6b32c.
|
|
// #23 code-correctness-reviewer cites a "MANDATORY OVERRIDE rule above"
|
|
// that is no longer there; the sentence says the rule itself.
|
|
// #24 research-scout says "Try Tavily first" but its tools held no Tavily
|
|
// tool; its tools line now holds both, in docs-researcher's order.
|
|
// #111 A reviewer drops a finding that feels off-tier only when it can say
|
|
// why it is wrong, and then as one Declined to Judge line with that
|
|
// reason; the skill's line agrees; an unknown rule_key is routed to
|
|
// `unverified`, not dropped (lib/review/coordinator-contract.mjs).
|
|
// Fix the SOURCE a pin guards, not the pin.
|
|
|
|
import { after, test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
|
import { readFrontmatterFields } from '../../lib/skills/skill-frontmatter.mjs';
|
|
import { childEnv } from '../helpers/child-env.mjs';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
|
|
const readCache = new Map();
|
|
|
|
/** A repo file's text; the file must exist. Read from disk once per run. */
|
|
function read(rel) {
|
|
if (!readCache.has(rel)) {
|
|
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
|
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
|
|
}
|
|
return readCache.get(rel);
|
|
}
|
|
|
|
/** Whitespace (line breaks too) collapsed to one space. */
|
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
|
|
/**
|
|
* The text of a heading's section, up to the next heading of the same or a
|
|
* higher level outside a fence. The heading must exist.
|
|
*/
|
|
function section(text, heading) {
|
|
const level = heading.match(/^#+/)[0].length;
|
|
const s = findSection(text, heading.replace(/^#+ /, ''), level);
|
|
assert.ok(s, `missing ${heading}`);
|
|
const lines = s.text.split('\n');
|
|
let fenced = false;
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
|
const m = !fenced && lines[i].match(/^(#+) /);
|
|
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
|
|
}
|
|
return s.text;
|
|
}
|
|
|
|
/** From the line holding `marker` to the first line where `stop` is true, flattened. */
|
|
function partAt(rel, marker, stop) {
|
|
const lines = read(rel).split('\n');
|
|
const start = lines.findIndex((l) => l.includes(marker));
|
|
assert.ok(start !== -1, `${rel} must hold "${marker}"`);
|
|
let end = lines.length;
|
|
for (let i = start + 1; i < lines.length; i++) if (stop(lines[i])) { end = i; break; }
|
|
return flat(lines.slice(start, end).join(' '));
|
|
}
|
|
|
|
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
|
|
function line(text, mod, verb = '') {
|
|
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
|
|
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
|
|
assert.equal(hits.length, 1, `expected exactly one \`${mod}${verb ? ` ${verb}` : ''}\` line; found ${hits.length}`);
|
|
return hits[0].trim();
|
|
}
|
|
|
|
/** Run a command line from the prose with the plugin root filled in. */
|
|
function bash(cmd, cwd) {
|
|
return spawnSync('bash', ['-c', cmd.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd, encoding: 'utf8' });
|
|
}
|
|
|
|
/** The runtime ```bash block of `text` that holds `cmd`, or undefined. */
|
|
function bashBlockHolding(text, cmd) {
|
|
return text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]).find((b) => b.includes(cmd));
|
|
}
|
|
|
|
/** A Phase 0 parser line run through bash() with `args` in place of $ARGUMENTS. */
|
|
function runParser(cmd, args) {
|
|
return bash(cmd.split('$ARGUMENTS').join(args), ROOT);
|
|
}
|
|
|
|
// --- #23 ---------------------------------------------------------------------------
|
|
|
|
test('[#23] no agent file refers to a MANDATORY OVERRIDE rule', () => {
|
|
const hits = readdirSync(join(ROOT, 'agents'))
|
|
.filter((f) => f.endsWith('.md'))
|
|
.filter((f) => read(`agents/${f}`).includes('MANDATORY OVERRIDE'));
|
|
assert.deepEqual(hits, [], 'an agent still points at a MANDATORY OVERRIDE rule that is not there');
|
|
const d5 = flat(section(read('agents/code-correctness-reviewer.md'), '### 5. Placeholder code — `PLACEHOLDER_IN_CODE` (MAJOR)'));
|
|
assert.match(d5, /Never accept "not implemented" placeholders as fine "for now"/);
|
|
});
|
|
|
|
// --- #24 ---------------------------------------------------------------------------
|
|
|
|
test('[#24] research-scout holds both Tavily tools, in docs-researcher\'s order, and still tries Tavily first', () => {
|
|
const text = read('agents/research-scout.md');
|
|
const fm = readFrontmatterFields(text);
|
|
assert.ok(fm.ok, fm.error);
|
|
assert.deepEqual(fm.fields.tools.value,
|
|
['WebSearch', 'WebFetch', 'Read', 'mcp__tavily__tavily_search', 'mcp__tavily__tavily_research']);
|
|
assert.match(text, /\*\*Try Tavily first\*\*/);
|
|
});
|
|
|
|
// --- #111 --------------------------------------------------------------------------
|
|
|
|
/** No bare "drop it" choice; a drop needs a reason, written as one Declined to Judge line. */
|
|
function dropsOnlyWithTrace(part, where) {
|
|
assert.doesNotMatch(part, /either drop (it|the finding)|or drop (it|the finding)\s*\.|drop (it|the finding) \(it was wrong\)/i,
|
|
`${where}: an off-tier finding may still be dropped with no trace`);
|
|
assert.match(part, /drop[^.]{0,60}only when you can say why it is wrong[^.]{0,120}Declined to Judge[^.]{0,60}with that reason/i,
|
|
`${where}: a drop must carry its reason on a Declined to Judge line`);
|
|
assert.match(part, /catalogue('s)? (tier|severity)/i, `${where}: the catalogue tier stays the default`);
|
|
// Every sentence that drops (other than "not / never dropped") carries the reason and the trace.
|
|
for (const s of part.split(/(?<=\.)\s+/)) {
|
|
if (!/\bdrop/i.test(s) || /\b(not|never) dropped\b/i.test(s)) continue;
|
|
assert.match(s, /only when you can say why it is wrong/i, `${where}: a drop sentence names no reason: ${s}`);
|
|
assert.match(s, /Declined to Judge/i, `${where}: a drop sentence leaves no Declined to Judge line: ${s}`);
|
|
}
|
|
}
|
|
|
|
const toBlank = (l) => l.trim() === '';
|
|
|
|
// Untagged: these test the helper itself, so they pass at ab6b32c by construction.
|
|
const COMPLIANT_DROP = 'If a finding feels less severe than its catalogue tier, keep the catalogue tier; drop it only '
|
|
+ 'when you can say why it is wrong, and then write one Declined to Judge line with that reason.';
|
|
|
|
test('step 25: dropsOnlyWithTrace throws on a compliant off-tier sentence followed by " Drop it."', () => {
|
|
assert.throws(() => dropsOnlyWithTrace(`${COMPLIANT_DROP} Drop it.`, 'planted'), /a drop sentence names no reason: Drop it\./);
|
|
});
|
|
|
|
test('step 25: dropsOnlyWithTrace does not throw on the compliant sentence alone (control)', () => {
|
|
assert.doesNotThrow(() => dropsOnlyWithTrace(COMPLIANT_DROP, 'control'));
|
|
});
|
|
|
|
test('[#111] code-correctness-reviewer dimension 4: a nice-to-have test gap is dropped only with a Declined to Judge line', () => {
|
|
dropsOnlyWithTrace(partAt('agents/code-correctness-reviewer.md', 'When the brief explicitly asks for tests', toBlank),
|
|
'code-correctness-reviewer dimension 4');
|
|
});
|
|
|
|
test('[#111] code-correctness-reviewer severity rules: an off-tier finding is dropped only with a Declined to Judge line', () => {
|
|
dropsOnlyWithTrace(partAt('agents/code-correctness-reviewer.md', 'If a finding feels off-tier', toBlank),
|
|
'code-correctness-reviewer ## Severity rules');
|
|
});
|
|
|
|
test('[#111] code-correctness-reviewer rules: "No silent severity downgrades" drops only with a Declined to Judge line', () => {
|
|
dropsOnlyWithTrace(partAt('agents/code-correctness-reviewer.md', '**No silent severity downgrades.**', (l) => /^- /.test(l) || toBlank(l)),
|
|
'code-correctness-reviewer ## Rules');
|
|
});
|
|
|
|
test('[#111] brief-conformance-reviewer severity rules: a finding is dropped only with a Declined to Judge line', () => {
|
|
dropsOnlyWithTrace(partAt('agents/brief-conformance-reviewer.md', 'If a finding feels less severe than its catalogue tier', toBlank),
|
|
'brief-conformance-reviewer ## Severity rules');
|
|
});
|
|
|
|
test('[#111] reviewing-adversarially: keep the tier, or drop as wrong with its reason on a Declined to Judge line', () => {
|
|
const s5 = flat(section(read('skills/reviewing-adversarially/SKILL.md'), '### 5. No downgrade without a reason'));
|
|
assert.match(s5, /keep the tier, or drop the finding as wrong with its reason on a Declined to Judge line; never soften it in between/);
|
|
assert.doesNotMatch(s5, /keep the tier or drop the finding as wrong;/);
|
|
});
|
|
|
|
/** The stale "unknown rule keys are dropped" line now says they are routed to `unverified`. */
|
|
function routedNotDropped(part, where) {
|
|
assert.doesNotMatch(part, /dropped by (the coordinator's reasonableness filter|Pass 3)/i, `${where}: still says unknown rule keys are dropped`);
|
|
assert.match(part, /routed to `unverified`/, `${where}: must say an unknown rule key is routed to \`unverified\``);
|
|
}
|
|
|
|
const toBullet = (l) => /^- /.test(l) || toBlank(l) || /^#/.test(l);
|
|
|
|
test('[#111] code-correctness-reviewer: an unknown rule_key is routed to `unverified`, not dropped', () => {
|
|
routedNotDropped(partAt('agents/code-correctness-reviewer.md', '`rule_key` MUST be in the catalogue', toBullet),
|
|
'code-correctness-reviewer ## JSON output rules');
|
|
});
|
|
|
|
test('[#111] brief-conformance-reviewer: an unknown rule_key is routed to `unverified`, not dropped', () => {
|
|
routedNotDropped(partAt('agents/brief-conformance-reviewer.md', '`rule_key` MUST be in the catalogue', toBullet),
|
|
'brief-conformance-reviewer ## JSON output rules');
|
|
});
|
|
|
|
test('[#111] review-coordinator input: a rule_key outside the catalogue is routed to `unverified` by Pass 3', () => {
|
|
routedNotDropped(partAt('agents/review-coordinator.md', '**Rule catalogue** — `lib/review/rule-catalogue.mjs`', toBullet),
|
|
'review-coordinator ## Input');
|
|
});
|
|
|
|
// The coordinator's Pass 3 as its 4-pass lists say it (agents/review-coordinator.md ### Pass 3).
|
|
const PASS3_ITEM = '**Cloudflare reasonableness** — drop a finding with no `file:line` citation (`no-citation`) or whose '
|
|
+ 'file is in neither the working tree nor the diff (`file-existence:refuted`); route one whose `rule_key` is not in '
|
|
+ '`RULE_CATALOGUE` (`unknown-rule_key`) or whose file cannot be checked (`file-existence:indeterminate`) to '
|
|
+ '`unverified`, which forbids `ALLOW`; reset a severity that does not match its catalogue tier.';
|
|
|
|
/** Item 3 of a 4-pass list: from "3. **Cloudflare reasonableness**" to item 4. */
|
|
const pass3Item = (rel) => partAt(rel, '3. **Cloudflare reasonableness**', (l) => /^4\. /.test(l));
|
|
|
|
/** No item 3 says an unknown rule_key or a "catalogue-violating" finding is dropped. */
|
|
function pass3DropsNoUnknownKey(item, where) {
|
|
assert.doesNotMatch(item, /catalogue-violating/, `${where}: item 3 still drops catalogue-violating findings`);
|
|
assert.doesNotMatch(item, /drop findings whose `rule_key` is not in/, `${where}: item 3 still drops an unknown rule_key`);
|
|
}
|
|
|
|
test('[#111] sibling: agents/review-orchestrator.md item 3 says what Pass 3 does', () => {
|
|
const item = pass3Item('agents/review-orchestrator.md');
|
|
assert.ok(item.includes(`3. ${PASS3_ITEM}`), `item 3 is not the Pass 3 sentence: ${item}`);
|
|
pass3DropsNoUnknownKey(item, 'agents/review-orchestrator.md');
|
|
});
|
|
|
|
test('[#111] sibling: commands/trekreview.md item 3 says what Pass 3 does, skipped in quick mode', () => {
|
|
const item = pass3Item('commands/trekreview.md');
|
|
const want = `3. ${PASS3_ITEM.replace('**Cloudflare reasonableness** —', '**Cloudflare reasonableness** (skipped in `quick` mode) —')}`;
|
|
assert.ok(item.includes(want), `item 3 is not the Pass 3 sentence: ${item}`);
|
|
pass3DropsNoUnknownKey(item, 'commands/trekreview.md');
|
|
});
|
|
|
|
test('[#111] sibling: the reasonablenessFilter docblock routes an unknown rule_key to `unverified`', () => {
|
|
const doc = partAt('lib/review/coordinator-contract.mjs', 'Pass 3 — Cloudflare reasonableness (deterministic subset)',
|
|
(l) => /^\s*\*\/\s*$/.test(l));
|
|
assert.ok(doc.includes('route an unknown rule_key to `unverified`'), `the docblock does not route an unknown rule_key: ${doc}`);
|
|
assert.ok(!doc.includes('or an unknown rule_key;'), 'the docblock still drops an unknown rule_key');
|
|
});
|
|
|
|
// Steps 2-24 use these helpers; referenced here so the file states its interface.
|
|
test('helpers: line and bash are the shared line-runner pair', () => {
|
|
assert.equal(typeof line, 'function');
|
|
assert.equal(typeof bash, 'function');
|
|
assert.equal(line('x\n node ${CLAUDE_PLUGIN_ROOT}/lib/util/a.mjs go\n', 'util/a.mjs', 'go'), 'node ${CLAUDE_PLUGIN_ROOT}/lib/util/a.mjs go');
|
|
const r = bash('printf %s "${CLAUDE_PLUGIN_ROOT}"', ROOT);
|
|
assert.equal(r.status, 0);
|
|
assert.equal(r.stdout, ROOT);
|
|
});
|
|
|
|
// --- #2 ----------------------------------------------------------------------------
|
|
// planning-orchestrator.md is a reference document that nothing runs; the text
|
|
// that sent a reader to it ("re-run", "must emit", "should have prevented",
|
|
// Consumer/Producer) now names /trekplan.
|
|
|
|
test('[#2] /trekexecute never sends anyone to re-run the planning reference document', () => {
|
|
const text = flat(read('commands/trekexecute.md'));
|
|
assert.doesNotMatch(text, /re-run planning-orchestrator/i, 'still says to re-run planning-orchestrator');
|
|
assert.doesNotMatch(text, /planning-orchestrator must/i, 'still says planning-orchestrator must emit or include');
|
|
assert.doesNotMatch(text, /planning-orchestrator should have prevented/i, 'still says planning-orchestrator should have prevented this');
|
|
assert.match(text, /Re-run \/trekplan — the plan is not executable\./);
|
|
assert.match(text, /Fix: re-run \/trekplan — the plan must use v1\.7 format/);
|
|
assert.match(text, /Fix: re-run \/trekplan — every step needs a Manifest YAML block/);
|
|
assert.doesNotMatch(text, /validator should have prevented this/, 'Check 3 still names a validator check that does not exist');
|
|
assert.match(text, /Stop execution\. This check runs at execution time, so it also catches a plan that was edited by hand after planning\./);
|
|
});
|
|
|
|
test('[#2] sibling: commands/trekbrief.md names /trekplan as what finishes', () => {
|
|
const text = flat(read('commands/trekbrief.md'));
|
|
assert.doesNotMatch(text, /When the planning-orchestrator finishes/);
|
|
assert.match(text, /When \/trekplan finishes/);
|
|
});
|
|
|
|
test('[#2] sibling: agents/brief-reviewer.md names /trekplan Phase 4b as its reader', () => {
|
|
const text = flat(read('agents/brief-reviewer.md'));
|
|
assert.doesNotMatch(text, /`planning-orchestrator` Phase 1b/);
|
|
assert.match(text, /\/trekplan Phase 4b/);
|
|
});
|
|
|
|
test('[#2] sibling: docs/HANDOVER-CONTRACTS.md has /trekplan, not planning-orchestrator, as Consumer, Producer and the one that must run', () => {
|
|
const text = read('docs/HANDOVER-CONTRACTS.md');
|
|
assert.doesNotMatch(text, /^\*\*(Consumer|Producer):\*\*[^\n]*planning-orchestrator/m, 'planning-orchestrator is still a Consumer or Producer');
|
|
assert.doesNotMatch(text, /planning-orchestrator\)? \*\*must\*\* run/, 'planning-orchestrator is still the one that must run the validator');
|
|
const lines = text.split('\n');
|
|
for (const want of [
|
|
'**Consumer:** `/trekplan` Phase 1 (project-dir auto-discovery) + `/trekplan` Phase 3 and Phase 7 (consumes findings as context).',
|
|
'**Consumer:** `/trekplan` Phase 1 (architecture-discovery) + `/trekplan` Phase 7 (cross-reference architecture-note as priors during synthesis).',
|
|
'**Producer:** `/trekplan` Phase 8 (plan synthesis, then the schema self-check via `plan-validator --strict`).',
|
|
]) assert.ok(lines.includes(want), `missing line: ${want}`);
|
|
assert.match(text, /`\/trekplan` Phase 8 \*\*must\*\* run `plan-validator --strict` before handing the plan to plan-critic\./);
|
|
});
|
|
|
|
test('[#2] sibling: docs/architecture.md names /trekplan Phase 8 as the plan-validator caller', () => {
|
|
const text = read('docs/architecture.md');
|
|
assert.doesNotMatch(text, /`planning-orchestrator` Phase 5\.5/);
|
|
assert.match(text, /^- `\/trekplan` Phase 8 → `plan-validator --strict` \(replaces 3 `grep -cE` calls\)$/m);
|
|
});
|
|
|
|
// --- #26 ---------------------------------------------------------------------------
|
|
|
|
test('[#26] session-decomposer says to re-run /trekplan, not the planning reference document', () => {
|
|
const text = flat(read('agents/session-decomposer.md'));
|
|
assert.doesNotMatch(text, /Re-run planning-orchestrator/i);
|
|
assert.match(text, /Re-run \/trekplan\./);
|
|
});
|
|
|
|
test('[#26] the three reference documents hold read-only tools', () => {
|
|
for (const name of ['planning-orchestrator', 'research-orchestrator', 'review-orchestrator']) {
|
|
const fm = readFrontmatterFields(read(`agents/${name}.md`));
|
|
assert.ok(fm.ok, fm.error);
|
|
assert.deepEqual(fm.fields.tools.value, ['Read', 'Glob', 'Grep'], `${name}: tools must be Read, Glob, Grep`);
|
|
assert.equal(fm.fields.model.value, 'opus', `${name}: model stays opus`);
|
|
}
|
|
});
|
|
|
|
// --- #52 ---------------------------------------------------------------------------
|
|
// /trekplan's "execute" answer began implementing the plan in the planning
|
|
// session, past /trekexecute's approval check (K2), test lock and step helpers.
|
|
// It now prints the /trekexecute line and stops.
|
|
|
|
/** The `/trekexecute` line /trekplan prints, in both forms. */
|
|
function printsTrekexecuteLine(part, where) {
|
|
assert.match(part, /`\/trekexecute --project \{project_dir\}`/, `${where}: must print the /trekexecute --project line`);
|
|
assert.match(part, /`\/trekexecute \{plan_path\}`/, `${where}: must print the /trekexecute {plan_path} line`);
|
|
}
|
|
|
|
/**
|
|
* No text that has /trekplan build the plan itself: no "Begin implementing",
|
|
* and no "in this session" other than the one prohibition sentence.
|
|
*/
|
|
function neverImplementsHere(part, where) {
|
|
const rest = part.split('Do not implement in this session.').join('');
|
|
assert.doesNotMatch(rest, /Begin implementing/i, `${where}: still begins implementing`);
|
|
assert.doesNotMatch(rest, /in this session/i, `${where}: still implements in this session`);
|
|
}
|
|
|
|
test('[#52] Phase 11 "execute": prints the /trekexecute line and stops, never implements in this session', () => {
|
|
const block = flat(section(read('commands/trekplan.md'), '### "execute" / "go" / "start"'));
|
|
neverImplementsHere(block, 'Phase 11 "execute"');
|
|
assert.match(block, /^### "execute" \/ "go" \/ "start" Do not implement in this session\./);
|
|
printsTrekexecuteLine(block, 'Phase 11 "execute"');
|
|
assert.match(block, /without a project directory — then run Phase 12 \(`outcome: execute`\)\./);
|
|
assert.match(block, /`\/trekexecute` is what checks the plan's approval \(K2\), locks the tests, runs one step helper per step and the review\./);
|
|
});
|
|
|
|
test('[#52] Phase 11 "team": no team path, the same /trekexecute line, then stop', () => {
|
|
const block = flat(section(read('commands/trekplan.md'), '### "execute with team" / "team"'));
|
|
neverImplementsHere(block, 'Phase 11 "team"');
|
|
assert.doesNotMatch(block, /proceed exactly as for "execute"/, 'Phase 11 "team": still proceeds as "execute" did');
|
|
assert.match(block, /There is no team path\. Print the same `\/trekexecute` line, then run Phase 12 \(`outcome: execute_team`\); parallel work is `--decompose` followed by `\/trekexecute`'s session orders\./);
|
|
});
|
|
|
|
test('[#52] Phase 10 menu: "execute" is for the /trekexecute line, and a stopped gate withholds it', () => {
|
|
const p10 = flat(section(read('commands/trekplan.md'), '## Phase 10 — Present and refine'));
|
|
assert.doesNotMatch(p10, /start implementing/i, 'Phase 10 still offers or withholds "start implementing"');
|
|
assert.match(p10, /- Say \*\*"execute"\*\* for the `\/trekexecute` line to run ← only when the gate exited 0/);
|
|
assert.match(p10, /`Execution is blocked: \{the gate's reasons\}` and do not print the `\/trekexecute` line, whatever is said next, until the gate exits 0\./);
|
|
});
|
|
|
|
test('[#52] sibling: plan mode step 3 goes through Phase 11 (its plan approval, then the /trekexecute line) and stops', () => {
|
|
const pm = flat(section(read('commands/trekplan.md'), '### In plan mode'));
|
|
assert.ok(pm.includes('Then go through Phase 11 (its plan approval, then the `/trekexecute` line) and stop — leaving plan mode is not an order to implement. '
|
|
+ 'Phase 11 asks again because the plan-mode approval is logged under Claude Code\'s plan file, and `/trekexecute` checks the copy at the plan destination.'),
|
|
'plan mode step 3 does not go through Phase 11\'s plan approval');
|
|
assert.doesNotMatch(pm, /Then continue with the operator's choice \(Phase 11\)\./, 'plan mode still continues with the operator\'s choice');
|
|
assert.ok(!pm.includes('Then print the `/trekexecute` line (Phase 11) and stop'), 'plan mode still skips Phase 11\'s plan approval');
|
|
});
|
|
|
|
test('[#52] hard rule: "No premature execution" holds, and nothing in Phases 10-11 implements the plan', () => {
|
|
const text = read('commands/trekplan.md');
|
|
const rules = flat(section(text, '## Hard rules'));
|
|
assert.match(rules, /\*\*No premature execution\*\*: Do not modify any project files until the user explicitly approves the plan, and never while `plan-gate\.mjs` exits non-zero on it\./);
|
|
for (const h of ['## Phase 10 — Present and refine', '## Phase 11 — Handoff']) {
|
|
const part = flat(section(text, h));
|
|
neverImplementsHere(part, h);
|
|
assert.doesNotMatch(part, /start implementing|Follow the plan exactly|Mark each step complete/i, `${h}: still has /trekplan build the plan`);
|
|
}
|
|
});
|
|
|
|
test('[#52] sibling: agents/planning-orchestrator.md says "execute" gives the /trekexecute line', () => {
|
|
const text = read('agents/planning-orchestrator.md');
|
|
assert.doesNotMatch(text, /Say "execute" to implement/, 'planning-orchestrator still says "execute" implements');
|
|
assert.ok(text.split('\n').includes('- Say "execute" for the /trekexecute line'), 'missing line: - Say "execute" for the /trekexecute line');
|
|
});
|
|
|
|
// --- #48 / #12 / #57 ---------------------------------------------------------------
|
|
// The planning and research spawn sites pinned `model: "opus"`, past the
|
|
// resolved model (brief signal > profile > agent frontmatter); every spawn now
|
|
// takes `phase_signal_result.model`. No `maxTurns` budget is promised: the
|
|
// Agent tool takes none, so "scale turns by size" was a promise nothing kept.
|
|
|
|
/**
|
|
* From the line holding `marker` (it must be the only one) to the end of the
|
|
* blank-line-separated paragraph after the marker's own paragraph, flattened.
|
|
*/
|
|
function paraAfter(rel, marker) {
|
|
const lines = read(rel).split('\n');
|
|
const hits = lines.filter((l) => l.includes(marker)).length;
|
|
assert.equal(hits, 1, `${rel}: expected exactly one line holding "${marker}"; found ${hits}`);
|
|
const start = lines.findIndex((l) => l.includes(marker));
|
|
let i = start;
|
|
while (i < lines.length && lines[i].trim() !== '') i++;
|
|
while (i < lines.length && lines[i].trim() === '') i++;
|
|
while (i < lines.length && lines[i].trim() !== '') i++;
|
|
return flat(lines.slice(start, i).join('\n'));
|
|
}
|
|
|
|
const PASS_MODEL = /Pass `model: \{phase_signal_result\.model\}` at this spawn; without it \(the `--brief` branch, where the resolver does not run, or a resolver that printed nothing\) the agent's own `model:` applies\./;
|
|
|
|
test('[#48] commands/trekplan.md pins no spawn to `model: "opus"`', () => {
|
|
assert.doesNotMatch(read('commands/trekplan.md'), /model: "opus"/);
|
|
});
|
|
|
|
for (const [where, marker] of [
|
|
['brief-reviewer (Phase 4b)', 'Launch the **brief-reviewer** agent.'],
|
|
['### Always launch (Phase 5)', '### Always launch (all codebase sizes):'],
|
|
['research-scout (Phase 5)', 'launch **research-scout** in'],
|
|
['plan-critic and scope-guardian (Phase 9)', 'it traces into `## Verification`.'],
|
|
]) {
|
|
test(`[#48] ${where}: the spawn takes phase_signal_result.model, the agent's own model: as the fallback`, () => {
|
|
const part = paraAfter('commands/trekplan.md', marker);
|
|
assert.match(part, PASS_MODEL, `${where}: missing the resolved-model paragraph after "${marker}"`);
|
|
assert.doesNotMatch(part, /model: "opus"/);
|
|
});
|
|
}
|
|
|
|
test('[#48] sibling: session-decomposer (Phase 1.5): --decompose has no resolved model, so the spawn passes no model:', () => {
|
|
const part = paraAfter('commands/trekplan.md', 'Launch the **session-decomposer** agent:');
|
|
assert.ok(part.includes('Phase 1 runs the model resolver only for `--project`, so `--decompose` has no resolved model: this spawn '
|
|
+ 'passes no `model:`, and the agent\'s own `model:` applies.'), `the session-decomposer paragraph does not say it passes no model: ${part}`);
|
|
assert.ok(!part.includes('phase_signal_result'), 'the session-decomposer spawn still names phase_signal_result');
|
|
assert.doesNotMatch(part, /model: "opus"/);
|
|
});
|
|
|
|
test('[#48] convention-scanner (Phase 5): the spawn takes phase_signal_result.model, the frontmatter as the fallback', () => {
|
|
const part = paraAfter('commands/trekplan.md', 'use the `convention-scanner` plugin agent');
|
|
assert.match(part, /\(model: phase_signal_result\.model, the agent's frontmatter as the fallback\)/);
|
|
assert.doesNotMatch(part, /model: "opus"/);
|
|
});
|
|
|
|
test('[#48] deep-dives (Phase 6): the spawn takes phase_signal_result.model, else opus', () => {
|
|
const part = paraAfter('commands/trekplan.md', 'spawn a targeted deep-dive agent');
|
|
assert.match(part, /\(model: phase_signal_result\.model, else opus — Explore is Claude Code's built-in agent and has no Voyage frontmatter to fall back to, subagent_type: "Explore"\)/);
|
|
assert.doesNotMatch(part, /model: "opus"/);
|
|
});
|
|
|
|
test('[#48] sibling: agents/planning-orchestrator.md:137 takes phase_signal_result.model', () => {
|
|
const text = read('agents/planning-orchestrator.md');
|
|
assert.doesNotMatch(text, /model: "opus"/);
|
|
const part = partAt('agents/planning-orchestrator.md', '**Convention Scanner** — use the `convention-scanner` plugin agent', toBlank);
|
|
assert.match(part, /\(model: phase_signal_result\.model, the agent's frontmatter as the fallback\)/);
|
|
});
|
|
|
|
test('[#12] trekresearch Phase 5 follow-ups take phase_signal_result.model, else opus', () => {
|
|
const text = read('commands/trekresearch.md');
|
|
const p5 = flat(section(text, '## Phase 5 — Targeted follow-ups'));
|
|
assert.doesNotMatch(p5, /model: "opus"/);
|
|
const part = partAt('commands/trekresearch.md', 'launch a targeted follow-up agent', toBlank);
|
|
assert.match(part, /launch a targeted follow-up agent \(model: phase_signal_result\.model, else opus — a follow-up may be one of Claude Code's built-in agents, which have no Voyage frontmatter to fall back to; see Launch rules\)/);
|
|
assert.match(flat(text), /captured as `phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject the resolved model/);
|
|
});
|
|
|
|
test('[#57] commands/trekresearch.md promises no maxTurns budget', () => {
|
|
const text = read('commands/trekresearch.md');
|
|
assert.doesNotMatch(text, /maxTurns/);
|
|
assert.match(text, /- convention-scanner: medium\+ codebases only \(50\+ files\)/);
|
|
});
|
|
|
|
test('[#57] sibling: commands/trekplan.md promises no maxTurns budget, and Phase 4 calibrates no turns', () => {
|
|
const text = read('commands/trekplan.md');
|
|
assert.doesNotMatch(text, /maxTurns/);
|
|
const p4 = flat(section(text, '## Phase 4 — Codebase sizing'));
|
|
assert.doesNotMatch(p4, /calibrate agent turns/);
|
|
assert.match(p4, /Determine codebase scale to decide whether convention-scanner runs\./);
|
|
assert.match(text, /^\*\*All agents run for all codebase sizes\.\*\*$/m);
|
|
});
|
|
|
|
test('[#57] sibling: agents/planning-orchestrator.md promises no maxTurns budget and scales no turns', () => {
|
|
const text = read('agents/planning-orchestrator.md');
|
|
assert.doesNotMatch(text, /maxTurns/);
|
|
assert.doesNotMatch(text, /Scale turns/i);
|
|
assert.match(text, /^\*\*All agents run for all codebase sizes\.\*\*$/m);
|
|
assert.match(text, /^- \*\*Adaptive:\*\* All agents run for all sizes\.$/m);
|
|
});
|
|
|
|
test('keep: /trekexecute\'s controller and its model note stay pinned to opus', () => {
|
|
const text = read('commands/trekexecute.md');
|
|
assert.match(text, /`subagent_type: "general-purpose"`, `model: "opus"`, no `name`/);
|
|
assert.match(flat(text), /controller's `model: "opus"` — so `phase_signal_result\.model` still has no spawn site here/);
|
|
});
|
|
|
|
// --- #46 ---------------------------------------------------------------------------
|
|
// Six commands said "Stats records emit `profile`, `profile_source` ..." under
|
|
// a stats heredoc that writes none of those keys. A paragraph outside a code
|
|
// fence that mentions stats may name a profile key only when the command's own
|
|
// `stats-append.mjs <kind> <<'JSON'` record writes it.
|
|
|
|
const STATS_PROFILE_KEYS = ['profile', 'phase_models', 'parallel_agents', 'external_research_enabled',
|
|
'profile_source', 'model_used', 'phase_models_resolved'];
|
|
|
|
/** The keys of every `stats-append.mjs <kind> <<'JSON'` … `JSON` heredoc in `text`. */
|
|
function statsHeredocs(text) {
|
|
const out = [];
|
|
for (const m of text.matchAll(/stats-append\.mjs (\S+) <<'JSON'\n([\s\S]*?)\nJSON\s*$/gm)) {
|
|
out.push({ kind: m[1], keys: new Set([...m[2].matchAll(/"([A-Za-z_][A-Za-z0-9_]*)"\s*:/g)].map((k) => k[1])) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Every backticked profile key named in a paragraph (outside code fences,
|
|
* split on blank lines) that mentions stats, and not in `written`; sorted.
|
|
*/
|
|
function unwrittenStatsKeys(text, written) {
|
|
const paras = [];
|
|
let cur = [];
|
|
let fenced = false;
|
|
for (const l of text.split('\n')) {
|
|
if (/^\s{0,3}(`{3,}|~{3,})/.test(l)) { fenced = !fenced; paras.push(cur); cur = []; continue; }
|
|
if (fenced) continue;
|
|
if (l.trim() === '') { paras.push(cur); cur = []; } else cur.push(l);
|
|
}
|
|
paras.push(cur);
|
|
const bad = new Set();
|
|
for (const p of paras.map((x) => x.join('\n')).filter((p) => /stats/i.test(p))) {
|
|
for (const m of p.matchAll(/`([^`\n]+)`/g)) {
|
|
if (STATS_PROFILE_KEYS.includes(m[1]) && !written.has(m[1])) bad.add(m[1]);
|
|
}
|
|
}
|
|
return [...bad].sort();
|
|
}
|
|
|
|
for (const cmd of ['trekbrief', 'trekresearch', 'trekplan', 'trekexecute', 'trekreview', 'trekcontinue']) {
|
|
test(`[#46] commands/${cmd}.md: every profile key its stats prose names is one its stats record writes`, () => {
|
|
const text = read(`commands/${cmd}.md`);
|
|
const docs = statsHeredocs(text);
|
|
assert.equal(docs.length, 1, `${cmd}: expected exactly one stats-append.mjs heredoc; found ${docs.length}`);
|
|
assert.equal(docs[0].kind, cmd, `${cmd}: the stats heredoc writes kind ${docs[0].kind}`);
|
|
assert.ok(docs[0].keys.has('ts'), `${cmd}: the stats heredoc's keys were not read`);
|
|
assert.deepEqual(unwrittenStatsKeys(text, docs[0].keys), [],
|
|
`${cmd}: stats prose promises keys its stats record does not write`);
|
|
});
|
|
}
|
|
|
|
test('control: the stats-key checker reports a planted paragraph that promises `profile`', () => {
|
|
const written = statsHeredocs(read('commands/trekplan.md'))[0].keys;
|
|
assert.deepEqual(unwrittenStatsKeys('Intro.\n\nStats rows carry `profile`.\n', written), ['profile']);
|
|
assert.deepEqual(unwrittenStatsKeys('Intro.\n\n```\nStats rows carry `profile`.\n```\n', written), []);
|
|
assert.deepEqual(unwrittenStatsKeys('Rows carry `profile`.\n', written), []);
|
|
});
|
|
|
|
test('[#46] profile_source appears in no file under commands/', () => {
|
|
const hits = readdirSync(join(ROOT, 'commands'))
|
|
.filter((f) => read(`commands/${f}`).includes('profile_source'));
|
|
assert.deepEqual(hits, [], 'a command still names profile_source, a key no stats record writes');
|
|
});
|
|
|
|
test('[#46] sibling: docs/profiles.md claims no profile field in the stats records', () => {
|
|
const text = read('docs/profiles.md');
|
|
const f = flat(text);
|
|
assert.doesNotMatch(f, /recorded in two places/);
|
|
assert.doesNotMatch(f, /recorded in one place/);
|
|
assert.ok(text.split('\n').includes('The plan-file fields for the resolved value:'),
|
|
'missing line: The plan-file fields for the resolved value:');
|
|
assert.doesNotMatch(f, /Stats stream/);
|
|
assert.doesNotMatch(f, /`profile_source` distinguishes/);
|
|
assert.doesNotMatch(f, /Per-profile actuals are emitted/);
|
|
assert.match(f, /The stats records carry no profile field, so they cannot split cost by profile\./);
|
|
});
|
|
|
|
/** The one blank-line paragraph of `rel` that opens with `opening`, flattened. */
|
|
function paraOpening(rel, opening) {
|
|
const hits = read(rel).split(/\n[ \t]*\n/).filter((p) => p.trimStart().startsWith(opening));
|
|
assert.equal(hits.length, 1, `${rel}: expected exactly one paragraph opening "${opening}"; found ${hits.length}`);
|
|
return flat(hits[0]);
|
|
}
|
|
|
|
for (const rel of ['README.md', 'docs/operations.md']) {
|
|
test(`[#46] sibling: ${rel} profile paragraph says nothing is emitted to stats`, () => {
|
|
const p = paraOpening(rel, 'Four built-in model profiles');
|
|
assert.doesNotMatch(p, /emitted to/, `${rel}: the profile paragraph still says the profile is emitted`);
|
|
assert.doesNotMatch(p, /stats/i, `${rel}: the profile paragraph still names stats`);
|
|
});
|
|
}
|
|
|
|
// --- #47 ---------------------------------------------------------------------------
|
|
// /trekbrief Phase 5 said "Skip this phase if research_topics = 0. Proceed
|
|
// directly to Phase 6.", so a brief with no research topics walked into the
|
|
// auto-only research dispatch nobody opted into. It now prints the manual
|
|
// next steps without the /trekresearch lines, runs Phase 7 and stops.
|
|
|
|
test('[#47] Phase 5: a brief with no research topics prints /trekplan --project and stops, never proceeds to Phase 6', () => {
|
|
const text = read('commands/trekbrief.md');
|
|
const p5 = flat(section(text, '## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)'));
|
|
assert.doesNotMatch(p5, /Skip this phase if research_topics = 0/, 'Phase 5 still skips straight on with zero topics');
|
|
const opening = partAt('commands/trekbrief.md', '## Phase 5 — Auto-orchestration opt-in', (l) => l.startsWith('Ask the user'));
|
|
assert.doesNotMatch(opening, /proceed directly to Phase 6/i, 'a zero-topic brief still proceeds directly to Phase 6');
|
|
assert.match(opening, /\*\*With research_topics = 0\*\*, print the manual-path output below without the `\/trekresearch` lines \(Brief, then `\/trekplan --project \{PROJECT_DIR\}`, then `\/trekexecute --project \{PROJECT_DIR\}`\), then run Phase 7 \(`auto_result: manual`\) and stop\./);
|
|
assert.match(opening, /Phase 6 is never entered without the operator's opt-in\./);
|
|
const manual = flat(section(text, '### Manual path (default)'));
|
|
assert.match(manual, /Then: \/trekplan --project \{PROJECT_DIR\} Then: \/trekexecute --project \{PROJECT_DIR\}/);
|
|
assert.match(manual, /Run Phase 7 \(`auto_result: manual`\), then stop\. Do not continue to Phase 6\./);
|
|
});
|
|
|
|
test('[#47] Phase 6 runs only after the operator opted into auto mode and Phase 4h approved the intent', () => {
|
|
const p6 = flat(section(read('commands/trekbrief.md'), '## Phase 6 — Auto research dispatch (auto path only)'));
|
|
assert.match(p6, /^## Phase 6 — Auto research dispatch \(auto path only\) \*\*Runs only when user opted into auto mode,? and only after Phase 4h approved the intent\.\*\*/);
|
|
});
|
|
|
|
test('keep: /trekbrief\'s auto path still requires an approved intent', () => {
|
|
assert.equal(partAt('commands/trekbrief.md', '**Auto requires an approved intent.**', toBlank),
|
|
'**Auto requires an approved intent.** If Phase 4h ended "Leave unapproved", the auto chain would halt at `/trekplan` anyway — print the manual-path output instead, run Phase 7 (`auto_result: manual`) and stop.');
|
|
});
|
|
|
|
// Every exit of /trekbrief's manual and auto paths before the plan runs Phase 7
|
|
// first, with the auto_result Phase 7 lists: manual, cancelled or failed.
|
|
const BRIEF_OLD_EXITS = [
|
|
'Stop. Do not continue to Phase 6.',
|
|
'print the manual-path output instead and stop.',
|
|
'fall back to the manual path output and stop.',
|
|
'If cancelled → fall back to manual path output and stop.',
|
|
];
|
|
|
|
/** No old exit sentence stands in commands/trekbrief.md (case-sensitive). */
|
|
function noOldBriefExit() {
|
|
const f = flat(read('commands/trekbrief.md'));
|
|
for (const old of BRIEF_OLD_EXITS) assert.ok(!f.includes(old), `an exit still stops without Phase 7: ${old}`);
|
|
}
|
|
|
|
test('[#47] sibling: Phase 5 runs Phase 7 before each of its three exits', () => {
|
|
const p5 = flat(section(read('commands/trekbrief.md'), '## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)'));
|
|
for (const s of [
|
|
'Run Phase 7 (`auto_result: manual`), then stop. Do not continue to Phase 6.',
|
|
'print the manual-path output instead, run Phase 7 (`auto_result: manual`) and stop.',
|
|
'fall back to the manual path output, run Phase 7 (`auto_result: cancelled`) and stop.',
|
|
]) assert.ok(p5.includes(s), `Phase 5 is missing the exit: ${s}`);
|
|
noOldBriefExit();
|
|
});
|
|
|
|
test('[#47] sibling: Phase 6 runs Phase 7 before each of its three exits', () => {
|
|
const p6 = flat(section(read('commands/trekbrief.md'), '## Phase 6 — Auto research dispatch (auto path only)'));
|
|
for (const s of [
|
|
'If cancelled → fall back to manual path output, run Phase 7 (`auto_result: cancelled`) and stop.',
|
|
'ask the user how to proceed (retry, skip missing topic, cancel). On cancel, run Phase 7 (`auto_result: cancelled`) and stop.',
|
|
'Wait for the plan to be written to `{PROJECT_DIR}/plan.md` before continuing. If `/trekplan` stops before it writes the plan, '
|
|
+ 'report where it stopped, run Phase 7 (`auto_result: failed`) and stop.',
|
|
]) assert.ok(p6.includes(s), `Phase 6 is missing the exit: ${s}`);
|
|
noOldBriefExit();
|
|
});
|
|
|
|
// --- #1 ----------------------------------------------------------------------------
|
|
// `--gates` meant one thing in the shim (a boolean), another in the docs (an
|
|
// open/closed/adaptive strictness policy), and /trekexecute promised pauses it
|
|
// never makes. Each of the four commands now holds one rule: the next token is
|
|
// the flag's value only when it is true, false, closed or open; the shim maps
|
|
// it, run once; a broken shim falls back to the same mapping in prose and the
|
|
// run goes on. `gates_mode` changes nothing in `/trekexecute`: its one stop is
|
|
// the merge into main.
|
|
|
|
const GATES_COMMANDS = ['trekbrief', 'trekresearch', 'trekplan', 'trekexecute'];
|
|
|
|
const GATES_RULE = [
|
|
'`--gates` takes the next token as its value only when it is `true`, `false`, `closed` or `open` (strip both); any other next token is not its value (strip only the flag).',
|
|
'Run the shim once to map the value, bare when there is no value:',
|
|
'`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it false, and nothing else reads the state.',
|
|
'On exit 1 or no JSON (a broken shim; the four values cannot cause it), print the error in one line and map in prose instead: bare, `true`, `closed` → true; `false`, `open` → false. The run goes on either way.',
|
|
];
|
|
|
|
/** The command's one shim line, `[value]` filled in with `value` (left out when ''). */
|
|
function gatesShim(cmd, value) {
|
|
const l = line(read(`commands/${cmd}.md`), 'util/autonomy-gate.mjs', '--event start');
|
|
assert.match(l, / --state idle --event start --gates \[value\]$/, `${cmd}: the shim line must end "--gates [value]"`);
|
|
return l.replace(' [value]', value ? ` ${value}` : '');
|
|
}
|
|
|
|
const shimCache = new Map();
|
|
|
|
/** Run a shim line from the prose; its `next_state`. Each distinct line runs once. */
|
|
function shimNext(cmdLine) {
|
|
if (!shimCache.has(cmdLine)) {
|
|
const r = bash(cmdLine, ROOT);
|
|
assert.equal(r.status, 0, `shim exited ${r.status}: ${r.stdout}${r.stderr}`);
|
|
shimCache.set(cmdLine, JSON.parse(r.stdout).next_state);
|
|
}
|
|
return shimCache.get(cmdLine);
|
|
}
|
|
|
|
for (const cmd of GATES_COMMANDS) {
|
|
test(`[#1] commands/${cmd}.md holds the shared --gates rule and a shim line that maps closed to gates_on`, () => {
|
|
const text = read(`commands/${cmd}.md`);
|
|
const f = flat(text);
|
|
for (const s of GATES_RULE) assert.ok(f.includes(s), `${cmd}: missing the shared --gates sentence: ${s}`);
|
|
assert.doesNotMatch(text, /--state X --event Y/, `${cmd}: the placeholder shim line is still there`);
|
|
assert.doesNotMatch(f, /consumed by the autonomy-gate/, `${cmd}: still says the flag is consumed by the state machine`);
|
|
assert.equal(shimNext(gatesShim(cmd, 'closed')), 'gates_on');
|
|
});
|
|
}
|
|
|
|
// Review focus: `/trekplan --gates --project d` and `/trekexecute --gates plan.md`
|
|
// are a bare --gates (the next token is not a value), so the shim runs bare.
|
|
test('keep: every command\'s shim line maps a bare --gates to gates_on and open to auto_running', () => {
|
|
for (const cmd of GATES_COMMANDS) {
|
|
assert.equal(shimNext(gatesShim(cmd, '')), 'gates_on', `${cmd}: bare --gates`);
|
|
assert.equal(shimNext(gatesShim(cmd, 'open')), 'auto_running', `${cmd}: --gates open`);
|
|
}
|
|
});
|
|
|
|
const GATES_ONE_PAUSE = '`gates_mode = true` adds one pause, before the auto chain starts (Phase 5); the chain does not pass '
|
|
+ '`--gates` on to `/trekresearch` or `/trekplan`. Default `gates_mode = false` skips that pause.';
|
|
|
|
test('[#1] sibling: commands/trekbrief.md promises no execute pause and no pause at every wave in the executor', () => {
|
|
const f = flat(read('commands/trekbrief.md'));
|
|
assert.doesNotMatch(f, /every wave in the executor/, 'trekbrief still promises a pause at every wave in the executor');
|
|
assert.doesNotMatch(f, /\(research, plan, execute\)/, 'trekbrief still promises an execute pause');
|
|
assert.ok(f.includes(GATES_ONE_PAUSE), 'trekbrief does not say --gates adds one pause before its auto chain');
|
|
});
|
|
|
|
test('[#1] sibling: /trekbrief Phase 1 says --gates adds one pause, before the auto chain', () => {
|
|
const p1 = flat(section(read('commands/trekbrief.md'), '## Phase 1 — Parse mode and validate input'));
|
|
assert.ok(p1.includes(GATES_ONE_PAUSE), 'Phase 1 does not say --gates adds one pause before the auto chain');
|
|
assert.ok(!p1.includes('every phase boundary in the downstream pipeline'), 'Phase 1 still promises a pause at every downstream phase boundary');
|
|
assert.ok(!p1.includes('runs continuously until the main-merge gate'), 'Phase 1 still says the auto chain runs until the main-merge gate');
|
|
});
|
|
|
|
test('[#1] sibling: /trekbrief Phase 5 says the auto chain ends at the plan, never at the main-merge gate', () => {
|
|
const p5 = flat(section(read('commands/trekbrief.md'), '## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)'));
|
|
assert.ok(p5.includes('The chain ends when `/trekplan` has written the plan (Step 6e); it never starts `/trekexecute`.'),
|
|
'Phase 5 does not say the chain ends when /trekplan has written the plan');
|
|
assert.ok(!p5.includes('The chain stops only at the main-merge gate'), 'Phase 5 still says the chain stops only at the main-merge gate');
|
|
});
|
|
|
|
test('[#1] sibling: docs/operations.md, docs/command-modes.md and README.md say /trekexecute accepts the flag and pauses on nothing', () => {
|
|
for (const rel of ['docs/operations.md', 'docs/command-modes.md', 'README.md']) {
|
|
assert.ok(flat(read(rel)).includes('`/trekexecute` accepts the flag and pauses on nothing'), `${rel}: missing "accepts the flag and pauses on nothing"`);
|
|
}
|
|
for (const rel of ['docs/operations.md', 'docs/command-modes.md', 'README.md', EXEC]) {
|
|
const f = flat(read(rel));
|
|
for (const old of ['records `gates_mode`', 'Record gates_mode', 'records the setting']) {
|
|
assert.ok(!f.includes(old), `${rel}: still says "${old}"`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('[#1] sibling: commands/trekexecute.md: `gates_mode` changes nothing in `/trekexecute`, and its one stop is the merge into main', () => {
|
|
const f = flat(read('commands/trekexecute.md'));
|
|
assert.doesNotMatch(f, /consumed by the autonomy-gate state machine/);
|
|
assert.ok(f.includes('`gates_mode` changes nothing in `/trekexecute`: no phase of it pauses on it. The one stop is the merge into main, which always asks (Phase 8).'),
|
|
'trekexecute does not say gates_mode changes nothing there and the one stop is the merge into main');
|
|
});
|
|
|
|
test('[#1] sibling: docs/operations.md gives the four values and the bare form, and no executor strictness table', () => {
|
|
const text = read('docs/operations.md');
|
|
const sec = flat(section(text, '## Autonomy mode (`--gates`, v3.4.0)'));
|
|
assert.doesNotMatch(text, /\{open\\?\|closed\\?\|adaptive\}/);
|
|
assert.doesNotMatch(sec, /\| `gates_mode` \| Derived from/, 'the executor strictness table is still there');
|
|
assert.doesNotMatch(sec, /\| `adaptive` \|/, 'the executor strictness table is still there');
|
|
assert.doesNotMatch(sec, /how strict/);
|
|
assert.doesNotMatch(sec, /gates_mode_flag_override/, 'still names an override record the executor does not write');
|
|
assert.match(sec, /accept `--gates \[true\|false\|closed\|open\]`/);
|
|
assert.match(sec, /A bare `--gates`, `--gates true` or `--gates closed` turns gating \*\*on\*\*/);
|
|
for (const s of ['gates_on', 'auto_running', 'paused_for_gate']) assert.ok(sec.includes(s), `the state ${s} stays named`);
|
|
});
|
|
|
|
test('[#1] sibling: docs/architecture.md does not call --gates open the executor\'s low effort', () => {
|
|
const text = read('docs/architecture.md');
|
|
assert.doesNotMatch(text, /--gates open/);
|
|
assert.match(text, /\(`\/trekexecute` low effort = one session at a time\)/);
|
|
});
|
|
|
|
test('[#1] sibling: docs/command-modes.md gives every --gates row the four values and no strictness policy', () => {
|
|
const text = read('docs/command-modes.md');
|
|
assert.doesNotMatch(text, /--gates \{true\\\|false\}/);
|
|
assert.doesNotMatch(text, /\{open\\?\|closed\\?\|adaptive\}/);
|
|
assert.doesNotMatch(text, /Policy \(`gates_mode`\)/, 'a row still points at a gates_mode strictness policy');
|
|
const rows = text.split('\n').filter((l) => l.startsWith('| `--gates'));
|
|
assert.equal(rows.length, 4, 'one --gates row per command');
|
|
for (const r of rows) {
|
|
assert.ok(r.startsWith('| `--gates [true\\|false\\|closed\\|open]` |'), `row does not give the four values: ${r}`);
|
|
assert.match(r, /`## Autonomy mode` in `docs\/operations\.md`/);
|
|
}
|
|
});
|
|
|
|
test('[#1] sibling: README.md gives the four values, and no strictness table', () => {
|
|
const text = read('README.md');
|
|
const sec = flat(section(text, '### Autonomy mode (`--gates`, v3.4.0)'));
|
|
assert.doesNotMatch(text, /\{open\|closed\|adaptive\}/);
|
|
assert.doesNotMatch(sec, /\| Value \| Behavior \|/, 'the strictness table is still there');
|
|
assert.doesNotMatch(sec, /`adaptive` \(default\)/);
|
|
assert.match(sec, /accept `--gates \[true\|false\|closed\|open\]`/);
|
|
});
|
|
|
|
// --- #30 ---------------------------------------------------------------------------
|
|
|
|
const EXEC = 'commands/trekexecute.md';
|
|
const SAME_ON_OPUS = 'the step helper and the controller stay on opus';
|
|
|
|
/** Phase 1's top-level numbered rules, each as [number, text of its first line]. */
|
|
function phase1Rules() {
|
|
const sec = section(read(EXEC), '## Phase 1 — Parse mode and validate input').split('\n');
|
|
const stop = sec.findIndex((l) => l.startsWith('If no path is provided'));
|
|
assert.ok(stop !== -1, 'Phase 1 must still say what happens when no path is provided');
|
|
return sec.slice(0, stop)
|
|
.map((l) => l.match(/^(\d+)\. (.*)$/))
|
|
.filter(Boolean)
|
|
.map((m) => [Number(m[1]), m[2]]);
|
|
}
|
|
|
|
test('[#30] the frontmatter hint names --profile <name> and --gates', () => {
|
|
const m = read(EXEC).match(/^argument-hint: (.*)$/m);
|
|
assert.ok(m, 'trekexecute has no argument-hint');
|
|
assert.ok(m[1].includes('[--profile <name>]'), `the hint does not name --profile <name>: ${m[1]}`);
|
|
assert.ok(m[1].includes('[--gates [true|false|closed|open]]'), `the hint does not name --gates: ${m[1]}`);
|
|
assert.equal(m[1],
|
|
'"[--project <dir>] [--profile <name>] [--gates [true|false|closed|open]] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"');
|
|
});
|
|
|
|
test('[#30] Phase 1 strips --profile <name>, and its last rule reads what is left after the flags are stripped', () => {
|
|
const f = flat(section(read(EXEC), '## Phase 1 — Parse mode and validate input'));
|
|
assert.doesNotMatch(f, /the entire argument string is the file path/i,
|
|
'Phase 1 still reads the whole argument string as the file path');
|
|
const rules = phase1Rules();
|
|
assert.deepEqual(rules.map(([n]) => n), rules.map((_, i) => i + 1), 'Phase 1 rules are not numbered 1..n');
|
|
const [last, profile] = [rules[rules.length - 1][1], rules[rules.length - 2][1]];
|
|
assert.ok(last.startsWith('Otherwise: the arguments left after every flag above is stripped are the file path.'),
|
|
`the last rule does not read what is left after the flags: ${last}`);
|
|
assert.ok(profile.includes('`--profile <name>`: set `profile = {name}`; strip both tokens'),
|
|
`the rule before the last one is not the --profile strip rule: ${profile}`);
|
|
});
|
|
|
|
test('[#30] the usage block names --profile and --gates, and its --profile line keeps the helper and the controller on opus', () => {
|
|
const usage = partAt(EXEC, 'Usage: /trekexecute <plan.md>', (l) => l.startsWith('```'));
|
|
assert.ok(usage.includes('/trekexecute --profile <name> <plan.md>'), 'the usage block has no --profile line');
|
|
assert.ok(usage.includes(`--profile Profile for this run's resolver; ${SAME_ON_OPUS}`),
|
|
'the Modes list has no --profile line, or it does not keep the helper and the controller on opus');
|
|
assert.ok(usage.includes('--gates Accepted, pauses on nothing: bare, true or closed = on; false or open = off'),
|
|
'the Modes list has no --gates line');
|
|
});
|
|
|
|
test('[#30] sibling: Profile section says the profile changes no model here', () => {
|
|
const f = flat(section(read(EXEC), '## Profile (v4.1)'));
|
|
assert.doesNotMatch(f, /drives `phase_models\.execute`/, 'the Profile section still says the profile drives the execute model');
|
|
assert.ok(f.includes('The selected profile goes to the resolver. It changes no model here: the step helper and the '
|
|
+ 'controller run opus (Composition rule below), and nothing in this command reads `parallel_agents`.'),
|
|
'the Profile section does not say the profile changes no model here');
|
|
});
|
|
|
|
test('[#30] sibling: docs/command-modes.md /trekexecute --profile row keeps the helper and the controller on opus', () => {
|
|
const sec = section(read('docs/command-modes.md'), '## /trekexecute modes');
|
|
const rows = sec.split('\n').filter((l) => l.startsWith('| `--profile <name>` |'));
|
|
assert.equal(rows.length, 1, 'expected one --profile row under /trekexecute modes');
|
|
assert.doesNotMatch(rows[0], /Model profile for the execute phase/);
|
|
assert.ok(rows[0].includes(`| (v4.1.0) Profile for this run's resolver; ${SAME_ON_OPUS}.`), rows[0]);
|
|
});
|
|
|
|
test('[#30] sibling: README.md /trekexecute Profile row keeps the helper and the controller on opus', () => {
|
|
const rows = read('README.md').split('\n').filter((l) => l.startsWith('| **Profile** | `/trekexecute '));
|
|
assert.equal(rows.length, 1, 'expected one /trekexecute Profile row in README.md');
|
|
assert.doesNotMatch(rows[0], /Pin model profile for execution/);
|
|
assert.ok(rows[0].includes(`| (v4.1.0) Profile for this run's resolver; ${SAME_ON_OPUS}.`), rows[0]);
|
|
});
|
|
|
|
// --- #33 ---------------------------------------------------------------------------
|
|
// /trekexecute promised a brief check and an effort-driven gates mode that no
|
|
// line ran. Phase 1's --project branch now runs the two lines /trekplan runs,
|
|
// for the execute phase, and halts only on BRIEF_V51_MISSING_SIGNALS (a brief
|
|
// edited since planning does not stop a --resume on any other error). Low
|
|
// effort means one session, step after step; high effort changes nothing.
|
|
|
|
/** Phase 1's rule 1 (the --project branch), as lines. */
|
|
function projectRule() {
|
|
const sec = section(read(EXEC), '## Phase 1 — Parse mode and validate input').split('\n');
|
|
const start = sec.findIndex((l) => l.startsWith('1. If arguments contain `--project <dir>`'));
|
|
assert.ok(start !== -1, 'Phase 1 must still open with the --project rule');
|
|
const end = sec.findIndex((l, i) => i > start && /^2\. /.test(l));
|
|
assert.ok(end !== -1, 'Phase 1 must still have a rule 2');
|
|
return sec.slice(start, end).join('\n');
|
|
}
|
|
|
|
test('[#33] Phase 1 --project runs brief-validator --soft --json and the execute resolver, and halts only on BRIEF_V51_MISSING_SIGNALS', () => {
|
|
const rule = projectRule();
|
|
const validator = line(rule, 'validators/brief-validator.mjs', '--soft --json');
|
|
const resolver = line(rule, 'profiles/resolver.mjs', '--resolve-phase-model --phase execute');
|
|
assert.equal(validator, 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{dir}/brief.md"');
|
|
assert.equal(resolver,
|
|
'node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json');
|
|
const f = flat(rule);
|
|
for (const s of [
|
|
'The validator exits 1 when its `errors` are not empty; read its JSON either way.',
|
|
'Halt only when the validator\'s `errors` hold `BRIEF_V51_MISSING_SIGNALS`, with the message from "Sequencing gate surface" below.',
|
|
'Any other error is printed in one line and the run continues.',
|
|
'A validator or resolver that cannot run (no JSON) is "not measured" in one line, and the run continues.',
|
|
'Capture the resolver\'s JSON as `phase_signal_result` (Composition rule below).',
|
|
'No `{dir}/brief.md`: skip both lines.',
|
|
]) assert.ok(f.includes(s), `the --project rule is missing: ${s}`);
|
|
|
|
// The two lines run as printed: the validator names the one halting code for a
|
|
// 2.1 brief without signals, and the resolver hands Phase 2.5 its low effort.
|
|
const dir = mkdtempSync(join(tmpdir(), 'fix-33-'));
|
|
try {
|
|
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-v21-no-signals.md'), join(dir, 'brief.md'));
|
|
const v = bash(validator.split('{dir}').join(dir), ROOT);
|
|
assert.equal(v.status, 1, `validator: ${v.stdout}${v.stderr}`);
|
|
assert.ok(JSON.parse(v.stdout).errors.some((e) => e.code === 'BRIEF_V51_MISSING_SIGNALS'), v.stdout);
|
|
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-effort-low.md'), join(dir, 'brief.md'));
|
|
const r = bash(resolver.split('{dir}').join(dir).replace(' [--profile {profile}]', ''), ROOT);
|
|
assert.equal(r.status, 0, `resolver: ${r.stdout}${r.stderr}`);
|
|
assert.equal(JSON.parse(r.stdout).effort, 'low');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('[#33] the Composition rule says the resolver runs in Phase 1, and no line says Phase 2.4 runs it', () => {
|
|
assert.doesNotMatch(flat(read(EXEC)), /invoked in Phase 2\.4/);
|
|
assert.ok(flat(section(read(EXEC), '## Composition rule (v5.1)')).includes('invoked in Phase 1 alongside the sequencing-gate brief-validator call'),
|
|
'the Composition rule does not say the resolver is invoked in Phase 1');
|
|
});
|
|
|
|
test('[#33] sibling: Composition rule has no "uniformity and stats" and no gates auto-escalation', () => {
|
|
const f = flat(section(read(EXEC), '## Composition rule (v5.1)'));
|
|
assert.doesNotMatch(f, /uniformity and stats/, 'the Composition rule still says the model is returned for stats');
|
|
assert.ok(f.includes('it is returned for cross-command uniformity; no stats record carries it.'),
|
|
'the Composition rule does not say no stats record carries the model');
|
|
assert.doesNotMatch(f, /gates auto-escalation/);
|
|
});
|
|
|
|
test('[#33] Phase 2.5 names low effort as one session, steps in order, never while a wave is in flight', () => {
|
|
const sec = section(read(EXEC), '## Phase 2.5 — Execution strategy decision').split('\n');
|
|
const start = sec.findIndex((l) => l.startsWith('**Run as single session (foreground)** when ANY of these are true:'));
|
|
assert.ok(start !== -1, 'Phase 2.5 must still list when it runs as a single session');
|
|
const end = sec.findIndex((l, i) => i > start && l.trim() === '');
|
|
const bullets = sec.slice(start + 1, end);
|
|
assert.ok(bullets.includes("- `phase_signal_result.effort == 'low'` → one session, steps in order, unless a wave is in flight: when "
|
|
+ '`.claude/trekplan-sessions/{slug}/orders/wave.json` exists in the main checkout (the first entry of `git worktree list`), '
|
|
+ 'this bullet does not apply and the multi-session path below collects the wave. The check only reads the file and never stops the run.'),
|
|
`the single-session list does not name low effort: ${bullets.join(' | ')}`);
|
|
});
|
|
|
|
test('[#33] no gates_mode = \'closed\', "strictest" or --event gates_mode_flag_override is left, and high effort says it changes nothing', () => {
|
|
const text = read(EXEC);
|
|
const f = flat(text);
|
|
assert.doesNotMatch(f, /gates_mode = 'closed'/);
|
|
assert.doesNotMatch(f, /strictest/);
|
|
assert.doesNotMatch(f, /halts immediately/);
|
|
assert.doesNotMatch(text, /--event gates_mode_flag_override/);
|
|
assert.doesNotMatch(f, /gates_mode = '(adaptive|open)'/);
|
|
assert.equal(flat(section(text, '### High-effort behavior (v5.1.1)')),
|
|
'### High-effort behavior (v5.1.1) High effort changes nothing in `/trekexecute`: the step helper and the controller '
|
|
+ 'already run opus, and every gate already runs. No `gates_mode` is set from the effort.');
|
|
});
|
|
|
|
// --- #28 ---------------------------------------------------------------------------
|
|
|
|
/** The completion gate's text with its blockquote markers removed, flattened. */
|
|
function completionGate() {
|
|
return flat(section(read(EXEC), '### Machine-verifiable completion gate (stop-signal contract)').replace(/^> ?/gm, ''));
|
|
}
|
|
|
|
test('[#28] the completion gate calls nothing deterministic that is executor judgement, and cites no line number', () => {
|
|
const gate = completionGate();
|
|
assert.ok(!gate.includes('Counter liveness (deterministic'), 'clause 3 still calls the counter check deterministic');
|
|
assert.ok(!gate.includes('deterministic audit'), 'the gate still says termination rests on a deterministic audit');
|
|
assert.ok(!gate.includes('`:1221`'), 'the gate still cites `:1221`');
|
|
assert.doesNotMatch(gate, /`:\d+`/, 'the gate cites a line number; line numbers rot');
|
|
assert.ok(gate.includes('Counter liveness (checked by the executor against the audit\'s counts, defeats "never decremented")'),
|
|
'clause 3 does not say the executor checks the counter against the audit\'s counts');
|
|
assert.ok(gate.includes('rests on the audit and an objective stop-signal, never on the model\'s narrative'),
|
|
'the termination sentence does not rest on the audit and an objective stop-signal');
|
|
});
|
|
|
|
test('[#28] the completion gate names its commands and its executor judgement, and each command is in Phase 7.5', () => {
|
|
const gate = completionGate();
|
|
const c = gate.indexOf('Commands:');
|
|
const j = gate.indexOf('Executor judgement:');
|
|
assert.ok(c !== -1, 'the gate does not say which checks are commands');
|
|
assert.ok(j > c, 'the gate does not say which checks are executor judgement, after its commands');
|
|
const commands = gate.slice(c, j);
|
|
const judgement = gate.slice(j);
|
|
// Phase 7.5's own steps only: the gate subsection after them holds the Commands part under test.
|
|
const phase75 = section(read(EXEC), '## Phase 7.5 — Manifest audit (independent)');
|
|
const gateAt = phase75.indexOf('\n### Machine-verifiable completion gate');
|
|
assert.ok(gateAt !== -1, 'Phase 7.5 no longer holds the completion gate subsection');
|
|
const audit = flat(phase75.slice(0, gateAt));
|
|
assert.ok(!audit.includes('Commands:'), 'the audit slice still holds the gate\'s Commands part');
|
|
for (const cmd of ['git rev-list --count', 'bash -n', 'git diff --name-only', 'done-line.mjs --check']) {
|
|
assert.ok(commands.includes(cmd), `the Commands part does not name ${cmd}`);
|
|
assert.ok(audit.includes(cmd), `Phase 7.5 does not run ${cmd}, so the gate cannot call it a command`);
|
|
}
|
|
// `git diff --name-only` in its step-6 form (step 5's line has no `-- {path}`).
|
|
assert.ok(commands.includes("step 6's `git diff --name-only`"), 'the Commands part does not name step 6\'s git diff --name-only');
|
|
assert.ok(audit.includes('`git diff --name-only {session_start_sha} HEAD -- {path}`'),
|
|
'Phase 7.5 step 6 does not run git diff --name-only {session_start_sha} HEAD -- {path}');
|
|
for (const s of ['criteria runner', 'arch-rules.mjs', 'plan-scope-sweep.mjs']) {
|
|
assert.ok(commands.includes(s), `the Commands part does not name ${s}`);
|
|
}
|
|
for (const s of ['steps 1, 2 and 4', 'the expected count in step 3', 'clause 3\'s formula']) {
|
|
assert.ok(judgement.includes(s), `the Executor judgement part does not name ${s}`);
|
|
}
|
|
});
|
|
|
|
test('[#28] sibling: the completion gate says the judgement parts are not yet a script, and qualifies clauses 2 and 4', () => {
|
|
const gate = completionGate();
|
|
assert.ok(gate.includes('The judgement parts are not yet a script.'), 'the gate does not say the judgement parts are not yet a script');
|
|
assert.ok(gate.includes('the criteria runner behind clauses 2 and 4 for a trekplan (a session spec\'s clause 2 is Phase 7\'s '
|
|
+ '`## Exit Condition` checklist, and clause 4 is a trekplan\'s only);'), 'the gate does not qualify the criteria runner behind clauses 2 and 4');
|
|
assert.ok(!gate.includes('part c of the hardening'), 'the gate still points at part c of the hardening');
|
|
});
|
|
|
|
// ---- Architecture rule (plan, Step 12): lib/ never imports from scripts/ ----
|
|
|
|
/**
|
|
* The import specifiers in `text` (a file at `file`) that resolve into the
|
|
* repo's scripts/ directory: static and side-effect imports, re-exports and
|
|
* dynamic imports with a string literal.
|
|
*/
|
|
function importsIntoScripts(file, text) {
|
|
const re = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)|\b(?:import|export)\s+(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
const scripts = join(ROOT, 'scripts') + sep;
|
|
const hits = [];
|
|
for (const m of text.matchAll(re)) {
|
|
const spec = m[1] ?? m[2];
|
|
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|
|
if (resolve(dirname(file), spec).startsWith(scripts)) hits.push(spec);
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
/** Every .mjs file under `dir`, recursively. */
|
|
function mjsFiles(dir) {
|
|
const out = [];
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) out.push(...mjsFiles(p));
|
|
else if (e.name.endsWith('.mjs')) out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
test('arch: lib/ never imports from scripts/, and the scan reports a planted import (control)', () => {
|
|
const planted = importsIntoScripts(join(ROOT, 'lib', 'review', 'x.mjs'), "import x from '../../scripts/release-check.mjs';\n");
|
|
assert.deepEqual(planted, ['../../scripts/release-check.mjs'], 'control: the scan does not report a planted import from scripts/');
|
|
const files = mjsFiles(join(ROOT, 'lib'));
|
|
assert.ok(files.length > 10, `the scan found only ${files.length} files under lib/`);
|
|
const bad = files.flatMap((f) => importsIntoScripts(f, readFileSync(f, 'utf8')).map((s) => `${f.slice(ROOT.length + 1)}: ${s}`));
|
|
assert.deepEqual(bad, [], 'lib/ imports from scripts/');
|
|
});
|
|
|
|
// --- R6 ----------------------------------------------------------------------------
|
|
// The merge lock (Phase 7.2 step 4 and the Phase 8 Main-merge gate) runs the
|
|
// verdict gate with `--repo .`, so a review of an older commit than the one
|
|
// being merged is refused (`review-stale`), and only the closing run's own
|
|
// review opens the merge.
|
|
|
|
const MERGE_GATE = '### Main-merge gate (MAIN_MERGE_GATE — always pauses)';
|
|
const PHASE_72 = '## Phase 7.2 — Simplify, review, verdict (P11)';
|
|
const LOCK_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{review_path}" --repo .';
|
|
const STEP4_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .';
|
|
|
|
/** A review_version 1.1 review.md of `sha`: ALLOW with no findings, or BLOCK over one BLOCKER. */
|
|
function r6Review(verdict, sha) {
|
|
const id = 'd'.repeat(40);
|
|
const findings = verdict === 'BLOCK'
|
|
? [{ id, severity: 'BLOCKER', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.' }]
|
|
: [];
|
|
const counts = { BLOCKER: findings.length, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
|
|
const block = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings }, null, 2);
|
|
return [
|
|
'---', 'type: trekreview', 'review_version: "1.1"', 'task: "t"', 'slug: s',
|
|
'project_dir: .claude/projects/2026-10-23-s/', 'brief_path: .claude/projects/2026-10-23-s/brief.md',
|
|
`scope_sha_end: ${sha}`, 'reviewed_files_count: 1', `verdict: ${verdict}`,
|
|
findings.length ? `findings:\n - ${id}` : 'findings: []', '---', '', '# Review: t', '',
|
|
'## Executive Summary', '', 'x', '', '## Coverage', '', '| File | Treatment | Reason |', '|---|---|---|',
|
|
'| a.mjs | summary-only | default |', '', '## Remediation Summary', '', 'x', '', '```json', block, '```', '',
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* A throwaway repo (no git hooks): the reviewed commit, then a later commit
|
|
* that changes source. `review(verdict, sha)` writes review.md outside the
|
|
* repo and returns its folder.
|
|
*/
|
|
function staleRepo() {
|
|
const base = mkdtempSync(join(tmpdir(), 'fix-round-r6-'));
|
|
const repo = join(base, 'repo');
|
|
const out = join(base, 'project');
|
|
mkdirSync(repo);
|
|
mkdirSync(out);
|
|
mkdirSync(join(base, 'hooks-none'));
|
|
const g = (...args) => {
|
|
const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', env: childEnv() });
|
|
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
|
return r.stdout.trim();
|
|
};
|
|
g('init', '-q', '-b', 'main');
|
|
g('config', 'user.name', 'Test');
|
|
g('config', 'user.email', 'test@example.invalid');
|
|
g('config', 'commit.gpgsign', 'false');
|
|
g('config', 'core.hooksPath', join(base, 'hooks-none'));
|
|
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
|
|
g('add', 'a.mjs');
|
|
g('commit', '-q', '-m', 'feat: the reviewed work');
|
|
const reviewed = g('rev-parse', 'HEAD');
|
|
writeFileSync(join(repo, 'a.mjs'), 'export const a = 2;\n');
|
|
g('add', 'a.mjs');
|
|
g('commit', '-q', '-m', 'feat: a later source change');
|
|
const head = g('rev-parse', 'HEAD');
|
|
const review = (verdict, sha) => {
|
|
writeFileSync(join(out, 'review.md'), r6Review(verdict, sha));
|
|
return out;
|
|
};
|
|
return { repo, reviewed, head, review, cleanup: () => rmSync(base, { recursive: true, force: true }) };
|
|
}
|
|
|
|
let sharedStale = null;
|
|
after(() => sharedStale?.cleanup());
|
|
|
|
/** The file's one staleRepo, built on first use: its tests only rewrite review.md, outside the repo. */
|
|
function staleFixture() {
|
|
if (!sharedStale) sharedStale = staleRepo();
|
|
return sharedStale;
|
|
}
|
|
|
|
/** The lock line with its review path filled in, run in `repo` through bash(). */
|
|
function runLock(cmd, dir, repo) {
|
|
const filled = cmd.split('{review_path}').join(join(dir, 'review.md')).split('{project_dir}').join(dir);
|
|
return bash(filled, repo);
|
|
}
|
|
|
|
test('[R6] the Main-merge gate and Phase 7.2 step 4 each hold exactly one verdict-gate.mjs line, with --repo .', () => {
|
|
const text = read(EXEC);
|
|
assert.equal(line(section(text, MERGE_GATE), 'review/verdict-gate.mjs'), LOCK_LINE,
|
|
'the Phase 8 lock does not run the verdict gate against the repo being merged');
|
|
assert.equal(line(section(text, PHASE_72), 'review/verdict-gate.mjs'), STEP4_LINE,
|
|
'Phase 7.2 step 4 does not run the verdict gate against the repo being merged');
|
|
});
|
|
|
|
test('[R6] the extracted lock lines, run on an ALLOW review of an older commit with a later source commit, exit 2 with review-stale', () => {
|
|
const text = read(EXEC);
|
|
const lines = [line(section(text, MERGE_GATE), 'review/verdict-gate.mjs'), line(section(text, PHASE_72), 'review/verdict-gate.mjs')];
|
|
const fx = staleFixture();
|
|
for (const cmd of lines) {
|
|
const ok = runLock(cmd, fx.review('ALLOW', fx.head), fx.repo);
|
|
assert.equal(ok.status, 0, `control: an ALLOW review of HEAD opens the lock: ${ok.stdout}${ok.stderr}`);
|
|
const r = runLock(cmd, fx.review('ALLOW', fx.reviewed), fx.repo);
|
|
assert.equal(r.status, 2, `${cmd}: an ALLOW review of an older commit must exit 2: ${r.stdout}${r.stderr}`);
|
|
const reason = JSON.parse(r.stdout).reason;
|
|
assert.ok(reason.startsWith('review-stale'), `the reason must start review-stale: ${reason}`);
|
|
}
|
|
});
|
|
|
|
test('[R6] both decline lists name review-stale, the exit table says to re-run /trekreview on this commit, and --resume runs the same gate', () => {
|
|
const gate = flat(section(read(EXEC), MERGE_GATE));
|
|
const closed = gate.match(/\*\*Closed:\*\* emit `main-merge-declined` with the reason ([^.]*)\./);
|
|
assert.ok(closed, 'the gate no longer says which reasons a closed lock emits');
|
|
assert.ok(closed[1].includes('`review-stale`'), `the closed-lock reasons do not name review-stale: ${closed[1]}`);
|
|
const literal = gate.match(/The reason is one of the literal words above \(([^)]*)\)/);
|
|
assert.ok(literal, 'the gate no longer lists the literal decline reasons');
|
|
assert.ok(literal[1].includes('`review-stale`'), `the literal decline reasons do not name review-stale: ${literal[1]}`);
|
|
assert.match(gate, /a reason that starts `review-stale`[^.]*decline reason `review-stale`[^.]*re-run \/trekreview on this commit/,
|
|
'the exit table does not map a review-stale reason to the decline reason and its "re-run /trekreview on this commit" line');
|
|
assert.match(gate, /`--resume re-enters at the gate`[^.]*runs the same gate[^.]*review of an older commit stays refused/,
|
|
'the --resume paragraph does not say the re-read runs the same gate');
|
|
});
|
|
|
|
test('keep: Phase 7.2 step 4 run on a BLOCK review of an older commit still exits 1, so the fix loop opens', () => {
|
|
const step4 = line(section(read(EXEC), PHASE_72), 'review/verdict-gate.mjs');
|
|
const fx = staleFixture();
|
|
const r = runLock(step4, fx.review('BLOCK', fx.reviewed), fx.repo);
|
|
assert.equal(r.status, 1, `a BLOCK review must exit 1 before any staleness check: ${r.stdout}${r.stderr}`);
|
|
assert.equal(JSON.parse(r.stdout).verdict, 'BLOCK');
|
|
});
|
|
|
|
test('keep: the other verdict-gate callers carry no --repo', () => {
|
|
for (const rel of ['commands/trekreview.md', 'agents/review-orchestrator.md', 'README.md', 'docs/release-runbook.md', 'tests/e2e-files-trial4.sh']) {
|
|
const hits = read(rel).split('\n').filter((l) => /^\s*(?:out="\$\()?node \S*lib\/review\/verdict-gate\.mjs\b/.test(l));
|
|
assert.ok(hits.length > 0, `${rel} no longer runs verdict-gate.mjs; the check would be empty`);
|
|
for (const h of hits) assert.ok(!h.includes('--repo'), `${rel} gained --repo: ${h.trim()}`);
|
|
}
|
|
});
|
|
|
|
// --- D67: the final report says whether the app was seen with `run` ---------
|
|
// Phase 7's "run the thing" says to use Claude Code's built-in `run`, but the
|
|
// final report never said whether that happened. The line is said for every
|
|
// run, also one with no app to launch (the "no — {reason}" form).
|
|
|
|
const E2E = '### End to end — "run the thing" (P9 (6))';
|
|
|
|
/** Phase 8 of /trekexecute, read up to Phase 9 (its report fence holds its own `## ` heading). */
|
|
function phase8(text) {
|
|
const start = text.indexOf('## Phase 8 — Final report');
|
|
const end = text.indexOf('## Phase 9');
|
|
assert.ok(start !== -1 && end > start, 'Phase 8 — Final report and Phase 9 must both exist, in that order');
|
|
return text.slice(start, end);
|
|
}
|
|
|
|
test('[D67] the Phase 8 report template says, right after End to end, whether the app was seen with `run`', () => {
|
|
const lines = phase8(read(EXEC)).split('\n');
|
|
const e2e = lines.findIndex((l) => l.startsWith('**End to end:**'));
|
|
assert.ok(e2e !== -1, 'the report template has no **End to end:** line');
|
|
assert.equal(lines[e2e + 1], '**Seen with `run`:** {yes | no — {reason}}',
|
|
'the line after **End to end:** is not the "Seen with `run`" line in its yes / no — {reason} form');
|
|
});
|
|
|
|
test('[D67] the summary JSON carries "run_seen" right after "end_to_end"', () => {
|
|
const text = read(EXEC);
|
|
const sumIdx = text.indexOf('"trekexecute_summary": {');
|
|
assert.ok(sumIdx !== -1, 'summary JSON block missing');
|
|
const lines = text.slice(sumIdx, text.indexOf('\n```', sumIdx)).split('\n').map((l) => l.trim());
|
|
const e2e = lines.findIndex((l) => l.startsWith('"end_to_end":'));
|
|
assert.ok(e2e !== -1, 'the summary JSON has no "end_to_end" key');
|
|
assert.equal(lines[e2e + 1], '"run_seen": "{yes | no — reason}",',
|
|
'the key after "end_to_end" is not "run_seen" with its yes / no — reason value');
|
|
});
|
|
|
|
test('[D67] the End to end section says to record run_seen, also when no app was seen, and keeps the End-to-end: command as the verdict', () => {
|
|
const s = flat(section(read(EXEC), E2E));
|
|
assert.match(s, /Record `run_seen`: `yes` when the app was seen and driven with Claude Code's built-in `run` in this run, else `no — \{reason\}`/,
|
|
'the End to end section does not say to record run_seen as yes, or no with a reason');
|
|
assert.match(s, /no app to launch/, 'the no-form does not name the run with no app to launch');
|
|
assert.match(s, /The verdict is still the `End-to-end:` command\./,
|
|
'the End to end section no longer says the End-to-end: command is the verdict');
|
|
});
|
|
|
|
// --- #15 / #67 -----------------------------------------------------------------------
|
|
// #15: /trekendsession's Phase 0 printed its usage when $ARGUMENTS merely
|
|
// contained "--help" or "-h", so a slug such as "fix-header" asked for help.
|
|
// It now runs the arg parser and dispatches only on its exact result.
|
|
// #67: "NEVER auto-invoke this command" was a sentence only; the frontmatter
|
|
// now carries disable-model-invocation, so the model cannot start it.
|
|
|
|
const ENDSESSION = 'commands/trekendsession.md';
|
|
const ENDSESSION_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
|
|
|
|
test('[#15] /trekendsession Phase 0 has no "contains --help or -h" check, and its parser line run on fix-header gives no help', () => {
|
|
const phase0 = section(read(ENDSESSION), '## Phase 0 — `--help` handling');
|
|
assert.doesNotMatch(flat(phase0), /contains\s+`?--help`?\s+or\s+`?-h`?/i,
|
|
'Phase 0 still prints help when $ARGUMENTS merely contains --help or -h');
|
|
assert.match(flat(phase0), /`flags\['--help'\] === true`/, 'Phase 0 does not dispatch on the parsed --help flag');
|
|
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
|
|
const cmd = line(phase0, 'parsers/arg-parser.mjs');
|
|
assert.equal(cmd, ENDSESSION_PARSER_LINE, 'the Phase 0 parser line is not the trekendsession parser call on $ARGUMENTS');
|
|
const block = bashBlockHolding(phase0, cmd);
|
|
assert.ok(block, 'the parser line is not in a runtime ```bash block');
|
|
assert.equal(block.split('\n')[0], 'setopt nonomatch 2>/dev/null || true', 'the block does not open with setopt nonomatch');
|
|
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
|
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
|
|
const plain = runParser(block, 'fix-header');
|
|
assert.equal(plain.status, 0, `the parser line refused fix-header: ${plain.stdout}${plain.stderr}`);
|
|
const r = JSON.parse(plain.stdout);
|
|
assert.equal(r.flags['--help'], undefined, 'fix-header set the --help flag');
|
|
assert.deepEqual(r.positional, ['fix-header']);
|
|
assert.notEqual(r.positional[0], '-h');
|
|
const help = runParser(block, '--help');
|
|
assert.equal(help.status, 0, help.stdout + help.stderr);
|
|
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'control: --help must set the help flag');
|
|
const two = runParser(block, 'fix-header "Session 2"');
|
|
assert.equal(two.status, 0, two.stdout + two.stderr);
|
|
assert.deepEqual(JSON.parse(two.stdout).positional, ['fix-header', 'Session 2']);
|
|
});
|
|
|
|
test('[#15] /trekendsession Phase 2 reads the brief path from the parser\'s positional and the label from its tail', () => {
|
|
const phase2 = flat(section(read(ENDSESSION), '## Phase 2 — Required args check (headless-safe)'));
|
|
assert.match(phase2, /`positional\[0\]`/, 'Phase 2 does not read <next-brief-path> from positional[0]');
|
|
assert.match(phase2, /`<next-label>` is the parser's `tail`/, 'Phase 2 does not read <next-label> from the parser\'s tail');
|
|
});
|
|
|
|
test('[#67] /trekendsession sets disable-model-invocation, and CLAUDE.md names it as the one command that does', () => {
|
|
const text = read(ENDSESSION);
|
|
const fm = readFrontmatterFields(text);
|
|
assert.ok(fm.ok, fm.error);
|
|
assert.equal(fm.fields['disable-model-invocation']?.value, 'true',
|
|
'commands/trekendsession.md does not set disable-model-invocation: true');
|
|
assert.match(flat(text),
|
|
/\*\*NEVER auto-invoke this command\.\*\* It is operator-typed only at session-end \(the frontmatter field keeps the model from starting it; the operator types it\)/,
|
|
'the hard rule does not say the frontmatter field keeps the model from starting it');
|
|
const withField = readdirSync(join(ROOT, 'commands')).filter((f) => f.endsWith('.md'))
|
|
.filter((f) => readFrontmatterFields(read(`commands/${f}`)).fields['disable-model-invocation'] !== undefined);
|
|
assert.deepEqual(withField, ['trekendsession.md'], 'trekendsession must be the one command with the field');
|
|
const trunk = read('CLAUDE.md').split('\n').find((l) => l.startsWith('> **Commands are the trunk**'));
|
|
assert.ok(trunk, 'CLAUDE.md has no "Commands are the trunk" note');
|
|
assert.doesNotMatch(trunk, /none sets `disable-model-invocation`/, 'CLAUDE.md still says no command or skill sets the field');
|
|
assert.ok(trunk.includes('no skill sets `disable-model-invocation`; the one command that does is `/trekendsession`, operator-typed only (Claude Code still lets the operator type it; the model cannot start it)'),
|
|
'CLAUDE.md does not name /trekendsession as the one command that sets the field');
|
|
});
|
|
|
|
test('keep: commands/trekreview.md carries no disable-model-invocation field (/trekexecute starts it through the Skill tool)', () => {
|
|
const fm = readFrontmatterFields(read('commands/trekreview.md'));
|
|
assert.ok(fm.ok, fm.error);
|
|
assert.equal(fm.fields['disable-model-invocation'], undefined);
|
|
});
|
|
|
|
// --- #66 / #102 ----------------------------------------------------------------------
|
|
// #66: /trekcontinue's Phase 0 told the session to call parseArgs($ARGUMENTS, ...)
|
|
// as a function, which no Bash line runs; it now runs the arg-parser CLI and
|
|
// reads its JSON. agents/review-orchestrator.md ran the parser without `--`, so
|
|
// the CLI parsed nothing.
|
|
// #102: Phase 1.5 looked for the master file at "<plugin-root>", where nothing
|
|
// writes it; the master file is at the repo root. The consistency call now
|
|
// passes the state file, so the state-anchored staleness check runs.
|
|
|
|
const CONTINUE = 'commands/trekcontinue.md';
|
|
const CONTINUE_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
|
|
const CONSISTENCY_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B> --state-file <RESOLVED-PATH-STATE>';
|
|
|
|
test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its parser line run with --help sets the help flag', () => {
|
|
const phase0 = section(read(CONTINUE), '## Phase 0 — `--help` handling');
|
|
assert.doesNotMatch(phase0, /parseArgs\(\$ARGUMENTS/, 'Phase 0 still calls parseArgs($ARGUMENTS, ...) as a function');
|
|
const cmd = line(phase0, 'parsers/arg-parser.mjs');
|
|
assert.equal(cmd, CONTINUE_PARSER_LINE, 'the Phase 0 parser line is not the trekcontinue parser call on $ARGUMENTS');
|
|
const block = bashBlockHolding(phase0, cmd);
|
|
assert.ok(block, 'the parser line is not in a runtime ```bash block');
|
|
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
|
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
|
|
assert.match(flat(phase0), /Run first, exactly like this:/);
|
|
assert.match(flat(phase0), /Read `flags` and `positional` from the JSON it prints/);
|
|
assert.match(flat(phase0), /`flags\['--help'\] === true`/, 'Phase 0 does not dispatch on the parsed --help flag');
|
|
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
|
|
const help = runParser(block, '--help');
|
|
assert.equal(help.status, 0, help.stdout + help.stderr);
|
|
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'the parser line run with --help does not set the help flag');
|
|
const dir = runParser(block, '.claude/projects/demo');
|
|
assert.equal(dir.status, 0, dir.stdout + dir.stderr);
|
|
const r = JSON.parse(dir.stdout);
|
|
assert.equal(r.flags['--help'], undefined, 'a project dir set the --help flag');
|
|
assert.deepEqual(r.positional, ['.claude/projects/demo']);
|
|
const h = runParser(block, '-h');
|
|
assert.equal(h.status, 0, h.stdout + h.stderr);
|
|
assert.equal(JSON.parse(h.stdout).positional[0], '-h');
|
|
const p05 = flat(section(read(CONTINUE), '## Phase 0.5 — Cleanup mode dispatch'));
|
|
assert.doesNotMatch(p05, /After `parseArgs` has resolved/);
|
|
assert.match(p05, /After the parser has printed/);
|
|
});
|
|
|
|
test('[#66] sibling: agents/review-orchestrator.md passes the arguments to the parser on stdin through the quoted heredoc', () => {
|
|
const p1 = section(read('agents/review-orchestrator.md'), '### Phase 1 — Parse mode and validate input');
|
|
const cmd = line(p1, 'parsers/arg-parser.mjs');
|
|
assert.equal(cmd, "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'");
|
|
const lines = p1.split('\n');
|
|
const at = lines.findIndex((l) => l.trim() === cmd);
|
|
assert.deepEqual(lines.slice(at + 1, at + 3), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
|
'the parser line is not followed by $ARGUMENTS and the closing line');
|
|
const r = runParser(lines.slice(at, at + 3).join('\n'), '--project .claude/projects/demo');
|
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
assert.equal(JSON.parse(r.stdout).flags['--project'], '.claude/projects/demo', 'the parser did not see --project');
|
|
});
|
|
|
|
test('[#102] /trekcontinue Phase 1.5 finds the master file at the repo root and passes the state file to the consistency call', () => {
|
|
assert.doesNotMatch(read(CONTINUE), /<plugin-root>/, 'commands/trekcontinue.md still names <plugin-root>');
|
|
const p15 = section(read(CONTINUE), '## Phase 1.5 — Frontmatter consistency check');
|
|
assert.ok(flat(p15).includes('a. `NEXT-SESSION-PROMPT.local.md` at the repo root (`git rev-parse --show-toplevel`) — operator-managed master file, written by the operator or an external producer, never by Voyage'),
|
|
'candidate a is not the repo-root master file');
|
|
const cmd = line(p15, 'validators/next-session-prompt-validator.mjs');
|
|
assert.equal(cmd, CONSISTENCY_LINE, 'the consistency call does not pass --state-file <RESOLVED-PATH-STATE>');
|
|
assert.match(flat(p15), /When the project's `\.session-state\.local\.json` exists, pass it with `--state-file` \(else run the same line without the option\)/);
|
|
assert.match(flat(p15), /`<RESOLVED-PATH-STATE>` with the concrete path of the project's `\.session-state\.local\.json`/);
|
|
// The line, with the tokens filled in, runs the state-anchored check.
|
|
const dir = mkdtempSync(join(tmpdir(), 'v-102-'));
|
|
try {
|
|
const a = join(dir, 'a.md');
|
|
const b = join(dir, 'b.md');
|
|
const s = join(dir, '.session-state.local.json');
|
|
writeFileSync(a, '---\nproduced_by: graceful-handoff\nproduced_at: 2026-05-03T10:00:00.000Z\n---\n\nbody\n');
|
|
writeFileSync(b, '---\nproduced_by: trekexecute\nproduced_at: 2026-05-04T16:05:00.000Z\n---\n\nbody\n');
|
|
writeFileSync(s, JSON.stringify({ schema_version: 1, updated_at: '2026-05-04T16:00:00.000Z' }));
|
|
const filled = cmd.replace('<RESOLVED-PATH-A>', a).replace('<RESOLVED-PATH-B>', b).replace('<RESOLVED-PATH-STATE>', s);
|
|
const r = bash(filled, ROOT);
|
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
assert.ok(JSON.parse(r.stdout).warnings.some((w) => w.code === 'NEXT_SESSION_PROMPT_STALE_IGNORED'));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('[#102] sibling: commands/trekexecute.md names project-dir and repo-root candidates, never plugin-root candidates', () => {
|
|
const text = flat(read(EXEC));
|
|
assert.doesNotMatch(text, /plugin-root candidates/);
|
|
assert.equal((text.match(/project-dir and repo-root candidates/g) || []).length, 2);
|
|
});
|
|
|
|
// --- #113: the contrarian gets a conclusion to attack -----------------------
|
|
// contrarian-researcher was launched in the same parallel message as the agents
|
|
// whose conclusion it is meant to attack, and standard effort pointed at a
|
|
// "conditional trigger" that nothing defined. It now runs as wave 2, after wave
|
|
// 1 returns, given wave 1's leading answer from the external findings only,
|
|
// with the local parts stripped (the de-localising rule of External agents), so
|
|
// no local file name or project term reaches its web queries.
|
|
|
|
const RESEARCH = 'commands/trekresearch.md';
|
|
const RESEARCH_ORCH = 'agents/research-orchestrator.md';
|
|
|
|
test('[#113] Phase 4 launches contrarian-researcher as wave 2 after wave 1, with the leading answer and claims from external findings only, and names the deep-research case', () => {
|
|
const text = read(RESEARCH);
|
|
const ext = partAt(RESEARCH, '### External agents (scope = both or external)', (l) => /^## /.test(l));
|
|
assert.doesNotMatch(ext, /Launch ALL selected agents \*\*in parallel\*\*/, 'Launch rules still start every selected agent, the contrarian too, in one parallel message');
|
|
assert.match(ext, /Launch in two waves, both in Phase 4 \(before Phase 4\.5 and Phase 5/);
|
|
assert.match(ext, /\*\*Wave 1:\*\* ALL selected agents except `contrarian-researcher` \*\*in parallel\*\* in a single message/);
|
|
assert.match(ext, /\*\*Wave 2:\*\* `contrarian-researcher`, when selected, after wave 1 returns/);
|
|
assert.match(ext, /the leading answer \(one or two sentences\), the claims for it and the alternatives wave 1 dismissed/);
|
|
assert.match(ext, /taken from wave 1's external findings only, never from the local agents' findings/, 'wave 2 does not say external findings only');
|
|
assert.match(ext, /stripped of local parts like the research question above \(absolute paths, user names, internal project or repository names, ticket IDs\)/, 'wave 2 does not apply the stripping rule');
|
|
assert.match(ext, /Pass the research question to them with the local parts \(absolute paths, user names, internal project or repository names, ticket IDs\) already removed/);
|
|
assert.match(ext, /With `--engine deep-research` \(`effective_engine = deep-research`\), wave 2 runs after the engine returns, from the leading answer of its report/);
|
|
const p4 = partAt(RESEARCH, '## Phase 4 — Parallel research (agent swarm)', (l) => /^## /.test(l));
|
|
assert.match(p4, /then run wave 2 \(`contrarian-researcher`, see Launch rules\) from the report's leading answer/, 'the deep-research step 5 does not hand over to wave 2');
|
|
const w2 = text.indexOf('**Wave 2:**');
|
|
assert.ok(w2 > text.indexOf('## Phase 4 —') && w2 < text.indexOf('## Phase 4.5 —'), 'wave 2 is not in Phase 4, before Phase 4.5');
|
|
});
|
|
|
|
test('[#113] no conditional trigger is left, and high effort keeps contrarian-researcher unconditional, as wave 2', () => {
|
|
const text = flat(read(RESEARCH));
|
|
assert.doesNotMatch(text, /triggers conditionally when a leading recommendation is emerging/);
|
|
assert.doesNotMatch(text, /use the existing conditional triggers/);
|
|
const high = flat(section(read(RESEARCH), '### High-effort behavior (v5.1.1)'));
|
|
assert.ok(high.includes('force `contrarian-researcher` to always-on: in high-effort mode `contrarian-researcher` runs unconditionally, as wave 2 (see Launch rules), so the final brief always carries an adversarial counter-evidence pass'),
|
|
'high effort does not keep the contrarian unconditional as wave 2');
|
|
});
|
|
|
|
test('[#113] sibling: README.md', () => {
|
|
const text = flat(read('README.md'));
|
|
assert.doesNotMatch(text, /contrarian-researcher when a leading recommendation forms/);
|
|
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1\'s external findings to attack (with `--engine deep-research`, when the engine is active, after the engine returns, from the leading answer of its report), always at `effort=high`;'),
|
|
'README.md does not describe wave 2');
|
|
});
|
|
|
|
test('[#113] sibling: agents/research-orchestrator.md', () => {
|
|
const p2 = flat(section(read(RESEARCH_ORCH), '### Phase 2 — Parallel research'));
|
|
assert.doesNotMatch(p2, /Launch ALL selected agents \*\*in parallel\*\*/);
|
|
assert.match(p2, /\*\*Wave 1:\*\* ALL selected agents except `contrarian-researcher` \*\*in parallel\*\* using the Agent tool — one message, multiple tool calls/);
|
|
assert.match(p2, /\*\*Wave 2:\*\* `contrarian-researcher`, after wave 1 returns/);
|
|
assert.match(p2, /the leading answer \(one or two sentences\), the claims for it and the alternatives wave 1 dismissed/);
|
|
assert.match(p2, /taken from wave 1's external findings only, never from the local agents' findings/);
|
|
assert.match(p2, /with the local parts \(absolute paths, user names, internal project or repository names, ticket IDs\) removed/);
|
|
assert.ok(flat(read(RESEARCH_ORCH)).includes('| `contrarian-researcher` | Counter-evidence, overlooked alternatives, reasons to reconsider; runs in wave 2, against wave 1\'s leading answer |'),
|
|
'the agent table does not say the contrarian runs in wave 2');
|
|
});
|
|
|
|
// --- F9: changed code and instruction files get deep review by default ------
|
|
// The triage table made `summary-only` the default treatment for everything
|
|
// else, so changed code outside auth/crypto/security/hooks got a structural
|
|
// pass only. `deep-review` is now the default for every changed file not in
|
|
// the other two rows, the instruction files CLAUDE.md, AGENTS.md and
|
|
// .claude/rules/** among them; `summary-only` is for docs/**, root Markdown
|
|
// other than CLAUDE.md and AGENTS.md, and tests/fixtures/**. The four copies
|
|
// (command, orchestrator, README, workflow fallback, template rows) say the
|
|
// same; each is pinned by its own test against the phrase set below.
|
|
|
|
const REVIEW = 'commands/trekreview.md';
|
|
const F9_DEEP = 'for every changed file not in the other two rows — code, tests, `commands/`, `agents/`, `skills/` Markdown, and the instruction files `CLAUDE.md`, `AGENTS.md` and `.claude/rules/**`';
|
|
const F9_SUMMARY = '`docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`';
|
|
const F9_OLD = /Default treatment for everything else/;
|
|
|
|
/** The one table row in `text` whose first cell is `cell`. */
|
|
function f9Row(text, cell) {
|
|
const hits = text.split('\n').filter((l) => l.startsWith(`| ${cell} |`));
|
|
assert.equal(hits.length, 1, `expected exactly one "${cell}" row; found ${hits.length}`);
|
|
return hits[0];
|
|
}
|
|
|
|
/** The deep-review and summary-only rows hold the phrase set; the old default is gone. */
|
|
function f9Table(text, where) {
|
|
assert.doesNotMatch(text, F9_OLD, `${where}: summary-only is still the default treatment for everything else`);
|
|
const deep = f9Row(text, '`deep-review`');
|
|
assert.match(deep, /\| Default for every changed file/, `${where}: the deep-review row does not name the default`);
|
|
assert.ok(deep.includes(F9_DEEP), `${where}: the deep-review row does not name code, tests and CLAUDE.md, AGENTS.md, .claude/rules/**`);
|
|
const summary = f9Row(text, '`summary-only`');
|
|
assert.ok(summary.includes(F9_SUMMARY), `${where}: the summary-only row is not docs/**, root Markdown, tests/fixtures/**`);
|
|
assert.doesNotMatch(summary, /[Dd]efault/, `${where}: the summary-only row still claims the default`);
|
|
assert.match(f9Row(text, '`skip`'), /Matches `\*\.lock`, `\*\.svg`, `dist\/\*\*`, `build\/\*\*`, `node_modules\/\*\*`/, `${where}: the skip row changed`);
|
|
}
|
|
|
|
test('[F9] the trekreview triage table makes deep-review the default, naming CLAUDE.md, AGENTS.md and .claude/rules/**; summary-only is for docs; the size gate is unchanged', () => {
|
|
const p4 = section(read(REVIEW), '## Phase 4 — Triage gate (deterministic path-pattern classifier)');
|
|
f9Table(p4, REVIEW);
|
|
assert.ok(p4.includes('if (reviewed_files_count > 100) → ask user'), 'the file-count gate changed');
|
|
assert.ok(p4.includes('if (estimated_diff_tokens > 100000) → ask user'), 'the diff-token gate changed');
|
|
});
|
|
|
|
test('[F9] sibling: agents/review-orchestrator.md', () => {
|
|
f9Table(section(read('agents/review-orchestrator.md'), '### Phase 4 — Triage gate (path-pattern classifier)'), 'agents/review-orchestrator.md');
|
|
});
|
|
|
|
test('[F9] sibling: README.md', () => {
|
|
const tri = flat(section(read('README.md'), '### Triage gate (deterministic)'));
|
|
assert.doesNotMatch(tri, /Deep-review patterns: `auth\/\*\*`, `crypto\/\*\*`, `\*\*\/security\/\*\*`\./, 'README.md still limits deep-review to auth/crypto/security');
|
|
assert.ok(tri.includes(`\`deep-review\`: default ${F9_DEEP}.`), 'README.md does not make deep-review the default with the instruction files');
|
|
assert.ok(tri.includes(`\`summary-only\`: ${F9_SUMMARY}.`), 'README.md does not limit summary-only to docs/**, root Markdown, tests/fixtures/**');
|
|
assert.ok(tri.includes('Hard refuse-with-suggestion above 100 files / 100K diff tokens.'), 'README.md size gate changed');
|
|
});
|
|
|
|
test('[F9] sibling: scripts/trekreview-armB.workflow.mjs', () => {
|
|
const text = read('scripts/trekreview-armB.workflow.mjs');
|
|
assert.doesNotMatch(text, /treat all changed files as summary-only/, 'the workflow fallback still treats every file as summary-only');
|
|
assert.ok(text.includes("a.triage || '(no triage map supplied — deep-review every changed file except docs/**, root Markdown other than CLAUDE.md and AGENTS.md, and tests/fixtures/**)',"),
|
|
'the workflow fallback does not deep-review every changed file except docs/**, root Markdown other than CLAUDE.md and AGENTS.md, and tests/fixtures/**');
|
|
});
|
|
|
|
test('[F9] sibling: templates/trekreview-template.md', () => {
|
|
const cov = section(read('templates/trekreview-template.md'), '## Coverage');
|
|
assert.doesNotMatch(cov, /matched deep-review pattern|no test patterns matched/, 'the template rows still show the old pattern reasons');
|
|
assert.ok(cov.includes('| lib/foo.mjs | deep-review | default for changed code |'), 'the template has no deep-review default row');
|
|
assert.ok(cov.includes('| docs/guide.md | summary-only | docs/** is summary-only |'), 'the template has no docs summary-only row');
|
|
});
|
|
|
|
// --- CL: a line number alone never refutes a finding ------------------------
|
|
// Pass 2's Accuracy row dropped a finding as refuted (`accuracy:refuted`) for an
|
|
// "impossibly large line number", so a finding whose claim was never checked
|
|
// weighed nothing and could not hold ALLOW back. Only a citation that escapes
|
|
// the repo root or a negative line refutes; a line past the end of the file
|
|
// makes the finding `unverified` (`accuracy:line-out-of-range`): the citation
|
|
// is wrong, the claim is unchecked. The lib side is pinned in
|
|
// tests/lib/coordinator-contract.test.mjs.
|
|
//
|
|
// --- DJ: the Declined to Judge copy-through is held -------------------------
|
|
// A reviewer's own `### Declined to Judge` lines reach review.md only through
|
|
// three sentences: /trekreview Phase 6 passes them to the coordinator, the
|
|
// coordinator's Input names them, and body section 9 copies them. Each is
|
|
// pinned by its own flattened regex. These pass at ab6b32c by design (no text
|
|
// changed); the round's red script proves them red by deleting the sentences.
|
|
|
|
const COORD = 'agents/review-coordinator.md';
|
|
|
|
/** The one Pass 2 table row whose first cell is `Accuracy`. */
|
|
function accuracyRow() {
|
|
const p2 = section(read(COORD), '### Pass 2 — HubSpot Judge filters (3 criteria)');
|
|
const hits = p2.split('\n').filter((l) => /^\| Accuracy \|/.test(l));
|
|
assert.equal(hits.length, 1, `expected exactly one Accuracy row in Pass 2; found ${hits.length}`);
|
|
return hits[0];
|
|
}
|
|
|
|
test('[CL] the Accuracy row refutes only a root escape or a negative line; a line past the end is unverified (accuracy:line-out-of-range)', () => {
|
|
const row = accuracyRow();
|
|
assert.match(row, /dropped|accuracy:refuted/, 'control: the Accuracy row still drops something as refuted');
|
|
for (const phrase of [/impossibly large line number/, /≤ file line count/]) {
|
|
assert.doesNotMatch(row, phrase, `the Accuracy row still pairs ${phrase} with dropped / accuracy:refuted`);
|
|
}
|
|
assert.match(row, /Path traversal escape or negative line \| \*\*dropped\*\* \(`accuracy:refuted`\)/,
|
|
'the Accuracy row does not drop exactly a root escape or a negative line as refuted');
|
|
assert.match(row, /A line past the end of the file[^|]*the citation is wrong, the claim is unchecked[^|]*`unverified` \(`accuracy:line-out-of-range`\)/,
|
|
'the Accuracy row does not send a line past the end of the file to unverified (accuracy:line-out-of-range)');
|
|
const why = flat(section(read(COORD), '### Pass 2 — HubSpot Judge filters (3 criteria)'));
|
|
assert.match(why, /Accuracy does: a citation that escapes the repo root, or a negative line, refutes the finding as a claim about this codebase\. A line past the end of the file does not: the citation is wrong, the claim is unchecked\./,
|
|
'the paragraph after the table still lets a line number alone refute');
|
|
});
|
|
|
|
test('[DJ] /trekreview Phase 6 passes each reviewer\'s Declined to Judge lines verbatim for the coordinator to copy', () => {
|
|
const p6 = flat(section(read(REVIEW), '## Phase 6 — Coordinator dedup + verdict'));
|
|
assert.match(p6, /pass each reviewer's `### Declined to Judge` lines verbatim from its saved `\{project_dir\}\/\.review-out-\{reviewer\}\.md`: the coordinator copies them into `## Declined to Judge`, so nothing a reviewer set aside disappears\./,
|
|
'Phase 6 no longer hands the reviewers\' Declined to Judge lines to the coordinator');
|
|
});
|
|
|
|
test('[DJ] review-coordinator Input: the reviewers\' Declined to Judge lines are copied into body section 9, not ruled on', () => {
|
|
const input = flat(section(read(COORD), '## Input'));
|
|
assert.match(input, /\*\*Reviewers' Declined to Judge lines\*\* — each reviewer's own `### Declined to Judge` list, verbatim\. You copy them into `## Declined to Judge` \(body section 9\); you do not rule on them\./,
|
|
'the coordinator\'s Input no longer names the reviewers\' Declined to Judge lines to copy');
|
|
});
|
|
|
|
test('[DJ] review-coordinator body section 9 copies every reviewer Declined to Judge line as {reviewer}: {line}, outside the JSON array and the verdict', () => {
|
|
const body = flat(section(read(COORD), '### Body sections (in order)'));
|
|
assert.match(body, /then every line a reviewer wrote under its own `### Declined to Judge`, copied as `\{reviewer\}: \{line\}` \(they are not findings: they stay out of the JSON `declined_to_judge` array and do not change the verdict\)/,
|
|
'body section 9 no longer copies the reviewers\' Declined to Judge lines');
|
|
});
|
|
|
|
test('[ORD] sibling: agents/review-coordinator.md — Pass 1 groups in two passes, title first, with no "else the first one"', () => {
|
|
const pass1 = flat(section(read(COORD), '### Pass 1 — Dedup by `(file, line, rule_key)` triplet'));
|
|
assert.doesNotMatch(pass1, /else the first one/, 'Pass 1 still lets a finding fall back to the first row in arrival order');
|
|
assert.doesNotMatch(pass1, /keep both rows, in input order/, 'Pass 1 still keeps the rows in input order');
|
|
assert.match(pass1, /Group in two passes, so the order the findings arrive in never changes a row: first every finding joins the row of its title/,
|
|
'Pass 1 does not name the two passes, title first');
|
|
});
|
|
|
|
// --- F4b sibling: the writer of step-{N}-upward.diff ------------------------
|
|
// The park tool reads the diff "Back to planning" saves; with color.diff=always
|
|
// or core.quotepath on in the user's config, its headers were coloured or
|
|
// quoted. The save line pins both off, on the same line (no line_ref move).
|
|
|
|
const PARK_WRITER = 'git -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ {start_sha}';
|
|
|
|
test('[F4b] sibling: commands/trekexecute.md "Back to planning" saves the diff with core.quotepath=off and --no-color', () => {
|
|
const lines = read(EXEC).split('\n').filter((l) => l.startsWith('**Back to planning** (`fix_rounds = 4`): save `'));
|
|
assert.equal(lines.length, 1, `expected one "Back to planning" save line; found ${lines.length}`);
|
|
assert.ok(lines[0].includes(`save \`${PARK_WRITER}\` (one form whatever the user's git config) to`),
|
|
`the upward diff is not saved as ${PARK_WRITER}: ${lines[0]}`);
|
|
});
|
|
|
|
// --- e2e script: tests/e2e-fix-round-4-5a.sh --------------------------------
|
|
// The round run end to end (the plan's End-to-end:). Static pins only: the
|
|
// script itself is the End-to-end: command.
|
|
|
|
const E2E_ROUND = 'tests/e2e-fix-round-4-5a.sh';
|
|
|
|
test('e2e script: tests/e2e-fix-round-4-5a.sh exists, is ASCII only and passes /bin/bash -n', () => {
|
|
assert.ok(existsSync(join(ROOT, E2E_ROUND)), `missing file ${E2E_ROUND}`);
|
|
const buf = readFileSync(join(ROOT, E2E_ROUND));
|
|
assert.equal(buf.findIndex((b) => b > 127), -1, 'the e2e script is not ASCII only');
|
|
const r = spawnSync('/bin/bash', ['-n', join(ROOT, E2E_ROUND)], { encoding: 'utf8', env: childEnv() });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
const text = buf.toString('utf8');
|
|
assert.match(text, /e2e: ok/, 'the script does not print e2e: ok');
|
|
assert.match(text, /^set -u$/m, 'the script does not set -u');
|
|
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/]) {
|
|
assert.doesNotMatch(text, bash4, `the script uses ${bash4}, which bash 3.2 lacks`);
|
|
}
|
|
});
|
|
|
|
test('e2e script: it makes its temp directory with mktemp -d and an EXIT trap removes it', () => {
|
|
const text = read(E2E_ROUND);
|
|
assert.match(text, /^TMP="\$\(mktemp -d [^\n]*\)"/m, 'the script does not make $TMP with mktemp -d');
|
|
assert.match(text, /^trap 'rm -rf "\$TMP"' EXIT$/m, 'no EXIT trap removes $TMP');
|
|
});
|
|
|
|
test('e2e script: it names each of the four CLIs it runs', () => {
|
|
const text = read(E2E_ROUND);
|
|
for (const cli of ['lib/util/park-attempt-files.mjs', 'lib/review/verdict-gate.mjs', 'lib/parsers/arg-parser.mjs', 'lib/util/autonomy-gate.mjs']) {
|
|
assert.ok(text.includes(cli), `the e2e script does not run ${cli}`);
|
|
}
|
|
});
|
|
|
|
// --- red script: tests/fix-round-4-5a-red.sh --------------------------------
|
|
// The round's red proof (the brief's last criterion). Static pins only: the
|
|
// script runs itself as the step's Regression:. Its judge is tested to go red
|
|
// in tests/lib/red-judge.test.mjs. The 30 ids are the brief's (brief.md:134
|
|
// and the 4.5a rows), written here as a constant so the list in the script
|
|
// cannot shrink without this test noticing. Since fix round 4-5b the script is
|
|
// a wrapper over the shared tests/helpers/red-round.sh, which makes and
|
|
// removes the temp directory, so the trap pin reads that file.
|
|
|
|
const RED_SCRIPT = 'tests/fix-round-4-5a-red.sh';
|
|
const RED_ROUND = 'tests/helpers/red-round.sh';
|
|
const RED_IDS = ['#52', '#47', '#48', '#46', '#2', '#26', '#33', '#1', '#30', '#28', 'R6', 'D67', '#15', '#12', '#66',
|
|
'#102', '#57', '#67', '#113', '#111', '#24', '#23', 'F9', 'F4a', 'F4b', 'F4c', 'WF', 'CL', 'ORD', 'DJ'];
|
|
|
|
test('red script: tests/fix-round-4-5a-red.sh exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
|
|
assert.ok(existsSync(join(ROOT, RED_SCRIPT)), `missing file ${RED_SCRIPT}`);
|
|
const buf = readFileSync(join(ROOT, RED_SCRIPT));
|
|
assert.equal(buf.findIndex((b) => b > 127), -1, 'the red script is not ASCII only');
|
|
const r = spawnSync('/bin/bash', ['-n', join(ROOT, RED_SCRIPT)], { encoding: 'utf8', env: childEnv() });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
const text = buf.toString('utf8');
|
|
assert.match(text, /^set -u$/m, 'the red script does not set -u');
|
|
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/, /^\s*[A-Za-z_]+=\(/m, /\$\{[A-Za-z_]+,,\}/]) {
|
|
assert.doesNotMatch(text, bash4, `the red script uses ${bash4}, which bash 3.2 lacks or the round forbids`);
|
|
}
|
|
});
|
|
|
|
test('red script: an EXIT trap removes $TMP, and INT, TERM and HUP remove it too', () => {
|
|
const text = read(RED_ROUND);
|
|
assert.match(text, /^TMP="\$\(mktemp -d [^\n]*\)"/m, 'the red script does not make $TMP with mktemp -d');
|
|
assert.match(text, /^trap 'rm -rf "\$TMP"' EXIT$/m, 'no EXIT trap removes $TMP');
|
|
assert.match(text, /^trap '[^'\n]*rm -rf "\$TMP"[^'\n]*' INT TERM HUP$/m, 'no INT/TERM/HUP trap removes $TMP');
|
|
});
|
|
|
|
test('red script: BASE is the full ab6b32c sha', () => {
|
|
const text = read(RED_SCRIPT);
|
|
const lines = text.split('\n').filter((l) => /^BASE=/.test(l));
|
|
assert.deepEqual(lines, ['BASE=ab6b32c9304534b77c7ca2b36e7c993682c9efde'], 'BASE is not the one full ab6b32c sha');
|
|
});
|
|
|
|
test('red script: the id list is the round\'s 30 ids, in the brief\'s order', () => {
|
|
const text = read(RED_SCRIPT);
|
|
const lines = text.split('\n').filter((l) => /^IDS=/.test(l));
|
|
assert.equal(lines.length, 1, `expected one IDS= line; found ${lines.length}`);
|
|
const m = lines[0].match(/^IDS='([^']*)'$/);
|
|
assert.ok(m, `the IDS= line is not one single-quoted list: ${lines[0]}`);
|
|
assert.deepEqual(m[1].trim().split(/\s+/), RED_IDS);
|
|
});
|
|
|
|
// --- CHANGELOG: what the round fixed ----------------------------------------
|
|
// Untagged: the CHANGELOG text is the round's report, not one id's fix. The
|
|
// round's section is found by its title, as tests/helpers/changelog-section.mjs
|
|
// finds the 6.4.0 delivery's: `## Unreleased — <title>` before the release,
|
|
// `## vX.Y.Z — <date> — <title>` after it, exactly once, up to the next `## `
|
|
// heading. The released 6.5.0 section must stay byte-equal: its sha256 (the
|
|
// heading line through the line before the next `## ` heading, joined with
|
|
// `\n`) was computed once from `git show ab6b32c:CHANGELOG.md` with this
|
|
// slice; the test reads no git history.
|
|
|
|
const CHANGELOG = 'CHANGELOG.md';
|
|
const ROUND_TITLE = "The annotating skill, the run coupling and P10's model-free part";
|
|
const ROUND_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${ROUND_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
|
const V650_SHA256 = '1df8fd0fe7730204740ca509c8fceb6f9d331859b98ce5c1f8e62b9551ce3274';
|
|
|
|
/** The CHANGELOG lines from the one heading `isHeading` accepts to the line before the next `## ` heading. */
|
|
function changelogPart(isHeading, what) {
|
|
const lines = read(CHANGELOG).split('\n');
|
|
const at = [];
|
|
lines.forEach((l, i) => { if (isHeading(l.replace(/\s+$/, ''))) at.push(i); });
|
|
assert.equal(at.length, 1, `${CHANGELOG} has the ${what} section exactly once (found ${at.length})`);
|
|
let end = lines.length;
|
|
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
|
|
return lines.slice(at[0], end).join('\n');
|
|
}
|
|
|
|
const roundPart = () => changelogPart((l) => ROUND_HEADING.test(l), `"${ROUND_TITLE}"`);
|
|
|
|
/** The `### ` subsection headings of the round's section, in order. */
|
|
const roundSubsections = () => roundPart().split('\n').filter((l) => /^### /.test(l)).map((l) => l.replace(/\s+$/, ''));
|
|
|
|
/** The bullets of one `### ` subsection of the round's section, each flattened to one line. */
|
|
function roundBullets(heading) {
|
|
const lines = section(roundPart(), heading).split('\n').slice(1);
|
|
const out = [];
|
|
for (const l of lines) {
|
|
if (/^- /.test(l)) out.push(l);
|
|
else if (out.length && l.trim() !== '') out[out.length - 1] += ` ${l}`;
|
|
}
|
|
return out.map(flat);
|
|
}
|
|
|
|
const FIXED_KNOWN_ISSUES = [
|
|
['trial-5 finding 9 (core code never deep-reviewed)', /trial-5 finding 9/],
|
|
['the park step', /park step/],
|
|
['the workflow floor and put-back', /`--workflow`.*floor.*put-back|`--workflow`.*put-back.*floor/],
|
|
['the row order (which row a finding joins)', /which row a finding joins/i],
|
|
['the coordinator\'s line numbers (a line past the end refuted)', /line (?:number )?past the end of the file.*(?:refuted|refutes)/],
|
|
['the Declined to Judge copy-through', /Declined to Judge.*`review\.md`/],
|
|
];
|
|
|
|
test('CHANGELOG: the round\'s section has ### Fixed between ### Changed and ### Doors, and ### Known issues after ### Fixed', () => {
|
|
const subs = roundSubsections();
|
|
for (const h of ['### Changed', '### Fixed', '### Known issues', '### Doors']) {
|
|
assert.equal(subs.filter((s) => s === h).length, 1, `the round's section must have "${h}" once; it has ${JSON.stringify(subs)}`);
|
|
}
|
|
const at = (h) => subs.indexOf(h);
|
|
assert.ok(at('### Changed') < at('### Fixed') && at('### Fixed') < at('### Doors'),
|
|
`### Fixed must stand between ### Changed and ### Doors: ${JSON.stringify(subs)}`);
|
|
assert.ok(at('### Fixed') < at('### Known issues'), `### Known issues must come after ### Fixed: ${JSON.stringify(subs)}`);
|
|
});
|
|
|
|
test('CHANGELOG: ### Fixed names each of the six 6.5.0 Known issues the round fixed, each as a 6.5.0 Known issue', () => {
|
|
const bullets = roundBullets('### Fixed');
|
|
const known = bullets.filter((b) => /6\.5\.0 Known issue/.test(b));
|
|
const missing = FIXED_KNOWN_ISSUES.filter(([, re]) => !known.some((b) => re.test(b))).map(([name]) => name);
|
|
assert.deepEqual(missing, [], `### Fixed names no entry marked "6.5.0 Known issue" for: ${missing.join('; ')}`);
|
|
});
|
|
|
|
test('CHANGELOG: ### Known issues names that a plan\'s profile: field has no writer, and names /trekexecute and /trekcontinue', () => {
|
|
const hits = roundBullets('### Known issues').filter((b) => b.includes("**A plan's `profile:` field has no writer.**"));
|
|
assert.equal(hits.length, 1, `expected one Known issues line on the plan's profile: field; found ${hits.length}`);
|
|
assert.ok(hits[0].includes('`/trekexecute` and `/trekcontinue`'), `the line does not name \`/trekexecute\` and \`/trekcontinue\`: ${hits[0]}`);
|
|
});
|
|
|
|
test('CHANGELOG: ### Doors has the two-way line for fix round 4.5a', () => {
|
|
const hits = roundBullets('### Doors').filter((b) => b.startsWith('- two-way: fix round 4.5a'));
|
|
assert.deepEqual(hits, ['- two-way: fix round 4.5a (decisions 57, 67, 68) — a revert restores the old texts and checks'],
|
|
'the Doors part has no single two-way line for fix round 4.5a');
|
|
});
|
|
|
|
const SIBLINGS_ENTRY = '- **The texts next to the fixes say the same** (siblings found during the round): `/trekreview`, the review '
|
|
+ 'reference and the Arm B coordinator prompt no longer drop a finding whose rule key is unknown; `/trekbrief` writes its stats '
|
|
+ 'record on every exit of its manual and auto paths, says that `--gates` adds one pause before its auto chain, and that the chain '
|
|
+ 'ends at the plan; `/trekexecute` says `--gates` changes nothing there, checks the wave marker before a low-effort single session, '
|
|
+ 'and names no validator check that does not exist; plan mode goes through the plan approval before it prints the `/trekexecute` '
|
|
+ 'line; the commands read only the two-token `--gates <value>` form, and `--gates=true` is the gates shim\'s own form '
|
|
+ '(`lib/util/autonomy-gate.mjs`), which it reads as `--gates true`.';
|
|
|
|
test('CHANGELOG: ### Fixed holds the siblings entry, exactly', () => {
|
|
assert.ok(roundBullets('### Fixed').includes(SIBLINGS_ENTRY), '### Fixed has no siblings entry with the exact text');
|
|
});
|
|
|
|
test('CHANGELOG: the released v6.5.0 section is byte-equal to its text at ab6b32c (sha256)', () => {
|
|
const part = changelogPart((l) => l.startsWith('## v6.5.0 — '), 'v6.5.0');
|
|
assert.equal(createHash('sha256').update(part, 'utf8').digest('hex'), V650_SHA256,
|
|
'the released v6.5.0 section changed; edit only the round\'s section');
|
|
});
|
|
|
|
// Found in execution (f6bdc2d): texts next to the round's fixes that still
|
|
// said what the code no longer does. Each test fails on an assertion at
|
|
// 8a2a989, before the text changed; its presence assertion holds there. Each
|
|
// carries its 4.5a parent tag (`[WF] sibling:`, `[#113] sibling:`,
|
|
// `[#111] sibling:`) and fails on an assertion at ab6b32c, except the two
|
|
// `[M1r]` tests: they pass at ab6b32c, so they carry a 4.5b id and
|
|
// tests/fix-round-4-5b-red.sh proves them red under the M1r mutation.
|
|
|
|
// D1: the workflow matches each finding to its own row (`rowOf` in
|
|
// scripts/trekreview-armB.workflow.mjs), not to its exact triplet.
|
|
const PUT_BACK_TEXTS = [
|
|
['commands/trekreview.md', () => partAt('commands/trekreview.md', 'On this path dedup ranks as Pass 1 does', toBlank)],
|
|
['docs/HANDOVER-CONTRACTS.md', () => partAt('docs/HANDOVER-CONTRACTS.md', 'On the workflow path dedup ranks as Pass 1 does', () => true)],
|
|
];
|
|
const ROW_IS = 'its `(file, line, rule_key)` triplet plus its title, trimmed and in lower case';
|
|
const COUNTERPART_IS = 'its counterpart is its own row, else the strictest row on its triplet';
|
|
const PUT_BACK_IS = 'of the engine findings in a row no returned finding is on, the one with the highest severity (on a tie, the first seen) is put back at its own severity (operator decision 23)';
|
|
|
|
for (const [rel, part] of PUT_BACK_TEXTS) {
|
|
test(`[WF] sibling: ${rel} no longer puts back what the coordinator did not return "at its exact (file, line, rule_key)"`, () => {
|
|
const text = part();
|
|
assert.ok(text.includes('put back at its own severity'), `${rel}: the put-back sentence is missing: ${text}`);
|
|
assert.doesNotMatch(text, /at its exact `\(file, line, rule_key\)`/, `${rel}: the put-back still matches on the exact triplet`);
|
|
});
|
|
|
|
test(`[WF] sibling: ${rel} says a finding is matched to its row, the triplet plus the normalized title`, () => {
|
|
const text = part();
|
|
assert.ok(text.includes('put back at its own severity'), `${rel}: the put-back sentence is missing: ${text}`);
|
|
assert.ok(text.includes(`each finding is matched to its own row, ${ROW_IS}`), `${rel}: the put-back does not say the row: ${text}`);
|
|
});
|
|
|
|
test(`[WF] sibling: ${rel} puts back one copy per row, the row's engine finding with the highest severity`, () => {
|
|
const text = part();
|
|
assert.ok(text.includes('put back at its own severity'), `${rel}: the put-back sentence is missing: ${text}`);
|
|
assert.ok(text.includes(PUT_BACK_IS), `${rel}: the put-back does not say which one copy of a row is put back: ${text}`);
|
|
});
|
|
|
|
test(`[WF] sibling: ${rel} says floor (b)'s counterpart is the finding's own row, else the strictest row on its triplet`, () => {
|
|
const text = part();
|
|
assert.ok(text.includes('(floor (b))'), `${rel}: floor (b) is missing: ${text}`);
|
|
assert.ok(text.includes(COUNTERPART_IS), `${rel}: floor (b) does not say which row is the counterpart: ${text}`);
|
|
});
|
|
}
|
|
|
|
// D2: the README's conditional line says what /trekresearch says.
|
|
const conditionalLine = () => partAt('README.md', '`* = conditional`:', () => true);
|
|
|
|
test('[M1r] README.md names no "leading recommendation" trigger for security-researcher', () => {
|
|
const text = conditionalLine();
|
|
assert.ok(text.includes('contrarian-researcher'), `the conditional line does not name contrarian-researcher: ${text}`);
|
|
assert.doesNotMatch(text, /security-researcher when a leading recommendation forms/, 'README.md still names a trigger /trekresearch does not define');
|
|
});
|
|
|
|
test('[#113] sibling: README.md stars security-researcher nowhere, as docs-researcher and community-researcher (selected by scope alike)', () => {
|
|
const diagram = partAt('README.md', 'RE["EXTERNAL: docs-researcher', () => true);
|
|
assert.ok(diagram.includes('security-researcher'), `the diagram's external line does not name security-researcher: ${diagram}`);
|
|
assert.ok(diagram.includes('security-researcher · contrarian-researcher*'), `the diagram still stars security-researcher: ${diagram}`);
|
|
});
|
|
|
|
test('[M1r] README.md lists security-researcher as no conditional agent', () => {
|
|
const text = conditionalLine();
|
|
assert.ok(text.includes('contrarian-researcher'), `the conditional line does not name contrarian-researcher: ${text}`);
|
|
assert.ok(!text.includes('security-researcher'), `the conditional line still lists security-researcher: ${text}`);
|
|
});
|
|
|
|
test('[#113] sibling: README.md names the deep-research case of wave 2', () => {
|
|
const text = conditionalLine();
|
|
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2'), `the conditional line does not name wave 2: ${text}`);
|
|
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1\'s external findings to attack (with `--engine deep-research`, when the engine is active, after the engine returns, from the leading answer of its report), always at `effort=high`;'), `README.md does not name the deep-research case of wave 2: ${text}`);
|
|
});
|
|
|
|
test('[#113] sibling: README.md runs contrarian-researcher after wave 1 returns', () => {
|
|
const text = conditionalLine();
|
|
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2'), `the conditional line does not name wave 2: ${text}`);
|
|
assert.ok(text.includes('runs in wave 2 after wave 1 returns'), `README.md does not say "after wave 1 returns": ${text}`);
|
|
});
|
|
|
|
// D3: item 3 of both 4-pass lists names the high-effort skip and points at the normalization.
|
|
const HIGH_EFFORT_SKIP = {
|
|
'commands/trekreview.md': /Skipped at high effort too, where an unknown `rule_key` is normalized to `PLAN_EXECUTE_DRIFT` instead \(.*High-effort.*\); the brief-conflict rule still applies in every mode\./,
|
|
// This item 3 names no quick mode, so no "too": it points at the quick-mode line below the list.
|
|
'agents/review-orchestrator.md': /Skipped at high effort, as in `quick` mode \(below\); at high effort an unknown `rule_key` is normalized to `PLAN_EXECUTE_DRIFT` instead \(.*High-effort.*\); the brief-conflict rule still applies in every mode\./,
|
|
};
|
|
|
|
for (const rel of Object.keys(HIGH_EFFORT_SKIP)) {
|
|
test(`[#111] sibling: ${rel} item 3 names Pass 3's high-effort skip and the PLAN_EXECUTE_DRIFT normalization`, () => {
|
|
const item = pass3Item(rel);
|
|
assert.match(item, HIGH_EFFORT_SKIP[rel], `${rel}: item 3 does not name the high-effort skip: ${item}`);
|
|
assert.ok(item.includes(PASS3_ITEM.slice(PASS3_ITEM.indexOf(' — ') + 3)), `${rel}: item 3 is not the Pass 3 sentence: ${item}`);
|
|
});
|
|
}
|
|
|
|
// D4: the siblings entry has its own group label.
|
|
test('[#111] sibling: CHANGELOG ### Fixed has the **Across the round** label directly above the siblings entry', () => {
|
|
const lines = section(roundPart(), '### Fixed').split('\n');
|
|
const at = lines.map((l, i) => (l.startsWith('- **The texts next to the fixes say the same**') ? i : -1)).filter((i) => i !== -1);
|
|
assert.equal(at.length, 1, `### Fixed must hold the siblings entry once; found ${at.length}`);
|
|
assert.deepEqual(lines.slice(at[0] - 2, at[0]), ['**Across the round**', ''],
|
|
`the line pair above the siblings entry is not the **Across the round** label: ${JSON.stringify(lines.slice(at[0] - 2, at[0]))}`);
|
|
});
|
|
|
|
// The skip sentences outside the 4-pass lists keep the brief-conflict rule,
|
|
// as agents/review-coordinator.md does ("still applies under high-effort mode",
|
|
// "still applies in quick mode").
|
|
const BRIEF_CONFLICT_KEPT = ', except the brief-conflict rule, which applies in every mode.';
|
|
|
|
test('[#111] sibling: commands/trekreview.md high-effort section skips Pass 3 except the brief-conflict rule', () => {
|
|
const text = flat(section(read('commands/trekreview.md'), '### High-effort behavior (v5.1.1)'));
|
|
assert.ok(text.includes('skip Pass 3 (Cloudflare reasonableness filter)'), `the high-effort section does not skip Pass 3: ${text}`);
|
|
assert.ok(text.includes(`skip Pass 3 (Cloudflare reasonableness filter) in \`agents/review-coordinator.md\`${BRIEF_CONFLICT_KEPT}`),
|
|
`the high-effort skip keeps no brief-conflict rule: ${text}`);
|
|
});
|
|
|
|
test('[#111] sibling: agents/review-orchestrator.md quick mode skips pass 3 except the brief-conflict rule', () => {
|
|
const text = partAt('agents/review-orchestrator.md', 'In `quick` mode, skip pass 3', toBlank);
|
|
assert.ok(text.includes('skip pass 3 (reasonableness filter)'), `the quick-mode line does not skip pass 3: ${text}`);
|
|
assert.ok(text.includes(`In \`quick\` mode, skip pass 3 (reasonableness filter)${BRIEF_CONFLICT_KEPT}`),
|
|
`the quick-mode skip keeps no brief-conflict rule: ${text}`);
|
|
});
|