feat(ultraplan-local): add autonomy-gate state machine + manifest schema extensions for skip_commit_check + memory_write

Step 4 of plan-v2 (ultra-pipeline-speedup).

lib/util/autonomy-gate.mjs (NEW)
  5-state machine {idle, gates_on, auto_running, paused_for_gate, completed}
  honoring the --gates flag intent. Re-entry to completed is idempotent.
  Includes CLI shim:
    node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false]
  → JSON: { ok, next_state | error }, exit 0 on success / 1 on invalid.

lib/parsers/manifest-yaml.mjs (EXTENDED)
  OPTIONAL_KEYS list adds skip_commit_check and memory_write — both boolean,
  default false when absent, MANIFEST_OPTIONAL_TYPE when non-boolean.
  Existing REQUIRED_KEYS contract untouched; existing 9 manifest tests
  still pass.

Tests: 19 (autonomy-gate) + 8 (manifest-schema-extensions) = 27 new.

[skip-docs]
This commit is contained in:
Kjell Tore Guttormsen 2026-05-04 06:28:47 +02:00
commit 645f01625b
6 changed files with 602 additions and 2 deletions

View file

@ -0,0 +1,147 @@
// 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.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transition, isTerminal, STATES, EVENTS } from '../../lib/util/autonomy-gate.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'autonomy-gate.mjs');
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() ?? '' };
}
}
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:/);
});

View file

@ -0,0 +1,92 @@
// tests/lib/manifest-schema-extensions.test.mjs
// Cover the OPTIONAL_KEYS extension to lib/parsers/manifest-yaml.mjs:
// - skip_commit_check (boolean, default false)
// - memory_write (boolean, default false)
//
// Defaults must NOT break the REQUIRED_KEYS contract.
// Non-boolean values must produce MANIFEST_OPTIONAL_TYPE error.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { parseManifest, OPTIONAL_KEYS } from '../../lib/parsers/manifest-yaml.mjs';
const BASE = `### Step 1: Cover
- Manifest:
\`\`\`yaml
manifest:
expected_paths:
- lib/foo.mjs
min_file_count: 1
commit_message_pattern: "^feat:"
bash_syntax_check: []
forbidden_paths: []
must_contain: []`;
function bodyWithExtras(extras) {
return `${BASE}\n${extras}\n \`\`\`\n`;
}
function bodyOnlyRequired() {
return `${BASE}\n \`\`\`\n`;
}
test('OPTIONAL_KEYS exports skip_commit_check + memory_write', () => {
assert.deepEqual(
[...OPTIONAL_KEYS].sort(),
['memory_write', 'skip_commit_check'].sort(),
'OPTIONAL_KEYS export drift — pin contract',
);
});
test('absence of optional keys → defaults to false (both fields)', () => {
const r = parseManifest(bodyOnlyRequired());
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.skip_commit_check, false);
assert.equal(r.parsed.memory_write, false);
});
test('skip_commit_check: true honored', () => {
const r = parseManifest(bodyWithExtras(' skip_commit_check: true'));
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.skip_commit_check, true);
assert.equal(r.parsed.memory_write, false);
});
test('memory_write: true honored', () => {
const r = parseManifest(bodyWithExtras(' memory_write: true'));
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.memory_write, true);
assert.equal(r.parsed.skip_commit_check, false);
});
test('both optional fields together — both honored', () => {
const r = parseManifest(bodyWithExtras(' skip_commit_check: true\n memory_write: true'));
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.skip_commit_check, true);
assert.equal(r.parsed.memory_write, true);
});
test('skip_commit_check: non-boolean rejected with MANIFEST_OPTIONAL_TYPE', () => {
const r = parseManifest(bodyWithExtras(' skip_commit_check: "yes"'));
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE');
assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`);
assert.match(found.message, /skip_commit_check/);
});
test('memory_write: numeric rejected with MANIFEST_OPTIONAL_TYPE', () => {
const r = parseManifest(bodyWithExtras(' memory_write: 1'));
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE');
assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`);
assert.match(found.message, /memory_write/);
});
test('extension does NOT break REQUIRED_KEYS contract', () => {
const r = parseManifest(bodyOnlyRequired());
assert.equal(r.valid, true);
for (const k of ['expected_paths', 'min_file_count', 'commit_message_pattern',
'bash_syntax_check', 'forbidden_paths', 'must_contain']) {
assert.ok(k in r.parsed, `required key ${k} missing after extension`);
}
});