// 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 --steps-from {total + 1} "{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'); // Operator 2026-10-09, second decision: the other two classes as well. for (const cmd of ['git tag v1.0', 'npm publish', 'git reset --hard HEAD~1', 'git push --force origin feature']) { assert.match(guard(dir, cmd, 'default').stdout, /"permissionDecision":"ask"/, `the control: ${cmd} is asked where someone can answer`); const r = guard(dir, cmd, NO_PROMPT_MODE); assert.equal(r.status, 2, `${cmd}: refused where no one is asked`); assert.match(r.stderr, /no one to ask/); } 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 }); } }); // Release review of 6.3.0 (2026-10-10): the refusal covers the guard's two // fallbacks too — rules that will not load, and a command it could not read. function guardIn(plugin, cwd, command, permission_mode) { return node([join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs')], { input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd, permission_mode, session_id: 'coupling', tool_use_id: `t${Math.random()}` }), }); } test('P12 / review 630: a guard whose rules will not load refuses where no one is asked', async () => { const { pluginCopy } = await import('../hooks/guard-helpers.mjs'); const plugin = pluginCopy({ 'hooks/lib/git-approval.mjs': 'missing' }); const dir = repo(); try { const asked = guardIn(plugin, dir, 'git status', 'default'); assert.equal(asked.status, 0, asked.stderr); assert.match(asked.stdout, /"permissionDecision":"ask"/, 'the control: where someone can answer, the broken guard asks'); const r = guardIn(plugin, dir, 'git push origin main', NO_PROMPT_MODE); assert.equal(r.status, 2, `refused, not asked: ${r.stdout}${r.stderr}`); assert.match(r.stderr, /no one to ask/); assert.match(r.stderr, /could not load/); } finally { rmSync(dir, { recursive: true, force: true }); rmSync(plugin, { recursive: true, force: true }); } }); test('P12 / review 630: a command the classifier could not read is asked, and refused where no one is asked', async () => { const { pluginCopy } = await import('../hooks/guard-helpers.mjs'); const plugin = pluginCopy(); const lib = join(plugin, 'hooks', 'lib', 'git-approval.mjs'); const src = readFileSync(lib, 'utf8'); assert.ok(src.includes('export function classifyGitApproval('), 'the copy carries the classifier'); writeFileSync(lib, src.replace('export function classifyGitApproval(', 'export function classifyGitApproval() { throw new Error(\'classifier broke\'); }\nfunction classifyGitApprovalUnused(')); const dir = repo(); try { const asked = guardIn(plugin, dir, 'git push origin feature', 'default'); assert.equal(asked.status, 0, asked.stderr); assert.match(asked.stdout, /"permissionDecision":"ask"/, 'where someone can answer it is asked'); assert.match(asked.stdout, /could not read/); const r = guardIn(plugin, dir, 'git push origin feature', NO_PROMPT_MODE); assert.equal(r.status, 2, `refused: ${r.stdout}${r.stderr}`); assert.match(r.stderr, /could not read/); assert.match(r.stderr, /no one to ask/); } finally { rmSync(dir, { recursive: true, force: true }); rmSync(plugin, { 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)); });