mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
221 lines
15 KiB
TypeScript
Executable File
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));
|