// tests/lib/autonomy-gate.test.mjs // Cover the --gates flag parser (lib/util/autonomy-gate.mjs): the exported // parser function, the CLI's JSON-on-stdout contract, and the one CLI line // each of the four commands runs. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync, spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { findSection } from '../../lib/parsers/plan-schema.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const SHIM = join(ROOT, 'lib', 'util', 'autonomy-gate.mjs'); const GATES_USAGE = 'usage: --gates takes true, false, closed or open'; function runShim(args) { try { const out = execFileSync(process.execPath, [SHIM, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); return { code: 0, out }; } catch (e) { return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' }; } } // The exit code is asserted before any JSON.parse, so a crash or a usage // error never reads as a wrong value. function shimGates(args) { const r = runShim(args); assert.equal(r.code, 0, `expected exit 0 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`); const parsed = JSON.parse(r.out.trim()); assert.deepEqual(parsed, { ok: true, gates: parsed.gates }); assert.equal(typeof parsed.gates, 'boolean', `gates must be a boolean: ${r.out}`); return parsed.gates; } function shimUsage(args) { const r = runShim(args); assert.equal(r.code, 1, `expected exit 1 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.ok, false); assert.equal(Object.keys(parsed).sort().join(','), 'error,ok'); return parsed.error; } // ---- the exported parser ---------------------------------------------------- test('gatesValue maps true|closed to on, false|open to off, anything else to undefined', async () => { const mod = await import(pathToFileURL(SHIM).href); assert.equal(typeof mod.gatesValue, 'function', 'autonomy-gate.mjs must export gatesValue'); for (const v of ['true', 'closed']) assert.equal(mod.gatesValue(v), true, v); for (const v of ['false', 'open']) assert.equal(mod.gatesValue(v), false, v); for (const v of ['TRUE', 'yes', 'adaptive', '', undefined]) assert.equal(mod.gatesValue(v), undefined, String(v)); }); test('the module is the flag parser only: no state machine is exported', async () => { const mod = await import(pathToFileURL(SHIM).href); for (const gone of ['STATES', 'EVENTS', 'transition', 'isTerminal']) { assert.equal(mod[gone], undefined, `autonomy-gate.mjs still exports ${gone}`); } }); // ---- the CLI ---------------------------------------------------------------- test('CLI: bare --gates → {"ok":true,"gates":true}, exit 0', () => { assert.equal(shimGates(['--gates']), true); }); test('CLI: no flag → {"ok":true,"gates":false}, exit 0', () => { assert.equal(shimGates([]), false); }); test('CLI: --gates true and --gates closed → gates true', () => { assert.equal(shimGates(['--gates', 'true']), true); assert.equal(shimGates(['--gates', 'closed']), true); }); test('CLI: --gates false and --gates open → gates false', () => { assert.equal(shimGates(['--gates', 'false']), false); assert.equal(shimGates(['--gates', 'open']), false); }); test('CLI: the one-token --gates= form is read as --gates ', () => { assert.equal(shimGates(['--gates=true']), true); assert.equal(shimGates(['--gates=closed']), true); assert.equal(shimGates(['--gates=false']), false); assert.equal(shimGates(['--gates=open']), false); }); for (const bad of [['--gates', 'TRUE'], ['--gates', 'yes'], ['--gates', 'adaptive'], ['--gates=maybe'], ['--gates=']]) { test(`CLI: ${bad.join(' ')} → the exact usage error, exit 1`, () => { assert.equal(shimUsage(bad), GATES_USAGE); }); } for (const old of [ ['--state', 'idle', '--event', 'start', '--gates', 'true'], ['--state', 'idle', '--event', 'start'], ['--gates', '--state', 'idle'], ['--event', 'start'], ]) { test(`CLI: the old state-machine arguments are a usage error (${old.join(' ')}), exit 1`, () => { assert.match(shimUsage(old), /^usage:/); }); } // ---- the four commands' --gates rule and CLI line ---------------------------- // Each of the four commands holds one rule: the next token is the flag's value // only when it is true, false, closed or open; the CLI maps it, run once; a // broken CLI falls back to the same mapping in prose and the run goes on. const GATES_COMMANDS = ['trekbrief', 'trekresearch', 'trekplan', 'trekexecute']; const GATES_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --gates [value]'; 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:', '`gates: true` sets `gates_mode = true`, `false` sets it false.', '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.', ]; const commandText = (cmd) => readFileSync(join(ROOT, 'commands', `${cmd}.md`), 'utf8'); /** Whitespace (line breaks too) collapsed to one space. */ const flat = (s) => s.replace(/\s+/g, ' ').trim(); /** The command's one CLI line, `[value]` filled in with `value` (left out when ''). */ function gatesLine(cmd, value) { const hits = commandText(cmd).split('\n') .filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/autonomy-gate\.mjs\b/.test(l)); assert.equal(hits.length, 1, `${cmd}: expected exactly one autonomy-gate.mjs line; found ${hits.length}`); const l = hits[0].trim(); assert.equal(l, GATES_LINE, `${cmd}: the line must read "${GATES_LINE}"`); return l.replace(' [value]', value ? ` ${value}` : ''); } const lineCache = new Map(); /** Run a CLI line from the prose with the plugin root filled in; its `gates`. Each distinct line runs once. */ function lineGates(cmdLine) { if (!lineCache.has(cmdLine)) { const r = spawnSync('bash', ['-c', cmdLine.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd: ROOT, encoding: 'utf8' }); assert.equal(r.status, 0, `CLI exited ${r.status}: ${r.stdout}${r.stderr}`); const parsed = JSON.parse(r.stdout); assert.equal(parsed.ok, true); lineCache.set(cmdLine, parsed.gates); } return lineCache.get(cmdLine); } for (const cmd of GATES_COMMANDS) { test(`[#1] commands/${cmd}.md holds the shared --gates rule and a CLI line that maps closed to gates true`, () => { const text = commandText(cmd); 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 \w+ --event \w+/, `${cmd}: the state-machine line is still there`); assert.doesNotMatch(f, /next_state|auto_running|gates_on/, `${cmd}: still reads a state of the old state machine`); assert.equal(lineGates(gatesLine(cmd, 'closed')), true); }); } // `/trekplan --gates --project d` and `/trekexecute --gates plan.md` are a bare // --gates (the next token is not a value), so the line runs bare. test('every command\'s CLI line maps a bare --gates to true and open to false', () => { for (const cmd of GATES_COMMANDS) { assert.equal(lineGates(gatesLine(cmd, '')), true, `${cmd}: bare --gates`); assert.equal(lineGates(gatesLine(cmd, 'open')), false, `${cmd}: --gates open`); } }); // ---- [#1] siblings: what --gates does, said the same everywhere -------------- // /trekbrief adds one pause, before its auto chain; /trekexecute accepts the // flag and pauses on nothing; the docs give the four values and no strictness // policy (fix round 4-5a, #1). const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); const EXEC = 'commands/trekexecute.md'; /** * 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; } const GATES_ONE_PAUSE = '`gates_mode = true` adds one pause, before the auto chain starts (Phase 5); the chain does not pass ' + '`--gates` on to `/trekresearch` or `/trekplan`. Default `gates_mode = false` skips that pause.'; test('[#1] sibling: commands/trekbrief.md promises no execute pause and no pause at every wave in the executor', () => { const f = flat(read('commands/trekbrief.md')); assert.doesNotMatch(f, /every wave in the executor/, 'trekbrief still promises a pause at every wave in the executor'); assert.doesNotMatch(f, /\(research, plan, execute\)/, 'trekbrief still promises an execute pause'); assert.ok(f.includes(GATES_ONE_PAUSE), 'trekbrief does not say --gates adds one pause before its auto chain'); }); test('[#1] sibling: /trekbrief Phase 1 says --gates adds one pause, before the auto chain', () => { const p1 = flat(section(read('commands/trekbrief.md'), '## Phase 1 — Parse mode and validate input')); assert.ok(p1.includes(GATES_ONE_PAUSE), 'Phase 1 does not say --gates adds one pause before the auto chain'); assert.ok(!p1.includes('every phase boundary in the downstream pipeline'), 'Phase 1 still promises a pause at every downstream phase boundary'); assert.ok(!p1.includes('runs continuously until the main-merge gate'), 'Phase 1 still says the auto chain runs until the main-merge gate'); }); test('[#1] sibling: /trekbrief Phase 5 says the auto chain ends at the plan, never at the main-merge gate', () => { const p5 = flat(section(read('commands/trekbrief.md'), '## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)')); assert.ok(p5.includes('The chain ends when `/trekplan` has written the plan (Step 6e); it never starts `/trekexecute`.'), 'Phase 5 does not say the chain ends when /trekplan has written the plan'); assert.ok(!p5.includes('The chain stops only at the main-merge gate'), 'Phase 5 still says the chain stops only at the main-merge gate'); }); test('[#1] sibling: docs/operations.md, docs/command-modes.md and README.md say /trekexecute accepts the flag and pauses on nothing', () => { for (const rel of ['docs/operations.md', 'docs/command-modes.md', 'README.md']) { assert.ok(flat(read(rel)).includes('`/trekexecute` accepts the flag and pauses on nothing'), `${rel}: missing "accepts the flag and pauses on nothing"`); } for (const rel of ['docs/operations.md', 'docs/command-modes.md', 'README.md', EXEC]) { const f = flat(read(rel)); for (const old of ['records `gates_mode`', 'Record gates_mode', 'records the setting']) { assert.ok(!f.includes(old), `${rel}: still says "${old}"`); } } }); test('[#1] sibling: commands/trekexecute.md: `gates_mode` changes nothing in `/trekexecute`, and its one stop is the merge into main', () => { const f = flat(read('commands/trekexecute.md')); assert.doesNotMatch(f, /consumed by the autonomy-gate state machine/); assert.ok(f.includes('`gates_mode` changes nothing in `/trekexecute`: no phase of it pauses on it. The one stop is the merge into main, which always asks (Phase 8).'), 'trekexecute does not say gates_mode changes nothing there and the one stop is the merge into main'); }); test('[#1] sibling: docs/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\]`/); });