#!/usr/bin/env bun /** * gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2). * * gstack-autoplan next --out [--plan ] [--plan-heading ] [--ui|--no-ui] [--developer-tool] [--agent-primary] * [--no-outside] [--light] [--spend-cap ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json] * gstack-autoplan submit --out --phase

--voice native|outside --result --model --attempt * [--runner ] [--usage-usd ] [--reconcile ] [--json] * gstack-autoplan run --out

--plan --native-model --outside-model [--runner codex-cli|api] [--native-runner api] * [--deadline ] [--spend-cap ] [--estimate-usd ] [--json] next in a loop; needs runners for both voices * gstack-autoplan answer --out --gate-rev --reply "" [--json] * gstack-autoplan resume --out [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN * gstack-autoplan status --out [--json] query: the manifest, no GSTACK_RESULT line * gstack-autoplan export --out [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes * gstack-autoplan release --out --attempt drop a reservation for an attempt that never ran * gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2) * * Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer), * lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts. * Workflow commands end with GSTACK_RESULT: skill=autoplan status= run=; the run is never approved here. */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { answerGate } from '../lib/autoplan-gate'; import { exportRun } from '../lib/autoplan-export'; import { FILES, RunError, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } from '../lib/autoplan-run'; import type { Phase } from '../lib/autoplan-prompts'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voice-runner'; import { resultLine } from '../lib/result-codes'; import { release } from '../lib/spend-ledger'; import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract'; const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md) next --out [--plan ] [--plan-heading ] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-outside] [--light] [--spend-cap ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json] --light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything prepare the current phase; one ATTEMPT: line per voice; ends awaiting_result submit --out --phase

--voice native|outside --result --model --attempt [--runner ] [--usage-usd ] [--reconcile ] [--json] bind one reviewer result (receipt, canonical findings, model family) exactly once; closes the phase when both voices are in run --out

