ktg-plugin-marketplace/plugins/voyage/lib/util/autonomy-gate.mjs
Kjell Tore Guttormsen 7a90d348ad feat(voyage)!: marketplace handoff — rename plugins/ultraplan-local to plugins/voyage [skip-docs]
Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope.

- git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved)
- .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local
- CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list
- README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph
- plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path
- plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed)

Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
2026-05-05 15:37:52 +02:00

129 lines
4.3 KiB
JavaScript

// lib/util/autonomy-gate.mjs
// Autonomy-gate state machine for /trekexecute + /trekplan
// (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);
}