mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-14 20:37:14 +01:00
* fix(eval-plan): per-arm pilot projection and --seat-metered arms (#3653) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): guard tolerates Claude Code's cwd-reset footer and recovers after an unreadable init (#3651) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (journal reader strips the cwd-reset footer; an init with no text block no longer locks the session) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: journal reader skips a failed or unreadable init, as invocation() does Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): --ceo-only runs the CEO review alone with zero questions (GST-537) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(eval-plan): all-seat pilot, single-attempt arm and --seat-metered CLI wiring Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(eval-plan): summed deviation adds arm deviations, matching the summed band (arms share rows) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (test pins an unpriced seat-metered arm) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (seat-metered declaration fixed per --out, price row still required, a real charge settles as spent) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: classify autoplan-ceo-only in the Codex host metadata Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a seat-metered arm that settles a charge is projected as a priced arm Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: select autoplan-ceo-only on the skills it runs; say it needs Claude Code Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(skills): /eval-plan --seat-metered and per-arm projection Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(eval-plan): skill prose stays under its context budget and carries no $N token Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: run autoplan-ceo-only in the marathon lane Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(skills): a seat-metered arm leaves the seat set once it settles a charge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog entry for /autoplan --ceo-only Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): init_failed covers unreadable init output and the cwd-reset line (#3651) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: count the autoplan-ceo-only paid file in the shard inventory Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(autoplan): #3651 tests pass the test-code type ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(dx): CAP finding titles carry a one-line description (GBRA-223 claim 2) The DX error contract told both autoplan DX voices to title capped findings exactly CAP4:<path>:<item>, so gates, tasks and reconcile saw a bare code. The title is now CAP4:<path>:<item> <what fails>; the grader already parses that shape. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): reconcile pairs same-file findings that carry no line (GBRA-223 claim 1) Plan reviews name files, rarely lines, and reconcile associated by location only when both findings had a line, so findings both voices raised on one file were filed new and native-only. A same-file pair with a missing line is now associated as unverified (basis file), never confirmed; a CAP code no longer counts in title comparison. DRIVER.md names submit --reconcile. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): open Critical and High findings reach the gate as apply items (GBRA-223 claim 3) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a spend cap binds on every unattended host; --estimate-usd persists; DRIVER names --usage-usd (GBRA-223 claim 4) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): run binds complete api results, names a truncated response, --max-tokens; DRIVER documents run (GBRA-223 claim 6) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): carry an item-by-item answer when the later gate asks nothing new (GBRA-223 claim 7) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(dx): trim the compact error contract so plan-devex-review stays under its size pin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (p1b applies finding fixes, api attempts reserve no host default, cross-file title pairing, paired severity counted) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: review fixes (priced api reservations, no file-only twins, two-pass pairing, partial replies keep p1-dependent defaults open, outside refusal degrades) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * P7: record whose words answered a gate (--words-by agent:<id>), never carry an agent's own words, import each row under its own answerer (GEVA-2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: review pass 2 (p1b needs --rec-hash after recommend; api calls send the output limit they reserve for) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: fragment for the /autoplan field-report wave (GST-672) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: captain record notes --words-by agent attribution Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow finding apply items in approval-class and Captain flow tests; bind run-api limit to DEFAULT_MAX_TOKENS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wave autoplan: lower the gemini plan-devex-review pin to its exact size after #3781's DX trim Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): CAP titles on different paths never match by title in reconcile or confirm rounds (wave review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a truncated api call settles its cost and a retry is admitted afresh against the cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): an applied finding stays open until the revised plan is reviewed; a shared location alone never twins two findings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(approval): a plan gate answered in an agent's own words is not owner approval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gate): a new answer replaces the earlier answer's authorship and words Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a failed api call with no usage keeps its attempt open; reservations price the bound prompt (wave fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a spend cap fails closed: only a call refused before sending keeps its attempt; a sent call with unknown usage settles at its reservation Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): the 16384 output default applies to both providers; changelog for the wave's integrator fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): how run settles a failed api call; changelog scopes it to the native voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a spend cap fails closed on every exit of run; an unpriced model reserves the default once a cap is set Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): every sent call settles; an unpriced model under a cap reserves the default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): DRIVER and help say a cap reserves in any session kind and how run settles a sent call 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>
143 lines
10 KiB
TypeScript
143 lines
10 KiB
TypeScript
/**
|
|
* autoplan-state — the unattended runner's on-disk state (plan B2): the
|
|
* RunState shape (the B0 run manifest plus the runner's own fields), the
|
|
* typed RunError every command maps to a result code and exit code, the fixed
|
|
* file names, the append-only attempts journal, atomic run.json writes, and
|
|
* the per-voice reads the gate and export share. No command logic lives here.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import type { Phase, Scope } from './autoplan-prompts';
|
|
import type { DecisionRow, FindingRow, RunManifest, VoiceOutcome } from './headless-artifacts';
|
|
import { readJsonl } from './headless-artifacts';
|
|
import type { ResultCodeName } from './result-codes';
|
|
|
|
export type Voice = 'native' | 'outside';
|
|
export interface AttemptState { attempt: string; state: 'dispatched' | 'submitted' | 'execution_unknown' | 'cancelled' | 'superseded' | 'deferred'; prompt: string; result: string; estimate_usd: number; dispatched_at: string; terminal_at?: string; /** `run` set it before the paid call went out; an open attempt still carrying it was in flight when its process died. */ sent_at?: string }
|
|
export interface VoiceState extends VoiceOutcome {
|
|
attempt?: string; attempts?: AttemptState[]; result_sha256?: string; findings?: number; label?: string; usage?: Record<string, unknown>; detail?: string;
|
|
/** verified: the INPUT receipt matched the snapshot; attested: a --record bound without one, on the parent's word (attest_reason). */
|
|
receipt?: 'verified' | 'attested'; attest_reason?: string; findings_source?: 'fence' | 'table' | 'list' | 'none'; reviewed_sha256?: string;
|
|
}
|
|
/** An accepted plan change (gate option with changes_plan) the plan must absorb before the run continues. */
|
|
export interface Amendment {
|
|
decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn' | 'settled'; at: string; settled_at?: string;
|
|
/** The gate item's `changes` and phase: packaging/ordering changes settle without a plan edit (`revise --settle`). */
|
|
changes?: string; phase?: string; item?: string;
|
|
/** The reviewed Implementation-plan hash of the active plan when the owner accepted it: `--settle` evidence. */
|
|
plan_sha256?: string;
|
|
}
|
|
/** A revise that changed the plan under a phase at its round cap: the phase keeps its review, the delta becomes a gate item. */
|
|
export interface CappedDelta {
|
|
k: number; phase: string; round: number; cap: number; old_plan_sha256: string; new_plan_sha256: string; added: number; removed: number;
|
|
status: 'pending' | 'accepted' | 'reopened'; at: string; decided_at?: string;
|
|
}
|
|
/** Why a run stopped `incomplete` with work left: the structured budget_exhausted stop `resume` lifts after a budget raise. */
|
|
export interface RunStop {
|
|
reason: 'budget_exhausted'; phase?: string; cap_usd: number | null; max_rounds: number | null; rounds: Record<string, number>;
|
|
spent: number; reserved: number; unknown: number; unknown_count: number; next_estimate_usd: number; at: string;
|
|
}
|
|
/** A phase closed accepted-with-findings (`closeDeferred`): its open findings carry into tasks.jsonl and the gate. */
|
|
export interface DeferredClose {
|
|
reason: string; by: string; cause: 'cap' | 'owner'; at: string; round: number; counts: Record<string, number>;
|
|
carried_file?: string; blockers: string[]; open: number;
|
|
}
|
|
export interface PhaseState {
|
|
snapshot?: string; snapshot_dir?: string; snapshot_path?: string; methodology?: string; opened_at?: string; closed_at?: string;
|
|
native?: VoiceState; outside?: VoiceState; consensus?: Record<string, unknown>; skipped?: string;
|
|
prompts?: { native: string; outside: string; nativeResult: string; outsideResult: string };
|
|
sections?: { loaded: string[]; skipped: string[]; checklist: boolean };
|
|
deferred?: DeferredClose;
|
|
/** A confirm round (r > 1): the revision this phase last reviewed. */
|
|
confirm?: { round: number; previous_sha256?: string; added: number; removed: number };
|
|
/** Revisions bound to this phase without re-review: every incorporated decision was below High (lib/autoplan-revise.ts). */
|
|
rebound?: Array<{ k: number; from: string; to: string; decisions: string[]; at: string }>;
|
|
}
|
|
/** Reviewer attempts journaled as dispatched (failed, unknown and superseded ones included), per phase and voice. */
|
|
export interface DispatchCounts { total: number; by_phase: Record<string, Partial<Record<Voice, number>>> }
|
|
export interface RunState extends RunManifest {
|
|
revision: number; out: string; plan_source: string; active_plan: string; restore: string; scope: Scope; phase_order: Phase[];
|
|
phases: Record<string, PhaseState>; native_model?: string; outside_model?: string; outside_enabled: boolean; attempt_counter: number;
|
|
spend_cap_usd: number | null; /** `next --estimate-usd`, kept for every later attempt. */ estimate_usd?: number; deadline_at?: string; light?: boolean; history: Array<Record<string, unknown>>; created_at: string; updated_at: string;
|
|
/** The gstack that last wrote this run (lib/autoplan-migrate.ts); absent on runs written before 1.91.96. */
|
|
gstack_version?: string;
|
|
/** The --plan file the owner edits (drift hints, revise), the --plan-heading it was cut from, and the scope flags revise re-detects with. */
|
|
plan_input?: string; plan_heading?: string; scope_flags?: Record<string, boolean | undefined>; scaffolded?: boolean;
|
|
plan_revision?: number; amendments?: Amendment[];
|
|
plan_reality?: { plan_sha256: string; checker: string; verdict: string; line: string };
|
|
/** Review rounds per phase (null: uncapped); round_grants are the owner's per-phase raises at the gate. */
|
|
max_rounds?: number | null; round_grants?: Record<string, number>;
|
|
stop?: RunStop; stops?: RunStop[]; capped_deltas?: CappedDelta[];
|
|
/** Written by export from attempts.jsonl; est_input_tokens is dispatched prompt bytes / 4, a relative estimate only. Absent on older runs. */
|
|
dispatches?: DispatchCounts; est_input_tokens?: number;
|
|
/** Carry-forward (lib/autoplan-carry.ts): false with --no-carry-forward; the per-gate cap on the delta check; the latest carry decision. */
|
|
carry_forward?: boolean; carry_check_cap_usd?: number; carry?: import('./autoplan-carry').CarryRecord;
|
|
}
|
|
|
|
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
|
|
/** The one terminal-voice predicate `next`, `submit`, `resume` and phase close share: a terminal voice is never dispatched or resurrected. */
|
|
export function voiceTerminal(v: { status?: string } | undefined): boolean {
|
|
return (TERMINAL_VOICE_STATUSES as readonly string[]).includes(String(v?.status));
|
|
}
|
|
export const pendingAmendments = (state: RunState): Amendment[] => (state.amendments ?? []).filter(a => a.status === 'pending');
|
|
export class RunError extends Error {
|
|
/** `status`: the run status the refusal leaves (default refused); `lines`: what the command still reports on stdout. */
|
|
constructor(public code: ResultCodeName, public detail: string, public exit: 1 | 3 = 1, public after: { status?: string; lines?: string[] } = {}) { super(`${code}: ${detail}`); }
|
|
}
|
|
|
|
export const FILES = { run: 'run.json', journal: 'attempts.jsonl', lock: 'run.lock', spend: 'spend.json', active: 'plan-active.md', restore: 'restore.md', source: 'plan-source.md' } as const;
|
|
export const now = () => new Date().toISOString();
|
|
|
|
export function journal(out: string, row: Record<string, unknown>): void {
|
|
fs.appendFileSync(path.join(out, FILES.journal), JSON.stringify({ ts: now(), ...row }) + '\n');
|
|
}
|
|
export function readJournal(out: string): Array<Record<string, any>> {
|
|
const file = path.join(out, FILES.journal);
|
|
if (!fs.existsSync(file)) return [];
|
|
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap(l => { try { return [JSON.parse(l)]; } catch { return []; } });
|
|
}
|
|
export function writeState(state: RunState): void {
|
|
state.revision += 1;
|
|
state.updated_at = now();
|
|
const file = path.join(state.out, FILES.run);
|
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
export function readState(out: string): RunState {
|
|
const file = path.join(out, FILES.run);
|
|
if (!fs.existsSync(file)) throw new RunError('RUN_NOT_INITIALIZED', out, 3);
|
|
const state = JSON.parse(fs.readFileSync(file, 'utf8')) as RunState;
|
|
// The directory may have been moved or copied: paths inside it are re-rooted on every read.
|
|
const previous = state.out;
|
|
state.out = path.resolve(out);
|
|
if (previous && previous !== state.out) {
|
|
const reroot = (p: string | undefined) => (p && p.startsWith(previous + path.sep) ? path.join(state.out, path.relative(previous, p)) : p);
|
|
state.active_plan = reroot(state.active_plan)!; state.restore = reroot(state.restore)!; state.plan_source = reroot(state.plan_source)!; state.plan_input = reroot(state.plan_input);
|
|
for (const ps of Object.values(state.phases)) {
|
|
ps.snapshot_dir = reroot(ps.snapshot_dir); ps.snapshot_path = reroot(ps.snapshot_path); ps.methodology = reroot(ps.methodology);
|
|
if (ps.prompts) for (const k of Object.keys(ps.prompts) as Array<keyof typeof ps.prompts>) ps.prompts[k] = reroot(ps.prompts[k])!;
|
|
for (const v of [ps.native, ps.outside]) for (const a of v?.attempts ?? []) { a.prompt = reroot(a.prompt)!; a.result = reroot(a.result)!; }
|
|
}
|
|
}
|
|
return state;
|
|
}
|
|
|
|
/** The canonical rows one completed voice returned (`<phase>-<voice>.md.findings.jsonl`). */
|
|
export function voiceRows(state: RunState, phase: string, voice: Voice): FindingRow[] {
|
|
const v = state.phases[phase]?.[voice];
|
|
if (v?.status !== 'completed' || !v.output) return [];
|
|
const file = path.join(state.out, `${v.output}.findings.jsonl`);
|
|
return fs.existsSync(file) ? (readJsonl(file).rows as unknown as FindingRow[]) : [];
|
|
}
|
|
export function readDecisions(out: string): DecisionRow[] {
|
|
const file = path.join(out, 'decisions.jsonl');
|
|
return fs.existsSync(file) ? (readJsonl(file).rows as unknown as DecisionRow[]) : [];
|
|
}
|
|
export function writeDecisions(out: string, rows: DecisionRow[]): void {
|
|
const file = path.join(out, 'decisions.jsonl');
|
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
fs.writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''));
|
|
fs.renameSync(tmp, file);
|
|
}
|