--plan --native-model --outside-model [--runner codex-cli|api] [--native-runner api] ... next in a loop, only when a runner executes both voices here; --deadline cancels the children it started answer --out --gate-rev --reply "" [--json] resume --out [--json] status --out [--json] export --out release --out --attempt contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word) The first next needs --plan. The native voice is dispatched by the parent from the printed prompt file (a subagent on the parent's model); the outside voice runs through gstack-outside-voice or a cross-family subagent. Spend by host subagents is unknown. The first next reads codex_reviews: disabled (or an unreadable config) records every phase's outside voice as a terminal not_run consent_missing outcome and dispatches none. Enable: gstack-config set codex_reviews enabled. ${exitTableHelp()}`; function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } function usage(message: string): number { process.stderr.write(`gstack-autoplan: ${message}\n${HELP}\n`); return EXIT.usage; } function num(args: string[], name: string): number | undefined { const v = flag(args, name); if (v === undefined) return undefined; const n = Number(v); if (!Number.isFinite(n) || n < 0) throw new RunError('ARTIFACT_SCHEMA', `${name} needs a non-negative number`, 1); return n; } const resultLineFor = (status: string, out: string) => `GSTACK_RESULT: skill=autoplan status=${status} run=${out}`; function emit(json: boolean, lines: string[], payload: Record): void { if (json) process.stdout.write(JSON.stringify(payload) + '\n'); else process.stdout.write(lines.join('\n') + '\n'); } function doNext(out: string, rest: string[], json: boolean): number { const cwd = process.cwd(); if (!fs.existsSync(path.join(out, FILES.run))) { const plan = flag(rest, '--plan'); if (!plan) throw new RunError('RUN_NOT_INITIALIZED', `${out} has no run.json; pass --plan on the first next`, 3); const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined; initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary') } }); } const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : [])); const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch }); const status = r.state.status; const lines = [...r.lines]; if (status === 'gate_pending') lines.push(`GATE: gate_rev=${r.state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`); if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`); lines.push(resultLineFor(status, out)); emit(json, lines, { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, out }); return EXIT.ok; } function doSubmit(out: string, rest: string[], json: boolean): number { const phase = flag(rest, '--phase'); const voice = flag(rest, '--voice'); const result = flag(rest, '--result'); const model = flag(rest, '--model'); const attempt = flag(rest, '--attempt'); if (!phase || !voice || !result || !model || !attempt) return usage('submit needs --phase --voice --result --model --attempt'); if (voice !== 'native' && voice !== 'outside') return usage('--voice must be native or outside'); const r = submitResult({ out, phase, voice, result: path.resolve(result), model, attempt, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() }); const status = r.state.status; const lines = [...r.lines]; if (status === 'gate_pending') lines.push(`GATE: gate_rev=${r.state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`); if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`); lines.push(resultLineFor(status, out)); emit(json, lines, { run: r.state.run, status, phase, voice, attempt, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out }); return EXIT.ok; } async function doRun(out: string, rest: string[], json: boolean): Promise { const nativeModel = flag(rest, '--native-model'); const outsideModel = flag(rest, '--outside-model'); const outsideRunner = (flag(rest, '--runner') ?? 'codex-cli') as RunnerId; const nativeRunner = (flag(rest, '--native-runner') ?? 'api') as RunnerId; if (!nativeModel || !outsideModel) return usage('run needs --native-model and --outside-model (a runner must execute both voices)'); if (nativeRunner !== 'api') return usage('--native-runner must be api: the only runner that can execute the native voice here'); if (!['codex-cli', 'api'].includes(outsideRunner)) return usage('--runner must be codex-cli or api for run (host-subagent cannot execute here; use next/submit)'); const conflict = familyConflict(outsideModel, nativeModel); if (conflict) throw new RunError('MODEL_FAMILY_CONFLICT', conflict, 3); const deadline = num(rest, '--deadline'); const deadlineAt = deadline ? Date.now() + deadline * 60_000 : undefined; for (let guard = 0; guard < 40; guard++) { const code = withRunLock(out, () => doNext(out, rest, true)); if (code !== EXIT.ok) return code; const state = readState(out); if (state.status !== 'awaiting_result') { if (!json) process.stdout.write(statusLines(state).join('\n') + '\n'); process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok; } const phase = state.phase_order.find(p => state.phases[p]?.snapshot && !state.phases[p]?.closed_at)!; for (const voice of ['native', 'outside'] as const) { const v = state.phases[phase]![voice]!; const open = v.attempts?.find(a => a.state === 'dispatched'); if (!open) continue; const runnerId = voice === 'native' ? nativeRunner : outsideRunner; const runner = createRunner(runnerId); const remaining = deadlineAt ? deadlineAt - Date.now() : 300_000; if (remaining <= 0) { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; } const timer = deadlineAt ? setTimeout(() => runner.cancel(), remaining) : undefined; const res = await runner.run({ promptFile: open.prompt, outFile: `${open.result}.raw`, model: voice === 'native' ? nativeModel : outsideModel, timeoutMs: Math.min(remaining, 600_000), cwd: process.cwd(), gate: 'review' }); if (timer) clearTimeout(timer); if (!json) process.stdout.write(`VOICE: ${phase} ${voice} ${res.status} runner=${res.runner} model=${res.model ?? 'unknown'}\n`); if (res.status === 'cancelled') { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; } if (res.status !== 'completed' && res.status !== 'unverified') { if (voice === 'outside') { withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} ${res.status}${res.detail ? `: ${res.detail}` : ''}`)); continue; } process.stderr.write(`gstack-autoplan run: native voice ${res.status}${res.detail ? `: ${res.detail}` : ''}\n`); withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; } withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? (voice === 'native' ? nativeModel : outsideModel), '--attempt', open.attempt, '--runner', res.runner], true)); } } return EXIT.fail; } async function main(argv: string[]): Promise { const [cmd, ...rest] = argv; const json = rest.includes('--json'); if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } if (cmd === 'contract') { process.stdout.write(COORDINATOR_CONTRACT + '\n'); return EXIT.ok; } const outFlag = flag(rest, '--out'); if (!outFlag) return usage(`${cmd} needs --out `); const out = path.resolve(outFlag); try { if (cmd === 'next') return withRunLock(out, () => doNext(out, rest, json)); if (cmd === 'submit') return withRunLock(out, () => doSubmit(out, rest, json)); if (cmd === 'run') return await doRun(out, rest, json); if (cmd === 'status') { const state = readState(out); if (json) process.stdout.write(JSON.stringify(state) + '\n'); else process.stdout.write(statusLines(state).join('\n') + '\n'); return EXIT.ok; } if (cmd === 'resume') { return withRunLock(out, () => { const r = resumeRun(out); const lines = [...r.lines, resultLineFor(r.state.status, out)]; emit(json, lines, { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out }); return EXIT.ok; }, { steal: true }); } if (cmd === 'answer') { const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); if (reply === undefined) return usage('answer needs --reply ""'); if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; } return withRunLock(out, () => { const state = readState(out); const r = answerGate(state, Number(rev), reply); writeState(state); const lines = [...r.lines]; if (r.outcome === 'reopened') lines.push(`NEXT: gstack-autoplan next --out ${out}`); lines.push(resultLineFor(state.status, out)); emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, gate_rev: state.gate_rev, out }); return EXIT.ok; }); } if (cmd === 'export') { return withRunLock(out, () => { const state = readState(out); const r = exportRun(state); writeState(state); for (const w of r.warnings) process.stderr.write(`gstack-autoplan export: ${w}\n`); emit(json, r.files.map(f => `EXPORTED: ${f}`), { run: state.run, files: r.files, warnings: r.warnings }); return EXIT.ok; }); } if (cmd === 'release') { const attempt = flag(rest, '--attempt'); if (!attempt) return usage('release needs --attempt '); const ledger = release(path.join(out, FILES.spend), attempt); process.stdout.write(`RELEASED: ${attempt} reserved=${ledger.reserved.length}\n`); return EXIT.ok; } return usage(`unknown command ${cmd}`); } catch (e: any) { if (e instanceof RunError) { process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n'); if (['next', 'submit', 'answer', 'resume', 'run'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n'); return e.exit === 3 ? EXIT.refused : EXIT.fail; } process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`); return EXIT.fail; } } process.exitCode = await main(process.argv.slice(2));