mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): scope keys on rendered surfaces, per-axis provenance, --no-dx and a read-only plan marker
UI scope reads the Implementation plan only and needs a rendered file path or a UI
term next to a visual noun; bare UI words stay weak evidence (sections load, no
Design phase). Scope records sources {ui,dx} (flag|marker|detected), --no-dx turns
DX off, a first-line <!-- gstack-autoplan-scope: ... --> marker is read and never
written, and the first next prints SCOPE: evidence plus a SCOPE_PIN hint. The
snapshot tool's scope output carries uiRequired from the same detector and
autoplan Step 2 uses it instead of a prose grep (field wave item 11).
Intentional pin change: test/review-checklists.test.ts Scope literal and the
source= line shape (source=ui:detected,dx:detected).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(plan-reality): a receipt per measured number, the rule text for a rule claim, dot-path receipts
Each clause stating a measured quantity (a unit, or a number after measured/costs/
takes/took) needs its own receipt; misses print unreceipted_numbers=<n> and one
NUMBER_UNRECEIPTED: <clause> line each. A line claiming a decision rule must quote
it or link its file:line (unquoted=<n>). Either miss maps to the existing verdict
(incomplete unattended, repair otherwise). RECEIPT_RE accepts dot paths and needs
a hex letter or a commit/backtick context for a sha. New pure checkPlanReality(planText)
runs the same checks over plan prose for gstack-autoplan submit (field wave item 12).
The unreceipted= text token is now pinned as well as the JSON.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(autoplan): generated DRIVER.md, the compact entry for a parent driving gstack-autoplan
autoplan/DRIVER.md (from autoplan/DRIVER.md.tmpl) is what a parent agent on any host
reads instead of autoplan/SKILL.md: phase order and scope flags, the next/submit/
answer/revise/export loop with the plan-specified submit --record/--attest/
--unavailable, revise --incorporates/--withdraw and next --scaffold, the reviewer
RESULT FORMAT, the gate grammar including all as-is, the run files, the result codes
and the coordinator contract, every list rendered from code (117 lines). gen:skill-docs
emits it as a Claude asset, so normal, out-dir and dry-run inventories and the
freshness check cover it. gstack-autoplan --help and the first next print
DRIVER: <root>/autoplan/DRIVER.md, resolved through an install root's recorded
.source-path (new lib/source-root.ts) (field wave item 13).
Intentional pin change: test/gen-skill-docs-checks.test.ts asset inventory gains
autoplan/DRIVER.md.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(doctor): --min-revision, a launch Bun found off PATH, planning-only pins SKIP
gstack-doctor --check gains --min-revision <v>|unknown: below it the revision
row FAILs with REVISION_BELOW_MINIMUM naming the Capy wrapper or
bin/gstack-upgrade; unknown says so and never claims the minimum. The runtime
and pins rows name a supported Bun found in $BUN_INSTALL/bin (not on PATH)
with the export/GSTACK_LAUNCH fix (gstack_bun_launch_dir, one helper in
bin/gstack-bun-version.sh). --for a planning-only workflow (autoplan, the plan
reviews, office-hours) reports a repo test-pin mismatch as SKIP pins
(planning-only; ...). New result codes UPGRADE_REFUSED, UPGRADE_FETCH_FAILED,
REVISION_BELOW_MINIMUM, CAPY_WRAPPER_STALE, INSTALL_UNVERIFIED with
troubleshooting anchors; REVISION_UNMET's fix names bin/gstack-upgrade.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(install): install verdict and install admission at skill start
bin/gstack-install-verdict.sh (Bash) and lib/install-verdict.ts (TypeScript)
are the one reader of the install verdict gstack-capy-install writes to
<state root>/install-verdicts/<sha256(checkout realpath)>.json (checkout,
HEAD, workflow and required components, planning-only pins, trailer). The
source checkout and capy/support.env resolve through gstack_source_root /
sourceRoot, which follow an installed root's .source-path.
gstack-skill-start, after SESSION_KIND: an unattended session (any host)
refuses on a PATH Bun below the supported minimum; with GSTACK_CAPY=1 an
unattended or explicit spawned session also refuses without a passing
verdict for this checkout, HEAD and workflow (INSTALL_UNVERIFIED). A refusal
prints the INSTALL: row, an install-refused instruction block bound to its
SESSION_ID, GSTACK_RESULT ... status=refused run=- and exits 3. Ambient
spawned sessions warn; GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and says so.
capy/support.env declares GSTACK_MIN_REVISION and GSTACK_CAPY_MIN_WRAPPER.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(capy-install): launch PATH, Bun reuse and CI pins, setup skip on HEAD, verdict, --check
The GSTACK_LAUNCH line carries PATH=<dir>:$PATH when the certified Bun is not
first on PATH, plus GSTACK_CAPY=1 and GSTACK_INSTALL_VERDICT, and
GSTACK_LAUNCH_PATH: <dir>; the doctor runs with that PATH. A supported Bun in
$BUN_INSTALL/bin is reused instead of downloaded again. Project pins are read
with bin/gstack-runtime-pins.ts (CI lanes included): when every exact pin is
gstack's verified version the archive is installed alongside a different PATH
Bun; any other exact pin is RUNTIME_PIN_CONFLICT. Both ./setup runs are
skipped when this HEAD was set up with the same hosts, model and browser flag
and the check passes (never after a fast-forward); a failed setup ends with
gstack: fail setup and is retried next run. --check certifies only;
GSTACK_CAPY_LOCK_HELD=1 accepts the bootstrapper's held lock; result anchors
come from the generated components table; REV_NOTE names bin/gstack-upgrade.
Intentional pin change: test/gstack-capy-install.test.ts --revision trailer
now names <root>/bin/gstack-upgrade (the old text named a command that never
existed).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* result codes: PLAN_SECTIONS_INVALID, PLAN_AMEND_PENDING, GSTACK_VERSION_CHANGED, INSTALL_VERDICT_FAILED, ARTIFACT_GATE_UNSETTLED
Troubleshooting anchors for each and the regenerated doctor components table (field wave lane B-core).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* artifact contract: changes, recommended_basis, carried decisions, attested count, settled-gate check
Findings and decisions carry the user challenge's `changes` value; decisions record recommended_basis, origin,
carried_from and chosen_text; validate refuses a complete run with an unsettled gate (ARTIFACT_GATE_UNSETTLED) and
prints attested=<n>. The gate reply grammar gains `all as-is` and resolves partial replies against prior answers
(field wave B2a/B2b).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan: run-state fields, plan-text hashing, version migration and admission modules
RunState gains gstack_version, plan_input/heading, scope_flags, plan_revision, amendments and plan_reality; voices
record receipt kind and reviewed hash. New pure modules: Implementation-plan review hash, scaffold and drift lines
(B1c); gstack_version stamp and legacy decision-id migration (B1a); install-verdict and Bun-minimum admission
with a local verdict shim until lane A's lib/install-verdict.ts lands.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
bin/gstack-autoplan-snapshot.ts exports its section scanner and review projection, and initializes only a plan with
no section headings by wrapping it.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan: closable runs - submit --record/--attest/--unavailable, revise, amendment interlock, agreeing-UC defaults, admission
B1a: an older run directory prints GSTACK_VERSION_CHANGED once and migrates legacy decision ids; a newer one refuses.
B1b: submit binds hand-run records (fence, table, severity list, No findings.), attested binds need --attempt,
--attempt is optional with one open attempt, --unavailable/--failed close an outside voice native_only, and submit
caches the plan-reality report by Implementation-plan hash.
B1c: revise reviews a revised plan in the same run as one recoverable transaction (reopen on hash drift plus Eng,
add newly in-scope phases, .r<rev>/.v<k> archives, gate_rev+1 at the gate); PLAN_SECTIONS_INVALID and next --scaffold;
PLAN_CHANGED / PLAN_SOURCE_CHANGED drift hints.
B2a/B2b: user challenges carry changes; agreeing voices with the same changes on a confirmed finding recommend b with
both remedies shown (or the reconcile proposal); accepted changes print APPLY and become pending amendments that next
and export refuse past until revise --incorporates names them or --withdraw drops one; all as-is; partial replies,
DECIDED block, carry-forward and rerun demotion.
Mutating verbs pass admission (GSTACK_CAPY install verdict; unattended Bun minimum; override recorded as a deviation).
Intentional pin rewrite: test/autoplan-answer-invalidation.test.ts reopen tests now revise --incorporates before rerun.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(upgrade): bin/gstack-upgrade, the unattended fast-forward every fix line names
--check fetches and prints current|behind <installed> -> <remote> (<n>
commits); the default fast-forwards to the remote default branch tip and
reinstalls (bin/gstack-capy-install on a Capy install, else ./setup
--refresh-registered), completing a pending reinstall on the next run. It
refuses (UPGRADE_REFUSED, exit 3, nothing changed) a dirty tracked byte, a
diverged commit, a pinned checkout, the incoming Bun floor and a held
.setup-lock; never stashes or resets; an unreachable remote is
UPGRADE_FETCH_FAILED. Fetches are receipted (fail-open). The unattended
skill-end UPGRADE_AVAILABLE line is followed by UPGRADE_COMMAND naming it.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(capy): vendored Capy skill, frozen bootstrapper and full wrapper bin/gstack-capy-session
capy/skill/gstack/ is the Drive skill's source of truth. scripts/install.sh
is a frozen bootstrapper: it prints gstack-capy-skill: wrapper <n> (<path>),
clones or fetches under .gstack-capy-install.lock, reads the remote
capy/support.env (FETCH_HEAD), fast-forwards a clean unpinned checkout below
GSTACK_MIN_REVISION (refusing dirty, diverged or pinned checkouts with the
exact commands; GSTACK_CAPY_AUTO_UPGRADE=0 disables), and execs the
checkout's own bin/gstack-capy-session with GSTACK_CAPY_LOCK_HELD=1 so the
held lock is never taken twice. Offline it says the remote minimum is
unknown. A Drive copy older than GSTACK_CAPY_MIN_WRAPPER prints an owner
action (CAPY_WRAPPER_STALE). SKILL.md: check at session start, install
lazily before the first workflow, the GSTACK_LAUNCH line, the trailer rule,
the subagent first-report line, gstack-pregate sealed before any push, and
the autoplan/DRIVER.md pointer. The context-budget fixture gains the skill's
entry.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: Capy install layers, lazy install, snapshot bake, forward-only recovery, raising the minimum
docs/capy-install.md describes the three layers (Drive bootstrapper, full
wrapper, installer), the lazy install, the GSTACK_LAUNCH line, install
admission, baking gstack into a Capy snapshot, and that recovery after an
interrupted fast-forward completes or refuses the pending install stages
(checking out an older commit is not a rollback). docs/CHANGELOG_STYLE.md
(the release doc; there is no docs/RELEASING.md) says when
GSTACK_MIN_REVISION may be raised.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* refactor(capy): the stale-copy owner action moves from the frozen bootstrapper to bin/gstack-capy-session
The checkout-owned full wrapper reads GSTACK_CAPY_MIN_WRAPPER through
gstack_support_env_get(gstack_source_root) and compares it with the
bootstrapper revision it was handed, so the rule can change without
recopying the Drive skill. The bootstrapper keeps only what a stale checkout
cannot do: clone, fetch, read the remote minimum, fast-forward, exec.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.96.0: honest install and self-upgrade on Capy; supported minimum 1.91.96.0
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.97.0: closable unattended autoplan runs on Capy
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): revise naming a non-pending decision refuses with AMENDMENT_UNKNOWN, not PLAN_AMEND_PENDING
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: bound the new sync spawns in the install-verdict and wrapper tests (spawnSync tripwire)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(autoplan): pin CLI refusals to the result-code registry's fix text
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(gen-skill-docs): emit autoplan/DRIVER.md only when the autoplan template is in the tree (setup fixtures with a skill subset)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): run.lock is linked into place with its owner written; a fresh unreadable lock is held, not stolen
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci(paid-pr-profile): the vendored Capy skill is free-only; free-only files are not prompts needing paid coverage
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore(harness): non-behavioral HARNESS_VERSION decision for the PR-profile free-only rule
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(doctor): lane A runtime and pins cases are platform-neutral (bash paths; a lane-free .bun-version pin)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(designs): field wave plan with amendments and the gate rev 2-3 review record (ARTIFACT_VALID, 3 gate revisions)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(capy-install): follow a project's exact Bun pin above the minimum when gstack has its verified archive (adds Bun 1.4.3)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: the installer follows a verified exact project Bun pin (capy-install, troubleshooting, changelog)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(capy-install): the install reason names the project's pin and gstack's real minimum
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci: run the main-targeted checks after retargeting #3165 to main
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(autoplan-admission): the PATH-Bun refusal case needs the POSIX Capy host stub; skip it on Windows
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>
294 lines
21 KiB
TypeScript
Executable File
294 lines
21 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> | --record <file> [--attest <reason>]
|
|
* --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
|
|
* gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> [--json]
|
|
* gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--json] review a revised plan in this run
|
|
* 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.
|
|
* Mutating verbs (next, submit, answer, revise, export, run) pass admission first (lib/autoplan-admission.ts)
|
|
* and bring an older run directory forward (lib/autoplan-migrate.ts) under the run lock.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { answerGate } from '../lib/autoplan-gate';
|
|
import { admit } from '../lib/autoplan-admission';
|
|
import { exportRun } from '../lib/autoplan-export';
|
|
import { migrateRun } from '../lib/autoplan-migrate';
|
|
import { reviseRun } from '../lib/autoplan-revise';
|
|
import { pendingAmendments, type RunState } from '../lib/autoplan-state';
|
|
import { FILES, RunError, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } from '../lib/autoplan-run';
|
|
import { driverFile, 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)
|
|
DRIVER: ${driverFile()}
|
|
|
|
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx]
|
|
[--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
|
|
--scaffold (first next): a plan missing one of its two sections is reviewed from <out>/plan-source.md with the heading added
|
|
--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> | --record <file> [--attest <reason>]
|
|
--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.
|
|
--attempt may be omitted when exactly one attempt is open. --record takes a hand-run review: the gstack-findings fence,
|
|
one findings table (Severity and Title columns), one severity-tagged list (- [High] title) or a "No findings." line;
|
|
without its INPUT line it binds only with --attest <reason> and --attempt (receipt: attested)
|
|
submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason>
|
|
the outside voice could not run: closes the phase native_only once native is bound
|
|
revise --out <dir> --plan <file> [--incorporates <decision id,...>] [--withdraw <decision id>]
|
|
review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (plus Eng);
|
|
--incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one
|
|
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.
|
|
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}`;
|
|
const listFlag = (args: string[], name: string) => args.flatMap((a, i) => (a === name && args[i + 1] ? args[i + 1]!.split(',').map(x => x.trim()).filter(Boolean) : []));
|
|
/** The NEXT line after a reopen or revise: revise while accepted changes are pending, else next. */
|
|
function nextLine(state: RunState, out: string): string {
|
|
const pending = pendingAmendments(state).map(a => a.decision);
|
|
return pending.length ? `NEXT: gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')}` : `NEXT: gstack-autoplan next --out ${out}`;
|
|
}
|
|
|
|
/**
|
|
* Admission, then under the run lock: bring an existing run forward (GSTACK_VERSION_CHANGED, legacy decision ids)
|
|
* and record an admission override as a deviation. Notices go to stdout before the command's own lines (stderr with --json).
|
|
*/
|
|
function mutating(out: string, json: boolean, fn: () => number): number {
|
|
const admission = admit(process.env);
|
|
return withRunLock(out, () => {
|
|
const notices = [...admission.lines];
|
|
const record = (state: RunState) => {
|
|
if (!admission.deviation || (state.deviations ?? []).includes(admission.deviation)) return false;
|
|
(state.deviations ??= []).push(admission.deviation);
|
|
return true;
|
|
};
|
|
if (fs.existsSync(path.join(out, FILES.run))) {
|
|
const state = readState(out);
|
|
const migrated = migrateRun(state);
|
|
if (record(state) || migrated.changed) writeState(state);
|
|
notices.push(...migrated.lines);
|
|
}
|
|
if (notices.length) (json ? process.stderr : process.stdout).write(notices.join('\n') + '\n');
|
|
const code = fn();
|
|
if (admission.deviation && fs.existsSync(path.join(out, FILES.run))) { const state = readState(out); if (record(state)) writeState(state); }
|
|
return code;
|
|
});
|
|
}
|
|
|
|
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();
|
|
const initLines: string[] = [];
|
|
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'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined } }, initLines);
|
|
}
|
|
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 = [...initLines, ...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 record = flag(rest, '--record');
|
|
const model = flag(rest, '--model'); const attempt = flag(rest, '--attempt'); const unavailable = flag(rest, '--unavailable'); const failed = flag(rest, '--failed');
|
|
if (!phase || !voice) return usage('submit needs --phase and --voice');
|
|
if (voice !== 'native' && voice !== 'outside') return usage('--voice must be native or outside');
|
|
const outcome = unavailable !== undefined || failed !== undefined;
|
|
if (outcome && (voice !== 'outside' || result || record || (unavailable !== undefined && failed !== undefined))) return usage('--unavailable|--failed "<reason>" records the outside voice only, without --result or --record');
|
|
if (!outcome && (!!result === !!record || !model)) return usage('submit needs exactly one of --result <file> | --record <file>, and --model <id>');
|
|
const r = submitResult({ out, phase, voice, result: result ? path.resolve(result) : undefined, record: record ? path.resolve(record) : undefined, model, attempt, attest: flag(rest, '--attest'),
|
|
unavailable, failed, 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: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out });
|
|
return EXIT.ok;
|
|
}
|
|
|
|
function doRevise(out: string, rest: string[], json: boolean): number {
|
|
const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw');
|
|
if (!plan && !withdraw.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), or --withdraw <decision id>');
|
|
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>');
|
|
const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, cwd: process.cwd() });
|
|
const lines = [...r.lines];
|
|
if (r.state.status === 'running') lines.push(nextLine(r.state, out));
|
|
lines.push(resultLineFor(r.state.status, out));
|
|
emit(json, lines, { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), 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 mutating(out, json, () => doNext(out, rest, json));
|
|
if (cmd === 'submit') return mutating(out, json, () => doSubmit(out, rest, json));
|
|
if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json));
|
|
if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); 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 mutating(out, json, () => {
|
|
const state = readState(out);
|
|
const r = answerGate(state, Number(rev), reply);
|
|
writeState(state);
|
|
const lines = [...r.lines];
|
|
if (r.outcome === 'reopened') lines.push(nextLine(state, 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, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out });
|
|
return EXIT.ok;
|
|
});
|
|
}
|
|
if (cmd === 'export') {
|
|
return mutating(out, json, () => {
|
|
const state = readState(out);
|
|
const pending = pendingAmendments(state).map(a => a.decision);
|
|
if (state.status === 'complete' && pending.length) throw new RunError('PLAN_AMEND_PENDING', `run says complete with accepted changes not in the plan: ${pending.join(', ')}`, 3);
|
|
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', 'revise'].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));
|