Files
gstack/bin/gstack-autoplan
T
Garry Tanandcapy-ai[bot] 5d647455f2 v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E) (#3123)
* v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(spec,review): fail closed when the coordinator contract cannot be read; restore the omitted-Army sentence

The spec --execute spawn captures `gstack-autoplan contract` before piping
the archive, so a lane never starts without the contract; the C9 fixture
installs its gstack-like checkout (bin, lib, a contract stub) at every
host's global root. Review Step 5.8 keeps the pinned
'without claiming specialist coverage' wording.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-11 03:10:06 +00:00

221 lines
15 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2).
*
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary]
* [--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
* gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>
* [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
* gstack-autoplan run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api]
* [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--json] next in a loop; needs runners for both voices
* gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" [--json]
* gstack-autoplan resume --out <dir> [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN
* gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line
* gstack-autoplan export --out <dir> [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes
* gstack-autoplan release --out <dir> --attempt <id> 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=<s> run=<dir>; 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 <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary]
[--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--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 <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>
[--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
bind one reviewer result (receipt, canonical findings, model family) exactly once; closes the phase when both voices are in
run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--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 <dir> --gate-rev <rev> --reply "<all | d3b uc1a | all except d3b>" [--json]
resume --out <dir> [--json] status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
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.
${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<string, unknown>): 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 <file> 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<number> {
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<number> {
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 <dir>`);
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 "<text>"');
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 <id>');
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));