787 lines
46 KiB
JavaScript
787 lines
46 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). Every test carries its id tag in brackets, and every tagged
|
|
// test fails on an assertion at ab6b32c (the round's start point).
|
|
// #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 { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
|
import { readFrontmatterFields } from '../../lib/skills/skill-frontmatter.mjs';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
|
|
/** A repo file's text; the file must exist. */
|
|
function read(rel) {
|
|
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
|
return readFileSync(join(ROOT, rel), 'utf8');
|
|
}
|
|
|
|
/** 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' });
|
|
}
|
|
|
|
// --- #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`);
|
|
}
|
|
|
|
const toBlank = (l) => l.trim() === '';
|
|
|
|
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');
|
|
});
|
|
|
|
// 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.match(text, /\/trekplan's validator should have prevented this/);
|
|
});
|
|
|
|
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 — and stop\./);
|
|
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 and stop; 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 prints the /trekexecute line and stops', () => {
|
|
const pm = flat(section(read('commands/trekplan.md'), '### In plan mode'));
|
|
assert.doesNotMatch(pm, /Then continue with the operator's choice \(Phase 11\)\./, 'plan mode still continues with the operator\'s choice');
|
|
assert.match(pm, /Then print the `\/trekexecute` line \(Phase 11\) and stop — leaving plan mode is not an order to implement\./);
|
|
});
|
|
|
|
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 [
|
|
['session-decomposer (Phase 1.5)', 'Launch the **session-decomposer** agent:'],
|
|
['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] 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 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, /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 and stop.');
|
|
});
|
|
|
|
// --- #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. /trekexecute records gates_mode and pauses on nothing.
|
|
|
|
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}` : '');
|
|
}
|
|
|
|
/** Run a shim line from the prose; its `next_state`. */
|
|
function shimNext(cmdLine) {
|
|
const r = bash(cmdLine, ROOT);
|
|
assert.equal(r.status, 0, `shim exited ${r.status}: ${r.stdout}${r.stderr}`);
|
|
return JSON.parse(r.stdout).next_state;
|
|
}
|
|
|
|
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`);
|
|
}
|
|
});
|
|
|
|
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.match(f, /approval pauses at every phase boundary in the downstream pipeline \(research, plan\)\./);
|
|
});
|
|
|
|
test('[#1] sibling: commands/trekexecute.md records gates_mode and pauses only at the merge into main', () => {
|
|
const f = flat(read('commands/trekexecute.md'));
|
|
assert.doesNotMatch(f, /consumed by the autonomy-gate state machine/);
|
|
assert.match(f, /`\/trekexecute` records `gates_mode`; no phase of it pauses on it\. The one stop is the merge into main, which always asks \(Phase 8\)\./);
|
|
});
|
|
|
|
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 Record gates_mode: 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 — never while a wave is in flight (`WAVE_IN_FLIGHT`)"),
|
|
`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.');
|
|
});
|