diff --git a/lib/parsers/manifest-yaml.mjs b/lib/parsers/manifest-yaml.mjs index f9f1d97..d1cb24d 100644 --- a/lib/parsers/manifest-yaml.mjs +++ b/lib/parsers/manifest-yaml.mjs @@ -20,6 +20,19 @@ const REQUIRED_KEYS = [ 'must_contain', ]; +// Optional manifest keys (plan-v2 Step 4). Absence == false. +// `skip_commit_check`: opt out of the per-step commit assertion (e.g. memory-only steps). +// `memory_write` : marks a step that writes to ~/.claude/projects/.../memory/ +// so the executor can route it through the memory truth gate. +const OPTIONAL_KEYS = [ + 'skip_commit_check', + 'memory_write', +]; + +const OPTIONAL_BOOLEAN_KEYS = new Set(OPTIONAL_KEYS); + +export { OPTIONAL_KEYS }; + /** * Extract the first fenced YAML block whose first non-blank line begins with * `manifest:`. @@ -84,6 +97,19 @@ export function parseManifest(stepBody) { errors.push(issue('MANIFEST_COUNT_TYPE', 'min_file_count must be a number')); } + for (const k of OPTIONAL_BOOLEAN_KEYS) { + if (k in parsed) { + if (typeof parsed[k] !== 'boolean') { + errors.push(issue( + 'MANIFEST_OPTIONAL_TYPE', + `${k} must be boolean if present (got ${typeof parsed[k]})`, + )); + } + } else { + parsed[k] = false; // default: absence == false + } + } + return { valid: errors.length === 0, errors, warnings, parsed }; } diff --git a/lib/util/autonomy-gate.mjs b/lib/util/autonomy-gate.mjs new file mode 100644 index 0000000..314899c --- /dev/null +++ b/lib/util/autonomy-gate.mjs @@ -0,0 +1,129 @@ +// lib/util/autonomy-gate.mjs +// Autonomy-gate state machine for /ultraexecute-local + /ultraplan-local +// (plan-v2 Step 4 — drives the --gates flag). +// +// States: +// idle — not yet started +// gates_on — gates enabled, between phases +// auto_running — running phases continuously without pausing +// paused_for_gate — stopped at a phase boundary; awaiting `resume` +// completed — terminal +// +// Events: +// start — begin a run (gates flag chooses route) +// phase_boundary — a phase finished +// resume — operator confirmed; leave the gate +// finish — pipeline reached its end +// +// CLI shim: +// node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false] +// → JSON: { ok: true, next_state: "..." } (success) +// → JSON: { ok: false, error: "..." } (invalid transition; exit 1) +// +// Pure data; no I/O. Re-entry to `completed` is idempotent. + +export const STATES = Object.freeze({ + IDLE: 'idle', + GATES_ON: 'gates_on', + AUTO_RUNNING: 'auto_running', + PAUSED_FOR_GATE: 'paused_for_gate', + COMPLETED: 'completed', +}); + +export const EVENTS = Object.freeze({ + START: 'start', + PHASE_BOUNDARY: 'phase_boundary', + RESUME: 'resume', + FINISH: 'finish', +}); + +const STATE_SET = new Set(Object.values(STATES)); +const EVENT_SET = new Set(Object.values(EVENTS)); + +/** + * Compute the next state given the current state, event, and (optional) + * gates-flag intent (only consulted on `start` from `idle`). + * + * @param {string} state + * @param {string} event + * @param {{ gates?: boolean }} [opts] + * @returns {{ ok: true, next_state: string } | { ok: false, error: string }} + */ +export function transition(state, event, opts = {}) { + if (!STATE_SET.has(state)) { + return { ok: false, error: `unknown state: ${state}` }; + } + if (!EVENT_SET.has(event)) { + return { ok: false, error: `unknown event: ${event}` }; + } + + // completed is terminal & idempotent + if (state === STATES.COMPLETED) { + return { ok: true, next_state: STATES.COMPLETED }; + } + + if (state === STATES.IDLE) { + if (event === EVENTS.START) { + const gates = opts.gates === true; + return { ok: true, next_state: gates ? STATES.GATES_ON : STATES.AUTO_RUNNING }; + } + return { ok: false, error: `invalid transition: idle + ${event} (only \`start\` allowed from idle)` }; + } + + if (state === STATES.GATES_ON) { + if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.PAUSED_FOR_GATE }; + if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED }; + return { ok: false, error: `invalid transition: gates_on + ${event}` }; + } + + if (state === STATES.AUTO_RUNNING) { + if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.AUTO_RUNNING }; + if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED }; + return { ok: false, error: `invalid transition: auto_running + ${event}` }; + } + + if (state === STATES.PAUSED_FOR_GATE) { + if (event === EVENTS.RESUME) return { ok: true, next_state: STATES.GATES_ON }; + if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED }; + return { ok: false, error: `invalid transition: paused_for_gate + ${event}` }; + } + + return { ok: false, error: `unhandled state: ${state}` }; +} + +/** + * Convenience: is this state terminal? + */ +export function isTerminal(state) { + return state === STATES.COMPLETED; +} + +// ---- CLI shim ---------------------------------------------------------------- + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--state') out.state = argv[++i]; + else if (a === '--event') out.event = argv[++i]; + else if (a === '--gates') { + const v = argv[++i]; + out.gates = v === 'true'; + } + } + return out; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = parseArgs(process.argv.slice(2)); + if (!args.state || !args.event) { + process.stdout.write(JSON.stringify({ + ok: false, + error: 'usage: autonomy-gate.mjs --state --event [--gates true|false]', + }) + '\n'); + process.exit(1); + } + const result = transition(args.state, args.event, { gates: args.gates }); + process.stdout.write(JSON.stringify(result) + '\n'); + process.exit(result.ok ? 0 : 1); +} diff --git a/tests/lib/autonomy-gate.test.mjs b/tests/lib/autonomy-gate.test.mjs new file mode 100644 index 0000000..3bb77e6 --- /dev/null +++ b/tests/lib/autonomy-gate.test.mjs @@ -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:/); +}); diff --git a/tests/lib/manifest-schema-extensions.test.mjs b/tests/lib/manifest-schema-extensions.test.mjs new file mode 100644 index 0000000..fa326f7 --- /dev/null +++ b/tests/lib/manifest-schema-extensions.test.mjs @@ -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`); + } +});