refactor(gates): the autonomy gate is the --gates flag parser

lib/util/autonomy-gate.mjs keeps only gatesValue + parseGatesArgs and the
CLI ({"ok":true,"gates":true|false}; exact usage error on a bad value;
the old --state/--event arguments are a usage error). The four commands
run 'autonomy-gate.mjs --gates [value]' and read gates. Prose (README,
operations, architecture, sdlc-playbook-gap) and the S24, gates-flag
and [#1] tests follow (R9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-07 10:37:03 +01:00
commit 308b149e70
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
12 changed files with 165 additions and 355 deletions

View file

@ -1,18 +1,19 @@
// tests/lib/autonomy-gate.test.mjs
// Cover the autonomy-gate state machine (lib/util/autonomy-gate.mjs):
// every legal transition + every invalid-transition error + idempotent
// re-entry to `completed` + CLI-shim JSON-on-stdout contract.
// 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 } from 'node:url';
import { transition, isTerminal, STATES, EVENTS } from '../../lib/util/autonomy-gate.mjs';
import { fileURLToPath, pathToFileURL } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'autonomy-gate.mjs');
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 {
@ -26,200 +27,99 @@ function runShim(args) {
}
}
test('idle + start + gates=true → gates_on', () => {
const r = transition(STATES.IDLE, EVENTS.START, { gates: true });
assert.equal(r.ok, true);
assert.equal(r.next_state, STATES.GATES_ON);
});
test('idle + start + gates=false → auto_running', () => {
const r = transition(STATES.IDLE, EVENTS.START, { gates: false });
assert.equal(r.ok, true);
assert.equal(r.next_state, STATES.AUTO_RUNNING);
});
test('idle + start + gates omitted defaults to auto_running', () => {
const r = transition(STATES.IDLE, EVENTS.START);
assert.equal(r.ok, true);
assert.equal(r.next_state, STATES.AUTO_RUNNING);
});
test('gates_on + phase_boundary → paused_for_gate', () => {
const r = transition(STATES.GATES_ON, EVENTS.PHASE_BOUNDARY);
assert.equal(r.ok, true);
assert.equal(r.next_state, STATES.PAUSED_FOR_GATE);
});
test('gates_on + finish → completed', () => {
const r = transition(STATES.GATES_ON, EVENTS.FINISH);
assert.equal(r.ok, true);
assert.equal(r.next_state, STATES.COMPLETED);
});
test('auto_running + phase_boundary → auto_running (no pause)', () => {
const r = transition(STATES.AUTO_RUNNING, EVENTS.PHASE_BOUNDARY);
assert.equal(r.ok, true);
assert.equal(r.next_state, STATES.AUTO_RUNNING);
});
test('auto_running + finish → completed', () => {
const r = transition(STATES.AUTO_RUNNING, EVENTS.FINISH);
assert.equal(r.ok, true);
assert.equal(r.next_state, STATES.COMPLETED);
});
test('paused_for_gate + resume → gates_on', () => {
const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.RESUME);
assert.equal(r.ok, true);
assert.equal(r.next_state, STATES.GATES_ON);
});
test('paused_for_gate + finish → completed', () => {
const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.FINISH);
assert.equal(r.ok, true);
assert.equal(r.next_state, STATES.COMPLETED);
});
test('completed + any event → completed (idempotent re-entry)', () => {
for (const ev of Object.values(EVENTS)) {
const r = transition(STATES.COMPLETED, ev);
assert.equal(r.ok, true, `event ${ev} should be tolerated from completed`);
assert.equal(r.next_state, STATES.COMPLETED, `event ${ev} broke idempotency`);
}
});
test('idle + non-start event → invalid transition error', () => {
const r = transition(STATES.IDLE, EVENTS.PHASE_BOUNDARY);
assert.equal(r.ok, false);
assert.match(r.error, /invalid transition.*idle/);
});
test('gates_on + resume → invalid (resume is only valid from paused_for_gate)', () => {
const r = transition(STATES.GATES_ON, EVENTS.RESUME);
assert.equal(r.ok, false);
});
test('auto_running + resume → invalid (auto-mode never pauses)', () => {
const r = transition(STATES.AUTO_RUNNING, EVENTS.RESUME);
assert.equal(r.ok, false);
});
test('unknown state rejected with descriptive error', () => {
const r = transition('zombie', EVENTS.START);
assert.equal(r.ok, false);
assert.match(r.error, /unknown state/);
});
test('unknown event rejected with descriptive error', () => {
const r = transition(STATES.IDLE, 'snooze');
assert.equal(r.ok, false);
assert.match(r.error, /unknown event/);
});
test('isTerminal — only completed is terminal', () => {
assert.equal(isTerminal(STATES.COMPLETED), true);
for (const s of [STATES.IDLE, STATES.GATES_ON, STATES.AUTO_RUNNING, STATES.PAUSED_FOR_GATE]) {
assert.equal(isTerminal(s), false, `${s} should not be terminal`);
}
});
test('CLI shim returns valid JSON on success (exit 0)', () => {
const r = runShim(['--state', 'idle', '--event', 'start', '--gates', 'true']);
assert.equal(r.code, 0);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.next_state, 'gates_on');
});
test('CLI shim returns JSON error on invalid transition (exit 1)', () => {
const r = runShim(['--state', 'idle', '--event', 'phase_boundary']);
assert.equal(r.code, 1);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, false);
assert.match(parsed.error, /invalid transition/);
});
test('CLI shim missing required args returns usage error (exit 1)', () => {
const r = runShim(['--state', 'idle']);
assert.equal(r.code, 1);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, false);
assert.match(parsed.error, /usage:/);
});
// ---- --gates value rule (fix round 4.5a, #1) ---------------------------------
// The shim is tested only through its CLI; the exit code is asserted before
// any JSON.parse, so a crash or a usage error never reads as a wrong value.
function shimState(args) {
// 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.equal(parsed.ok, true);
return parsed.next_state;
assert.deepEqual(parsed, { ok: true, gates: parsed.gates });
assert.equal(typeof parsed.gates, 'boolean', `gates must be a boolean: ${r.out}`);
return parsed.gates;
}
test('[#1] bare --gates as the last argument → gates_on', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates']), 'gates_on');
});
test('[#1] bare --gates before other flags swallows none of them → exit 0, gates_on', () => {
assert.equal(shimState(['--gates', '--state', 'idle', '--event', 'start']), 'gates_on');
});
test('[#1] --gates closed → gates_on', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'closed']), 'gates_on');
});
for (const bad of ['TRUE', 'yes', 'adaptive']) {
test(`[#1] --gates ${bad} → usage error (exit 1)`, () => {
const r = runShim(['--state', 'idle', '--event', 'start', '--gates', bad]);
assert.equal(r.code, 1, `expected exit 1 for --gates ${bad}, got ${r.code}: ${r.out}`);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, false);
assert.match(parsed.error, /usage/);
});
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;
}
test('--gates true → gates_on', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'true']), 'gates_on');
// ---- 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('--gates false → auto_running', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'false']), 'auto_running');
});
test('--gates open → auto_running', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'open']), 'auto_running');
});
// The `=` form reaches the shim whole as one token; it is read as `--gates <value>`.
test('[#1] sibling: --gates=<value> is read as --gates <value>; an unknown or empty value is the usage error', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=true']), 'gates_on');
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=closed']), 'gates_on');
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=false']), 'auto_running');
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=open']), 'auto_running');
for (const bad of ['--gates=maybe', '--gates=']) {
const r = runShim(['--state', 'idle', '--event', 'start', bad]);
assert.equal(r.code, 1, `expected exit 1 for ${bad}, got ${r.code}: ${r.out}`);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, false);
assert.match(parsed.error, /usage: --gates takes true, false, closed or open/);
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 four commands' --gates rule and shim 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 shim maps it, run once; a
// broken shim falls back to the same mapping in prose and the run goes on.
// ---- 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=<value> form is read as --gates <value>', () => {
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 ROOT = join(HERE, '..', '..');
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:',
'`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it false, and nothing else reads the state.',
'`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.',
];
@ -228,44 +128,46 @@ const commandText = (cmd) => readFileSync(join(ROOT, 'commands', `${cmd}.md`), '
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** The command's one shim line, `[value]` filled in with `value` (left out when ''). */
function gatesShim(cmd, value) {
/** 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) && l.includes('--event start'));
assert.equal(hits.length, 1, `${cmd}: expected exactly one autonomy-gate.mjs --event start line; found ${hits.length}`);
.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.match(l, / --state idle --event start --gates \[value\]$/, `${cmd}: the shim line must end "--gates [value]"`);
assert.equal(l, GATES_LINE, `${cmd}: the line must read "${GATES_LINE}"`);
return l.replace(' [value]', value ? ` ${value}` : '');
}
const shimCache = new Map();
const lineCache = new Map();
/** Run a shim line from the prose with the plugin root filled in; its `next_state`. Each distinct line runs once. */
function shimNext(cmdLine) {
if (!shimCache.has(cmdLine)) {
/** 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, `shim exited ${r.status}: ${r.stdout}${r.stderr}`);
shimCache.set(cmdLine, JSON.parse(r.stdout).next_state);
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 shimCache.get(cmdLine);
return lineCache.get(cmdLine);
}
for (const cmd of GATES_COMMANDS) {
test(`[#1] commands/${cmd}.md holds the shared --gates rule and a shim line that maps closed to gates_on`, () => {
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 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');
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 shim runs bare.
test('every command\'s shim line maps a bare --gates to gates_on and open to auto_running', () => {
// --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(shimNext(gatesShim(cmd, '')), 'gates_on', `${cmd}: bare --gates`);
assert.equal(shimNext(gatesShim(cmd, 'open')), 'auto_running', `${cmd}: --gates open`);
assert.equal(lineGates(gatesLine(cmd, '')), true, `${cmd}: bare --gates`);
assert.equal(lineGates(gatesLine(cmd, 'open')), false, `${cmd}: --gates open`);
}
});

View file

@ -25,7 +25,6 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
import { resolveProfile, loadProfile } from '../../lib/profiles/resolver.mjs';
import { STATES } from '../../lib/util/autonomy-gate.mjs';
import { TREKRESEARCH_ALLOWED } from '../../lib/exporters/field-allowlist.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
@ -395,11 +394,11 @@ test('operations.md custom-profile prose matches findProfilePath resolution orde
// --- S24 — anti-false-claim: operations.md + command-modes.md autonomy-gate truth-pass ---
//
// autonomy-gate.mjs is a BOOLEAN gate: --gates [true|false|closed|open] → gates_on|auto_running.
// State machine: idle → gates_on|auto_running → paused_for_gate → completed.
// autonomy-gate.mjs is the --gates flag parser: --gates [true|false|closed|open]
// → {"ok":true,"gates":true|false}. It has no state machine (R9).
// The old prose (operations.md:15 / command-modes.md gates rows) (1) fabricated a
// state machine `idle → approved → executing → merge-pending → main-merged` — only
// `idle` exists in autonomy-gate.mjs STATES; (2) claimed event-emit.mjs "records
// state machine `idle → approved → executing → merge-pending → main-merged`, and
// later named the removed one (gates_on, auto_running, paused_for_gate); (2) claimed event-emit.mjs "records
// each transition" — it emits 3 named lifecycle events and is decoupled from the
// (pure, no-I/O) autonomy-gate; (3) advertised `--gates {open|closed|adaptive}` —
// there is no strictness policy and no `adaptive` value: the flag is boolean, and
@ -409,21 +408,19 @@ test('operations.md + command-modes.md describe the real autonomy-gate, not a fa
const ops = read('docs/operations.md');
const modes = read('docs/command-modes.md');
// F1 — fabricated state names (merge-pending/main-merged are unique to the false claim)
for (const bad of ['merge-pending', 'main-merged']) {
// F1 — state names: fabricated (merge-pending/main-merged) or removed with the state machine (R9)
for (const bad of ['merge-pending', 'main-merged', 'gates_on', 'auto_running', 'paused_for_gate']) {
assert.ok(
!ops.includes(bad),
`docs/operations.md: fabricated autonomy state "${bad}" — autonomy-gate.mjs has no such state; fix the prose, not this test`,
);
}
// F1 — the real transient states (derived from autonomy-gate.mjs STATES) must be documented
for (const real of [STATES.GATES_ON, STATES.AUTO_RUNNING, STATES.PAUSED_FOR_GATE]) {
assert.ok(
ops.includes(real),
`docs/operations.md must name the real autonomy-gate state "${real}" (from autonomy-gate.mjs STATES)`,
);
}
// F1 — the module is documented as what it is: the flag parser
assert.ok(
ops.includes('`lib/util/autonomy-gate.mjs` is the `--gates` flag parser'),
'docs/operations.md must describe lib/util/autonomy-gate.mjs as the --gates flag parser',
);
// F2 — event-emit does NOT record each autonomy-gate transition
assert.ok(

View file

@ -1,7 +1,7 @@
// tests/lib/gates-flag-coverage.test.mjs
// Step 11 (plan-v2) — pin that all four pipeline commands document the
// --gates autonomy-control flag and consume the autonomy-gate state
// machine via the lib/util/autonomy-gate.mjs CLI shim.
// --gates autonomy-control flag and map its value through the
// lib/util/autonomy-gate.mjs flag parser.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
@ -34,7 +34,7 @@ for (const cmdPath of COMMANDS) {
const text = read(cmdPath);
assert.ok(
text.includes('autonomy-gate.mjs'),
`${cmdPath} should reference lib/util/autonomy-gate.mjs as the state-machine implementation`,
`${cmdPath} should reference lib/util/autonomy-gate.mjs, the --gates flag parser`,
);
});
}
@ -43,6 +43,6 @@ test('commands/trekexecute.md mentions MAIN_MERGE_GATE', () => {
const text = read('commands/trekexecute.md');
assert.ok(
text.includes('MAIN_MERGE_GATE'),
'commands/trekexecute.md should name MAIN_MERGE_GATE — the only boundary that always pauses regardless of --gates',
'commands/trekexecute.md should name MAIN_MERGE_GATE — the lock on the review verdict before the merge into main; the guard is what asks before merge and push, whatever --gates says',
);
});