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:
parent
b1e161116a
commit
645f01625b
6 changed files with 602 additions and 2 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
129
plugins/ultraplan-local/lib/util/autonomy-gate.mjs
Normal file
129
plugins/ultraplan-local/lib/util/autonomy-gate.mjs
Normal file
|
|
@ -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 <state> --event <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);
|
||||
}
|
||||
147
plugins/ultraplan-local/tests/lib/autonomy-gate.test.mjs
Normal file
147
plugins/ultraplan-local/tests/lib/autonomy-gate.test.mjs
Normal 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:/);
|
||||
});
|
||||
|
|
@ -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`);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue