feat(trekexecute): couple the executor to the review gate, the plan approval and the step checks
Wave 2, order 6. /trekexecute now: - refuses a plan whose logged approval does not cover its current sha256 (K2, approval-log verify --kind plan), before step 1; - runs the step check (K9) and the step's optional Regression: (K10, new lib/verification/regression-check.mjs through the criteria runner) at step end, before the checkpoint; exit 2 is "not measured", never green; - runs the architecture rules (K5) and the plan-file sweep (K6) after the last step; a violation or a file outside the plan turns completed into partial; - reviews by itself in the closing run (Phase 7.2): run the thing, /simplify, /trekreview, verdict-gate; a BLOCK adds the review's remediation steps after the plan validator accepts them and the operator re-approves the plan, capped at TREKEXECUTE_MAX_REVIEW_ROUNDS (3); - makes the main-merge gate a lock on the verdict: BLOCK or undecided keeps the merge closed; when open, the executor merges and the guard's question is the one question (operator decision 2026-10-08). The --gates pause before the wave merge-back is gone for the same reason. The guard refuses a merge into main and a push in the permission mode that shows no prompts, since no one can be asked (operator decision 2026-10-09; two mode-read exemptions approved by the operator). progress.json gains optional coupling fields (Handover 5); the validator rejects the runner's exitCode in plan_verification.failed_criteria (C15). Pointer lines name the skills test-first and debugging (S2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4ee711a6ac
commit
920fbd3c47
15 changed files with 1310 additions and 64 deletions
349
tests/commands/trekexecute-coupling.test.mjs
Normal file
349
tests/commands/trekexecute-coupling.test.mjs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
// tests/commands/trekexecute-coupling.test.mjs
|
||||
// Wave 2, order 6 — the executor's coupling to the contracts, run against the
|
||||
// real CLIs (the prose pins are in tests/lib/coupling-prose-pins.test.mjs):
|
||||
//
|
||||
// K7 a stub review.md with `verdict: BLOCK` → the verdict gate exits 1, and
|
||||
// Phase 8 keeps the main merge closed on 1 and on 2.
|
||||
// K2 a plan edited after approval → the exact Phase 1 command refuses it
|
||||
// (a temp repo, the real approval log).
|
||||
// K9 the step check's exit 2 → `step_check: "not-measured"`, never green.
|
||||
// K10 a failing `Regression:` → regression-check exits 1, so the step does
|
||||
// not reach its checkpoint.
|
||||
// C15 the runner's `exitCode` must be written as `exit_code`: the progress
|
||||
// validator rejects the runner's key in `plan_verification.failed_criteria`.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validateProgressObject } from '../../lib/validators/progress-validator.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const CMD = readFileSync(join(ROOT, 'commands', 'trekexecute.md'), 'utf8');
|
||||
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
||||
const LOG = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
||||
const STEP_CHECK = join(ROOT, 'lib', 'verification', 'step-check.mjs');
|
||||
const REGRESSION = join(ROOT, 'lib', 'verification', 'regression-check.mjs');
|
||||
|
||||
/** The text of a markdown section: from its heading to the next heading of the same or a higher level. */
|
||||
function section(text, heading) {
|
||||
const at = text.indexOf(`\n${heading}\n`);
|
||||
assert.ok(at >= 0, `trekexecute.md must have the heading "${heading}"`);
|
||||
const level = heading.match(/^#+/)[0].length;
|
||||
const lines = text.slice(at + heading.length + 2).split('\n');
|
||||
const stop = new RegExp(`^#{1,${level}} `);
|
||||
let fenced = false;
|
||||
const out = [];
|
||||
for (const l of lines) {
|
||||
if (/^\s*(```|~~~)/.test(l)) fenced = !fenced;
|
||||
else if (!fenced && stop.test(l)) break;
|
||||
out.push(l);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function tmp(prefix) {
|
||||
return realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
||||
}
|
||||
|
||||
function git(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function repo() {
|
||||
const dir = tmp('coupling-repo-');
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
git(dir, 'config', 'user.email', 't@example.invalid');
|
||||
git(dir, 'config', 'user.name', 't');
|
||||
git(dir, 'config', 'commit.gpgsign', 'false');
|
||||
writeFileSync(join(dir, 'README.md'), 'x\n');
|
||||
git(dir, 'add', 'README.md');
|
||||
git(dir, 'commit', '-q', '-m', 'init');
|
||||
return dir;
|
||||
}
|
||||
|
||||
const node = (args, opts = {}) => spawnSync(process.execPath, args, { encoding: 'utf8', ...opts });
|
||||
const json = (r) => JSON.parse(r.stdout.trim().split('\n').pop());
|
||||
|
||||
// ---- K7 ---------------------------------------------------------------------
|
||||
|
||||
function reviewStub(verdict) {
|
||||
const id = 'b'.repeat(40);
|
||||
const severities = verdict === 'BLOCK' ? ['BLOCKER'] : [];
|
||||
const findings = severities.map((s) => ({
|
||||
id, severity: s, rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.',
|
||||
}));
|
||||
const counts = { BLOCKER: severities.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-08-s/', 'brief_path: .claude/projects/2026-10-08-s/brief.md',
|
||||
'scope_sha_end: abc', 'reviewed_files_count: 1', `verdict: ${verdict}`,
|
||||
severities.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');
|
||||
}
|
||||
|
||||
test('K7: a stub review.md with verdict BLOCK -> the gate exits 1, and Phase 8 keeps the merge closed on 1 and 2', () => {
|
||||
const dir = tmp('coupling-review-');
|
||||
try {
|
||||
const p = join(dir, 'review.md');
|
||||
writeFileSync(p, reviewStub('BLOCK'));
|
||||
const r = node([GATE, '--json', p]);
|
||||
assert.equal(r.status, 1, `BLOCK must exit 1: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(json(r).verdict, 'BLOCK');
|
||||
writeFileSync(p, reviewStub('ALLOW'));
|
||||
assert.equal(node([GATE, p]).status, 0, 'the control: ALLOW exits 0');
|
||||
rmSync(p);
|
||||
assert.equal(node([GATE, p]).status, 2, 'a review that was never written exits 2');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
|
||||
const gate = section(CMD, '### Main-merge gate (MAIN_MERGE_GATE — always pauses)');
|
||||
assert.match(gate, /lib\/review\/verdict-gate\.mjs --json "\{review_path\}"/, 'Phase 8 runs the verdict gate on review.md');
|
||||
assert.match(gate, /\| 1 \| BLOCK \| closed/, 'exit 1 keeps the merge closed');
|
||||
assert.match(gate, /\| 2 \| not measured \| closed/, 'exit 2 keeps the merge closed - never green');
|
||||
assert.match(gate, /The merge runs only on exit 0/);
|
||||
});
|
||||
|
||||
// ---- K2 ---------------------------------------------------------------------
|
||||
|
||||
/** The Phase 1 plan-approval command, as the prose writes it, with the placeholders filled. */
|
||||
function approvalCommand(planPath) {
|
||||
const m = /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/approval-log\.mjs verify --json --kind plan --subject "\{plan_path\}" --sha256-of "\{plan_path\}"/.exec(
|
||||
section(CMD, '### Plan approval gate (K2)'),
|
||||
);
|
||||
assert.ok(m, 'Phase 1 must run approval-log.mjs verify --kind plan against the plan file');
|
||||
return [LOG, 'verify', '--json', '--kind', 'plan', '--subject', planPath, '--sha256-of', planPath];
|
||||
}
|
||||
|
||||
test('K2: a plan edited after approval -> the Phase 1 command refuses it', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
const plan = join(dir, '.claude', 'projects', '2026-10-08-x', 'plan.md');
|
||||
mkdirSync(dirname(plan), { recursive: true });
|
||||
writeFileSync(plan, '# Plan\n\n### Step 1: one\n');
|
||||
const never = node(approvalCommand(plan), { cwd: dir });
|
||||
assert.equal(never.status, 1, 'a plan never approved is refused');
|
||||
assert.equal(json(never).status, 'not-logged');
|
||||
|
||||
const add = node([LOG, 'append', '--kind', 'plan', '--subject', plan, '--sha256-of', plan], { cwd: dir });
|
||||
assert.equal(add.status, 0, add.stdout + add.stderr);
|
||||
assert.equal(node(approvalCommand(plan), { cwd: dir }).status, 0, 'the approved plan, unchanged, passes');
|
||||
|
||||
writeFileSync(plan, '# Plan\n\n### Step 1: one\n### Step 2: added after approval\n');
|
||||
const edited = node(approvalCommand(plan), { cwd: dir });
|
||||
assert.equal(edited.status, 1, 'the edited plan is refused');
|
||||
assert.equal(json(edited).status, 'mismatch');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
|
||||
const gate = section(CMD, '### Plan approval gate (K2)');
|
||||
assert.match(gate, /Exit 1 → \*\*halt\*\*/, 'exit 1 halts');
|
||||
assert.match(gate, /Exit 2, or no JSON at all → \*\*halt\*\*/, 'exit 2 halts: a check that could not run is never a pass');
|
||||
});
|
||||
|
||||
// ---- K9 ---------------------------------------------------------------------
|
||||
|
||||
const stepWith = (extra) => ({
|
||||
schema_version: '1', plan: 'plan.md', plan_version: '1.8', mode: 'execute', status: 'in_progress',
|
||||
total_steps: 1, current_step: 1, steps: { 1: { status: 'in_progress', attempts: 1, ...extra } },
|
||||
});
|
||||
const codes = (r) => r.errors.map((e) => e.code);
|
||||
|
||||
test('K9: exit 2 from the step check -> step_check "not-measured", never green', () => {
|
||||
const dir = tmp('coupling-norepo-');
|
||||
try {
|
||||
const r = node([STEP_CHECK, '--cwd', dir]);
|
||||
assert.equal(r.status, 2, `not a repository must be "not measured" (exit 2): ${r.stdout}${r.stderr}`);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
|
||||
const d4 = section(CMD, '#### Sub-step D4 — Step check and regression');
|
||||
assert.match(d4, /\| 2 \| `"not-measured"` \|/, 'exit 2 is written as not-measured');
|
||||
assert.match(d4, /never green/);
|
||||
for (const v of ['clean', 'findings', 'not-measured']) {
|
||||
assert.deepEqual(codes(validateProgressObject(stepWith({ step_check: v }))), [], `step_check ${v}`);
|
||||
}
|
||||
for (const bad of ['green', 'pass', 'measured', 2]) {
|
||||
assert.deepEqual(codes(validateProgressObject(stepWith({ step_check: bad }))), ['PROGRESS_STEP_CHECK_INVALID'], `step_check ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- K10 --------------------------------------------------------------------
|
||||
|
||||
function regressionRepo(regressionLine) {
|
||||
const dir = repo();
|
||||
mkdirSync(join(dir, 'tests'));
|
||||
writeFileSync(join(dir, 'tests', 'red.test.mjs'), "import { test } from 'node:test';\ntest('red', () => { throw new Error('red'); });\n");
|
||||
writeFileSync(join(dir, 'tests', 'green.test.mjs'), "import { test } from 'node:test';\ntest('green', () => {});\n");
|
||||
const plan = join(dir, 'plan.md');
|
||||
writeFileSync(plan, [
|
||||
'---', 'plan_version: "1.8"', '---', '', '## Implementation Plan', '',
|
||||
'### Step 1: the step', '- **Files:** `src/a.mjs`', '- **Verify:** `node --test tests/green.test.mjs`',
|
||||
...(regressionLine ? [regressionLine] : []), '- **On failure:** revert', '',
|
||||
'### Step 2: the next', '- **Files:** `src/b.mjs`', '- **Regression:** `node --test tests/green.test.mjs`', '',
|
||||
].join('\n'));
|
||||
return { dir, plan };
|
||||
}
|
||||
|
||||
const regression = (dir, plan, step = '1') => node([REGRESSION, '--plan', plan, '--step', step, '--cwd', dir, '--json']);
|
||||
|
||||
test('K10: a failing Regression: -> exit 1, the step does not reach its checkpoint', () => {
|
||||
const red = regressionRepo('- **Regression:** `node --test tests/red.test.mjs`');
|
||||
try {
|
||||
const r = regression(red.dir, red.plan);
|
||||
assert.equal(r.status, 1, `a red regression exits 1: ${r.stdout}${r.stderr}`);
|
||||
const out = json(r);
|
||||
assert.equal(out.status, 'failed');
|
||||
assert.equal(out.exit_code, 1, 'the exit code is written as exit_code (C15)');
|
||||
assert.ok(!('exitCode' in out), 'never the runner key');
|
||||
assert.equal(regression(red.dir, red.plan, '2').status, 0, 'step 2 has its own, green regression');
|
||||
} finally { rmSync(red.dir, { recursive: true, force: true }); }
|
||||
|
||||
const absent = regressionRepo(null);
|
||||
try {
|
||||
const r = regression(absent.dir, absent.plan);
|
||||
assert.equal(r.status, 0, 'no Regression: field -> nothing new happens');
|
||||
assert.equal(json(r).status, 'absent');
|
||||
} finally { rmSync(absent.dir, { recursive: true, force: true }); }
|
||||
|
||||
const piped = regressionRepo('- **Regression:** `npm test | tee x`');
|
||||
try {
|
||||
const r = regression(piped.dir, piped.plan);
|
||||
assert.equal(r.status, 1, 'a command outside the allowlist is never green');
|
||||
assert.equal(json(r).status, 'unrunnable');
|
||||
} finally { rmSync(piped.dir, { recursive: true, force: true }); }
|
||||
|
||||
const none = node([REGRESSION, '--plan', join(tmpdir(), 'no-such-plan-coupling.md'), '--step', '1', '--json']);
|
||||
assert.equal(none.status, 2, 'a plan that cannot be read is "could not run"');
|
||||
|
||||
const d4 = section(CMD, '#### Sub-step D4 — Step check and regression');
|
||||
assert.match(d4, /lib\/verification\/regression-check\.mjs"? --plan "\{plan_path\}" --step \{N\}/);
|
||||
assert.match(d4, /a finding for Sub-step F/, 'a red regression goes into the fix rounds');
|
||||
const e = section(CMD, '#### Sub-step E — Control');
|
||||
assert.match(e, /`regression` is `"passed"` or `"absent"`/, 'the round passes only on a green or absent regression');
|
||||
});
|
||||
|
||||
// ---- C15 --------------------------------------------------------------------
|
||||
|
||||
const withVerification = (failed) => ({
|
||||
...stepWith({}), plan_verification: { status: 'failed', summary: { total: 1, passed: 0, failed: 1, blocked: 0, unrunnable: 0 }, failed_criteria: failed },
|
||||
});
|
||||
|
||||
test('C15: the progress validator rejects exitCode in failed_criteria and accepts exit_code', () => {
|
||||
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test', exit_code: 1 }]))), []);
|
||||
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test', exit_code: null }]))), [],
|
||||
'null: the criterion never ran');
|
||||
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test', exitCode: 1 }]))),
|
||||
['PROGRESS_FAILED_CRITERIA_KEY']);
|
||||
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test' }]))),
|
||||
['PROGRESS_FAILED_CRITERIA_KEY'], 'exit_code is required in each entry');
|
||||
assert.match(section(CMD, '### trekplans — run the plan\'s `## Verification`'), /`exitCode` → `exit_code`/,
|
||||
'the executor translates the runner key explicitly');
|
||||
});
|
||||
|
||||
// ---- P11: a BLOCK's remediation steps are checked before anyone is asked -------
|
||||
|
||||
test('P11: a review step in the template form is caught by the plan validator, so the loop stops before it asks', async () => {
|
||||
const { buildPlan } = await import('../helpers/plan-v18-builder.mjs');
|
||||
const { validatePlanContent } = await import('../../lib/validators/plan-validator.mjs');
|
||||
const plan = buildPlan();
|
||||
assert.deepEqual(validatePlanContent(plan, { strict: true }).errors, [], 'the control: the plan before the append is valid');
|
||||
// The review template's own Step R1, appended the Phase 7.2 way: next number, fields as they are, a Checkpoint.
|
||||
const tpl = readFileSync(join(ROOT, 'templates', 'trekreview-template.md'), 'utf8');
|
||||
const r1 = /### Step R1:[^\n]*\n[\s\S]*?(?=\n> )/.exec(tpl);
|
||||
assert.ok(r1, 'templates/trekreview-template.md carries a ### Step R1');
|
||||
const appended = r1[0].replace(/^### Step R1:/, '### Step 3:') +
|
||||
'- **Checkpoint:** `git commit -m "fix(review): block-style YAML [01234567]"`\n';
|
||||
const edited = plan.replace('\n## Test Strategy', `\n${appended}\n## Test Strategy`);
|
||||
const errors = validatePlanContent(edited, { strict: true }).errors.map((e) => e.code);
|
||||
assert.ok(errors.length > 0, 'a remediation step the executor cannot run must not validate');
|
||||
assert.ok(errors.some((c) => /VERIFY_NOT_RUNNABLE|TEST_FIRST/.test(c)), errors.join(','));
|
||||
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
const check = p.indexOf('plan-validator.mjs --strict --json "{plan_path}"');
|
||||
const ask = p.indexOf('Approve the plan as it is now?');
|
||||
assert.ok(check > 0 && ask > check, 'the validator runs before the approval question');
|
||||
assert.match(p, /restore the plan from the copy/, 'a stop leaves the plan matching its approval');
|
||||
});
|
||||
|
||||
test('P11: the lock is passed only by the closing run - a wave session keeps its completed', () => {
|
||||
const gate = section(CMD, '### Main-merge gate (MAIN_MERGE_GATE — always pauses)');
|
||||
assert.match(gate, /Only the closing run passes the lock/);
|
||||
assert.match(gate, /`--session N`\s+child, a `--step N` run and a session spec/);
|
||||
assert.match(gate, /left as Phase 7\.5 decided it/);
|
||||
assert.match(section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)'), /never a `--step N` run, a `--session N`\s+child/);
|
||||
});
|
||||
|
||||
test('every bash block outside Phase 2.6 that calls "$VOYAGE_ROOT" resolves it itself', () => {
|
||||
const start = CMD.indexOf('\n## Phase 2.6 —');
|
||||
const end = CMD.indexOf('\n## Phase 3 —');
|
||||
const outside = CMD.slice(0, start) + CMD.slice(end);
|
||||
const fences = [...outside.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
|
||||
// A wave-path block (it runs from "$REPO_ROOT") uses the values Phase 2.6 Step 0 records.
|
||||
const using = fences.filter((f) => f.includes('"$VOYAGE_ROOT/') && !f.includes('"$REPO_ROOT"'));
|
||||
assert.ok(using.length >= 3, `expected the Phase 7, D4 and K5/K6 blocks, found ${using.length}`);
|
||||
for (const f of using) {
|
||||
assert.match(f, /^VOYAGE_ROOT="\$\{CLAUDE_PLUGIN_ROOT\}"$/m, `a block uses $VOYAGE_ROOT without resolving it:\n${f.slice(0, 300)}`);
|
||||
assert.match(f, /\[ ! -f "\$VOYAGE_ROOT\//, 'and guards the unresolved root with exit 2');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- P12: the guard is the one question, and it refuses where no one is asked --
|
||||
|
||||
const GUARD = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const NO_PROMPT_MODE = 'bypassPermissions';
|
||||
|
||||
function guard(cwd, command, permission_mode) {
|
||||
return node([GUARD], {
|
||||
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd, permission_mode, session_id: 'coupling', tool_use_id: `t${Math.random()}` }),
|
||||
});
|
||||
}
|
||||
|
||||
test('P12: the guard asks before a merge into main; in the mode with no prompts it refuses (operator 2026-10-09)', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
git(dir, 'branch', 'feature');
|
||||
const merge = 'git merge --no-ff feature -m "merge: trekexecute x"';
|
||||
for (const mode of ['default', 'auto', 'acceptEdits']) {
|
||||
const r = guard(dir, merge, mode);
|
||||
assert.equal(r.status, 0, `${mode}: ${r.stderr}`);
|
||||
assert.match(r.stdout, /"permissionDecision":"ask"/, `${mode}: the merge into main is asked`);
|
||||
}
|
||||
const blocked = guard(dir, merge, NO_PROMPT_MODE);
|
||||
assert.equal(blocked.status, 2, 'no one to ask: the merge into main is refused');
|
||||
assert.match(blocked.stderr, /no one to ask/);
|
||||
assert.match(blocked.stderr, /Way forward/);
|
||||
assert.equal(guard(dir, 'git push origin feature', NO_PROMPT_MODE).status, 2, 'a push is refused too');
|
||||
const plain = guard(dir, 'git status', NO_PROMPT_MODE);
|
||||
assert.equal(plain.status, 0, 'an ordinary command still runs');
|
||||
assert.equal(plain.stdout, '');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ---- the other new progress fields (Handover 5, additive-optional) -----------
|
||||
|
||||
test('H5: the wave-2 fields are shape-checked when present and optional when absent', () => {
|
||||
assert.deepEqual(codes(validateProgressObject(stepWith({}))), [], 'an older progress file still validates');
|
||||
const ok = {
|
||||
...stepWith({ regression: 'passed' }),
|
||||
plan_approval: 'approved', arch_rules: 'not-measured', simplify: 'applied',
|
||||
scope_sweep: { status: 'deviation', outside: ['src/x.mjs'] },
|
||||
review: { round: 1, path: '.claude/projects/x/review.md', gate_exit: 1, verdict: 'BLOCK' },
|
||||
};
|
||||
assert.deepEqual(codes(validateProgressObject(ok)), []);
|
||||
const bad = [
|
||||
[{ ...stepWith({ regression: 'green' }) }, 'PROGRESS_REGRESSION_INVALID'],
|
||||
[{ ...stepWith({}), plan_approval: 'yes' }, 'PROGRESS_PLAN_APPROVAL_INVALID'],
|
||||
[{ ...stepWith({}), arch_rules: 'green' }, 'PROGRESS_ARCH_RULES_INVALID'],
|
||||
[{ ...stepWith({}), simplify: 'done' }, 'PROGRESS_SIMPLIFY_INVALID'],
|
||||
[{ ...stepWith({}), scope_sweep: { status: 'deviation' } }, 'PROGRESS_SCOPE_SWEEP_INVALID'],
|
||||
[{ ...stepWith({}), review: { round: 0, gate_exit: 0 } }, 'PROGRESS_REVIEW_INVALID'],
|
||||
[{ ...stepWith({}), review: { round: 1, gate_exit: 3 } }, 'PROGRESS_REVIEW_INVALID'],
|
||||
];
|
||||
for (const [p, code] of bad) assert.deepEqual(codes(validateProgressObject(p)), [code], JSON.stringify(p));
|
||||
});
|
||||
2
tests/fixtures/jsonl-schemas.md
vendored
2
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -22,7 +22,7 @@
|
|||
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1130-1145 | profile, phase_models, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:782-808 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1115-1135 | profile, phase_models, parallel_agents, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2344-2366 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2855-2877 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
|
||||
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
|
||||
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:628-632 | profile, phase_models, profile_source | none |
|
||||
|
|
|
|||
|
|
@ -103,15 +103,19 @@ test('review 8: a command nobody was asked about is not logged, even if it reads
|
|||
assert.equal(readStubLog(log).length, 0);
|
||||
});
|
||||
|
||||
test('uncertain (bypass mode): an approval that no person gave is not logged', async () => {
|
||||
// Operator decision 2026-10-09: where no person can be asked, the guard refuses
|
||||
// a merge into main or a push (exit 2) instead of asking, so the call never runs
|
||||
// and nothing reaches the approval log. post-approval-log's own check of the
|
||||
// recorded mode stays as a second line.
|
||||
test('uncertain (no-prompt mode): the push to main is refused, and no approval is logged', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
const base = { session_id: 's-b', tool_use_id: 'toolu_b', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' }, permission_mode: 'bypassPermissions' };
|
||||
await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
||||
const pre = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
||||
assert.equal(pre.code, 2, `refused, not asked: ${JSON.stringify(pre)}`);
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
||||
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
||||
assert.equal(readStubLog(log).length, 0);
|
||||
assert.match(parseJsonOut(r.stdout).hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
// 9. Classification
|
||||
|
|
|
|||
104
tests/lib/coupling-prose-pins.test.mjs
Normal file
104
tests/lib/coupling-prose-pins.test.mjs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// tests/lib/coupling-prose-pins.test.mjs
|
||||
// Wave 2, order 6 — prose pins for the coupling in commands/trekexecute.md:
|
||||
// the review at the end of a run (P11), the one question before a merge
|
||||
// (operator decision 2026-10-08: the guard stays, the executor's own question
|
||||
// goes where it doubled), the checks wired in at step end and in Phase 7
|
||||
// (K5, K6, K9, K10), and the pointer lines to the skills `test-first` and
|
||||
// `debugging` (S2). The behaviour behind each pin runs in
|
||||
// tests/commands/trekexecute-coupling.test.mjs.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const CMD = readFileSync(join(ROOT, 'commands', 'trekexecute.md'), 'utf8');
|
||||
|
||||
function section(text, heading) {
|
||||
const at = text.indexOf(`\n${heading}\n`);
|
||||
assert.ok(at >= 0, `trekexecute.md must have the heading "${heading}"`);
|
||||
const level = heading.match(/^#+/)[0].length;
|
||||
const lines = text.slice(at + heading.length + 2).split('\n');
|
||||
const stop = new RegExp(`^#{1,${level}} `);
|
||||
let fenced = false;
|
||||
const out = [];
|
||||
for (const l of lines) {
|
||||
if (/^\s*(```|~~~)/.test(l)) fenced = !fenced;
|
||||
else if (!fenced && stop.test(l)) break;
|
||||
out.push(l);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
test('S2: Sub-step B asks for test-first by name, Sub-step F for debugging', () => {
|
||||
assert.match(section(CMD, '#### Sub-step B — Test first and proof of red'), /the `test-first` skill/);
|
||||
assert.match(section(CMD, '#### Sub-step F — Fix rounds'), /the `debugging` skill/);
|
||||
const c = section(CMD, '#### Sub-step C — Implement through one fresh helper');
|
||||
assert.match(c, /`skill: test-first`/, 'the build helper is told the skill by name');
|
||||
assert.match(c, /`skill: debugging`/, 'the fix helper is told the skill by name');
|
||||
// The skill is the advice, the gate is the guarantee: proof of red stays a command.
|
||||
assert.match(section(CMD, '#### Sub-step B — Test first and proof of red'), /red-proof\.mjs --base \{start_sha\}/);
|
||||
});
|
||||
|
||||
test('P11: the review runs by itself after Phase 7, in Cherny\'s order, before Phase 8', () => {
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
const order = ['run the thing', 'Skill("simplify"', 'Skill("voyage:trekreview"', 'verdict-gate.mjs'];
|
||||
const at = order.map((s) => p.indexOf(s));
|
||||
at.forEach((i, k) => assert.ok(i >= 0, `Phase 7.2 must name ${order[k]}`));
|
||||
assert.deepEqual([...at].sort((a, b) => a - b), at, `order must be ${order.join(' -> ')}`);
|
||||
assert.ok(CMD.indexOf('## Phase 7.2 —') > CMD.indexOf('## Phase 7 —'), 'after Phase 7');
|
||||
assert.ok(CMD.indexOf('## Phase 7.2 —') < CMD.indexOf('## Phase 8 —'), 'before Phase 8');
|
||||
assert.match(CMD.split('\n---\n')[0], /\nallowed-tools:[^\n]*\bSkill\b/, 'the command may use the Skill tool');
|
||||
assert.match(p, /never typed by the operator/i);
|
||||
});
|
||||
|
||||
test('P11: a BLOCKER becomes new numbered plan steps, in a bounded loop that ends with the operator', () => {
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
assert.match(p, /TREKEXECUTE_MAX_REVIEW_ROUNDS/, 'the cap is named');
|
||||
assert.match(p, /default \*\*3\*\*/);
|
||||
assert.match(p, /### Step R<n>/, 'the review\'s remediation steps are the source');
|
||||
assert.match(p, /never reworded/);
|
||||
assert.match(p, /at the cap[^\n]*operator/i);
|
||||
assert.match(p, /not improvised/);
|
||||
});
|
||||
|
||||
test('P11: nothing is dropped silently - simplify edits outside the run are reverted and listed', () => {
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
assert.match(p, /outside the run's changed files/i);
|
||||
assert.match(p, /"reverted"/);
|
||||
assert.match(p, /"not-run"/, 'a simplify that could not start is said, not hidden');
|
||||
});
|
||||
|
||||
test('P12: one question before a merge - the guard asks, the executor does not ask again', () => {
|
||||
const gate = section(CMD, '### Main-merge gate (MAIN_MERGE_GATE — always pauses)');
|
||||
assert.doesNotMatch(gate, /AskUserQuestion/, 'the gate is a lock, not a question');
|
||||
assert.match(gate, /operator decision 2026-10-08/);
|
||||
assert.match(gate, /the guard's question is the one question/i);
|
||||
const step3 = section(CMD, '### Step 3 — Collect wave W (a later run)');
|
||||
assert.doesNotMatch(step3, /pause for operator\s+confirmation before merge-back/, 'no executor pause before the wave merge');
|
||||
const allDone = /- \*\*`all_completed: true`:\*\*([\s\S]*?)\n\n/.exec(step3);
|
||||
assert.ok(allDone, 'Step 3 has its all_completed rule');
|
||||
assert.match(allDone[1], /The executor asks\s+nothing here, `gates_mode` or not/);
|
||||
assert.doesNotMatch(allDone[1], /AskUserQuestion|pause for operator/i);
|
||||
assert.match(step3, /one Bash call/, 'push and merge go in one call, so the guard asks once');
|
||||
assert.match(section(CMD, '## Hard rules'), /never asks again/);
|
||||
});
|
||||
|
||||
test('P7/P3/P9: K9 at step end, K5 and K6 in Phase 7', () => {
|
||||
const d4 = section(CMD, '#### Sub-step D4 — Step check and regression');
|
||||
assert.match(d4, /lib\/verification\/step-check\.mjs"? --cwd "\$CHECK_CWD"/);
|
||||
const p7 = section(CMD, '### Architecture rules (K5) and the plan-file sweep (K6)');
|
||||
assert.match(p7, /lib\/verification\/arch-rules\.mjs"? --repo "\$CHECK_CWD"/);
|
||||
assert.match(p7, /lib\/verification\/plan-scope-sweep\.mjs"? --plan "\{plan_path\}" --base "\{session_start_sha\}"/);
|
||||
assert.match(p7, /a finding in the final report with the file list/, 'a K6 deviation is a finding, not a silent warning');
|
||||
assert.match(p7, /not measured/);
|
||||
});
|
||||
|
||||
test('Credits: every borrowing of the coupling is named', () => {
|
||||
const credits = section(CMD, '## Credits');
|
||||
for (const s of [/Cherny[^\n]*order/i, /Kiro[^\n]*gate/i, /Spec Kit[^\n]*loop/i, /Superpowers[^\n]*silent/i]) {
|
||||
assert.match(credits, s);
|
||||
}
|
||||
});
|
||||
|
|
@ -67,6 +67,8 @@ const EXEMPT = new Map([
|
|||
['.gitignore', ['operator-instrument', 1, 'ignores the output of that same operator-run eval']],
|
||||
['hooks/scripts/post-approval-log.mjs', ['mode-read', 3, "reads the session's permission_mode from hook input so an approval is never logged when no person was asked; starts nothing - operator decision 2026-10-06"]],
|
||||
['tests/hooks/guards-review-regressions.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the hook under test; starts nothing']],
|
||||
['hooks/scripts/pre-bash-executor.mjs', ['mode-read', 1, "reads the session's permission_mode from hook input so a merge into main or a push is refused when no person can be asked; starts nothing - operator decision 2026-10-09"]],
|
||||
['tests/commands/trekexecute-coupling.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the guard under test; starts nothing - operator decision 2026-10-09']],
|
||||
]);
|
||||
|
||||
function trackedFiles(root) {
|
||||
|
|
|
|||
|
|
@ -47,9 +47,10 @@ function run(blockText, home) {
|
|||
return execFileSync('bash', ['-c', `${blockText}\nprintf '%s' "$VOYAGE_ROOT"`], { env, encoding: 'utf-8' });
|
||||
}
|
||||
|
||||
test('commands carry exactly the four known VOYAGE_ROOT blocks', () => {
|
||||
// trekexecute blocks 3 and 4 are Sub-step D4 (step check + regression) and Phase 7's K5/K6 checks (wave 2, order 6).
|
||||
test('commands carry exactly the six known VOYAGE_ROOT blocks', () => {
|
||||
assert.deepEqual(resolutionBlocks().map(b => b.where.split(':')[0]), [
|
||||
'commands/trekexecute.md', 'commands/trekexecute.md',
|
||||
'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md',
|
||||
'commands/trekresearch.md', 'commands/trekreview.md',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue