Files
gstack/docs/unattended.md
T
Garry Tanandcapy-ai[bot] 576839cb8c v1.91.256.0 feat(ship): review loop converges (frozen scope, 3-pass cap, NO FINDINGS) and receipts carry across conflict-only merges (#3481)
* feat(ship): review loop converges (frozen finding scope, 3-pass cap, NO FINDINGS first-class) and receipts carry across conflict-only merges

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

* test(ship): keep the Step 9 first-entry and Fix-First phrasing the skip/requeue contract asserts; fill the review record's pass, review_scope and range_base placeholders in the caller template test; narrow a test type

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

* fix: pre-landing review fixes (pass 1) and the review-loop budget wording

Two blocking findings from /ship's own pass 1, both reproduced in the QA smoke before the fix:
gstack-ship-receipt carry hashed the product diff with git patch-id --stable, which strips
whitespace, so a space moved in a reviewed shell line carried as "no product change"; and it
classed package.json and every lockfile as release metadata, so a dependency swap carried too.
The patch-id is now --verbatim, package.json and VERSION are metadata only while the change is a
version stamp (lib/pregate/stamp-only.ts), lockfiles are product unless the policy names them,
the test globs name code extensions, a dirty working tree refuses before anything is compared,
and a receipt carried twice keeps the reviewed head as carry.from.

gstack-review-scope: an unresolvable base is POLICY_SOURCE_UNAVAILABLE instead of a silent
working-tree read; --range-base is validated; git runs with core.quotePath=false; range_base is
null on pass 1; a blocking finding with no path is fix; category "testing" is a test finding
below the floor; defer searches once, files nothing on a failed search and exits 0 unfiled;
summary refuses zero matching passes and the receipt write is chained to it with &&; a finding
the diff causes elsewhere names the changed line in `cause` and classify follows it.

Review-loop budget wording (ideas from tstack's sol-loop and pstack's interrogate, rewritten;
NOTICE.md): the blocker / should / nit glossary and two stop rules in Step 9.4, the checklist
and docs/review-policy.md; a lead-judgment stage in the finding merge both /review and /ship
render. The core read diffs $RANGE_BASE from pass 2 on. Caps and pins re-measured with reasons.

Step 7 coverage audit: four tests added in existing files, the --review-loop case moved to its
own test (the shared test tripped bun's 5 s default).

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

* fix: pin the renumbered review-army stages and point the wording test at product code

CI at c3e27fc failed three free-tier tests, all this branch's own: the
workflow-required-steps fixture and shared-libs-rendering still pinned the
pre-Lead-judgment stage numbers, and test-of-test-ratchet counted
review-loop-budget-wording.test.ts as test-only code because it read only
generated markdown. The fixture and rendering test now pin stages 1-8, and the
wording test renders both armies through generateReviewArmy and reads the
authored ship template as well as the generated sections.

Pass-2 maintainability notes folded in: docs/troubleshooting.md names the two
new RECEIPT_CARRY_REFUSED causes (dirty tree, unreadable status) and the two new
REVIEW_SCOPE_INPUT causes (non-hex --range-base, summary with no matching
pass); gstack-review-scope's help states that classify reads the base branch's
policy and refuses an unresolvable base unless --policy is passed.

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

* fix: date a review-log row from its binding start when it has no timestamp

The Step 9.4 template asks the writer to fill `timestamp`; the logger stamps
`review_binding.started_at` but never `timestamp`. `gstack-review-scope
summary` dropped such a row silently, so this PR's own pass 2 was missing from
REVIEW_LOOP (passes=1 for a two-pass loop). A row now dates from `timestamp`,
else from its binding start; a row with neither is still not counted.

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

* fix: pass-4 review fixes (carry stamp rule, deleted-file locus, scanned defer, diff prefixes, row time)

Five findings at the blocking floor from the Step 11 outside voice (gpt-6.1-sol)
and the native adversarial pass, fixed as a fourth pass approved by GStack
Minder under the three-pass policy:

- carry: a path the policy names under stamp_files, stamp_paths or
  release_outputs is metadata only while its change is a stamp (versionStampOnly
  for a manifest kind, stampOnlyText otherwise), the rule the tree receipt
  applies; a logic change in a stamped source file is product. Every
  metadata-listed path is read at both heads and a non-stamp change drops its
  globs from the product exclusion.
- review-scope: parseHunks keeps a deleted file as a whole changed location, so
  a cause anchored in a removed export is the diff's to fix, not deferred.
- defer: issues are filed through lib/gstack-post's publication scan; a HIGH
  finding in the title or body refuses and a MEDIUM one needs a human, both
  recorded as unfiled with the reason; --repo names the destination.
- classify: both diffs pin --src-prefix=a/ --dst-prefix=b/ --no-textconv, so
  diff.mnemonicPrefix cannot send every finding outside.
- summary: a row dates from review_binding.started_at before the writer-typed
  timestamp, so a date-only value cannot drop a real pass.

Docs: ship-policy.md#receipt-carry, review-policy.md, the Step 9.4 template
and the CHANGELOG gate-integrity note record the rule and the pass.

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

* test(review-army): assert the Red Team branch the run took in the N+1 case

Under the blocking floor the Performance specialist rates a 12-line N+1
INFORMATIONAL, so the conditional Red Team does not activate; the case
encoded the old severity (N+1 is CRITICAL, a Red Team always follows). It
now asserts the branch the run took: a Red Team that ran must follow the
Performance specialist; no Red Team is correct only when the report's own
count says 0 critical and its Red Team line names that reason. The N+1
detection assertion is unchanged. The free contract test gains the
informational-not-activated and informational-claimed controls and the
budget test's synthetic report carries the count line.

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

* v1.91.256.0 stamp

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-26 20:48:01 +00:00

31 KiB

Unattended runs: the artifact contract

A gstack skill runs unattended when a parent agent starts it with GSTACK_SESSION_KIND=unattended and reads its artifacts instead of its prose. This page is the contract between that parent and the run: what the session kind changes, which files a run writes, how the parent decides whether the run finished, and how it reads the result without reading any markdown.

The session kinds are: interactive (a human answers prompts), headless (GSTACK_HEADLESS, CI, the eval harness; a question BLOCKs), spawned (an orchestrator subagent; the recommended option is auto-chosen) and unattended (this page). unattended is honored only as the explicit GSTACK_SESSION_KIND=unattended override, which outranks GSTACK_HEADLESS so a parent can drive a run inside CI. Nothing in the test or eval harness sets it, so headless keeps its BLOCK semantics.

What the session kind changes

gstack-skill-start prints SESSION_KIND: unattended, UNATTENDED_SESSION: true and the unattended-session instruction block (GSTACK_INSTRUCTION_BEGIN: unattended-session <session-id> through GSTACK_INSTRUCTION_END), then applies these rules for the whole skill:

  • No prompts. The update check, upgrade offer, telemetry and sync consent, onboarding tips and routing blocks are suppressed exactly as they are for spawned. An unanswered consent is off.
  • No publishing or sync, enforced at the writers. Usage analytics are not written (TELEMETRY_WRITE: skipped (unattended session)), the artifacts repo is neither pulled nor pushed (ARTIFACTS_SYNC: skipped (unattended session; mode=<m> queue=<n> retained)), the sync spool is not fed (gstack-brain-enqueue is a no-op, and gstack-review-log says review-log: sync skipped (unattended session) on stderr), and gstack-skill-end drains nothing. Inherited enabled settings and pending queue work are left for the next human session. The upgrade offer becomes one line at skill end, read from the local cache: UPGRADE_AVAILABLE: <version>; run /gstack-upgrade (<version>+<n> (main at <sha>) when main moved commits past the checkout without a new version).
  • Questions decide themselves, gates do not. A question with a (recommended) option takes it and appends the choice to decisions.jsonl with kind: "auto". A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate are appended with kind: "approval" and status: "pending". The skill ends at its next gate with the pending list rendered; it never approves and never publishes.
  • State root durability is reported. STATE_ROOT: <path> durable=yes|no (no when the host sets GSTACK_EPHEMERAL=1). On an ephemeral root the run says learnings: skipped (state root is ephemeral; set GSTACK_STATE_ROOT) and timing: analytics skipped (...) instead of writing to a disk that vanishes. Point GSTACK_STATE_ROOT at a durable directory when you want cross-run history (see state-root.md).
  • The autoplan publication guard is named, not faked. On hosts that do not execute Claude Code hooks, and in every unattended session, gstack-skill-start prints autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED) once. gstack-artifact validate requires that line in an unattended run's review-record.md. Claude Code's deny-on-absent behavior is unchanged.

An unknown GSTACK_SESSION_KIND value coerces to interactive and says so: SESSION_KIND: interactive (unknown kind '<x>').

The terminal line

A run that writes a record ends with one line the parent greps:

GSTACK_RESULT: skill=<name> status=<status> run=<dir>

status is one of complete, gate_pending, incomplete, interrupted or refused; run is where the run's record is. /autoplan always ends gate_pending when it reaches the final gate, because the gate is written and never approved. Pure queries (gstack-artifact schema, gstack-gate render) print no result line.

Not every workflow writes this line or a run directory today:

  • /autoplan writes the full run directory below through gstack-autoplan, and gstack-artifact validate checks it. It is the only skill with steps and helpers that write run.json, decisions.jsonl and gate.json.

  • Workflow commands gstack-pregate, gstack-regen, gstack-restamp and gstack-ship-queue print their own line, with run= naming their own record (the pregate receipt, the regen worktree, the restamp journal, the queue record), not a run directory.

  • A refused launch. Any skill whose install check refuses it at startup prints GSTACK_RESULT: skill=<name> status=refused run=- and stops.

  • /ship ends with gstack-ship-receipt result --pr <n> (or --body .gstack/tmp/ship-receipt.md after the handoff section). It binds the receipt to the branch head, writes <state root>/ship/runs/<utc>-<head12>/ (ship-run.json with head, pr, base, version, queue_mode, session_kind, receipt as the path of the copied ship-receipt.md, and receipt_source; it is a /ship record, not the run v1 manifest below, so read it directly: gstack-artifact validate does not apply) and prints GSTACK_RESULT: skill=ship status=complete run=<dir>. When gh cannot read the PR it prints GSTACK_RESULT: skill=ship status=incomplete run=- (exit 1). A receipt that is missing or names another head prints GSTACK_RESULT: skill=ship status=refused run=- with QUEUE_RECEIPT_MISSING or QUEUE_RECEIPT_STALE on stderr (exit 3); a run that stops before publication prints the same refused line itself. A parent's merge check is then mechanical: take run= from the last result line, read ship-run.json, and compare its head to the PR head. gstack-ship-queue ready, train build and verify --train make the same comparison from the PR body (ship-policy.md), which is why an unattended /ship may update the body of a PR that already exists: when an open PR for the branch exists and HEAD is its pushed head, Step 18 takes the pr-body section's existing-PR path and posts the body (receipt first) and title through gstack-post. That is the one publication the unattended rules allow; it never runs pr-create, never pushes a commit, never runs gh pr ready (the existing-PR path's draft-state items concern an early PR an unattended run never opens), and a branch with no open PR, or whose PR head is not HEAD, takes the handoff section. result --pr <n> also refuses QUEUE_RECEIPT_STALE when HEAD is not the PR's pushed head. When the run committed anything (a CHANGELOG entry, generated tests), the parent pushes those commits itself, then reruns the receipt write, the body post and result at the pushed head: a receipt for an unpushed head is QUEUE_RECEIPT_STALE to the queue. The receipt's review_loop object (passes, blocking_found, deferred, noted, fixed, scope), written from gstack-review-scope summary, is the review loop's machine-readable outcome, and the summary line a parent reads prints it as passes=<n> blocking_found=<k> deferred=<m>; the PR body lists the deferred issues under ## Deferred findings and the below-floor items under ## Review notes (review-policy.md).

    When the parent merges the base in or fixes a test after the receipt was written, it runs gstack-ship-receipt carry --pr <n> --base <base> instead of another /ship: CARRY: carried from <old> to <new> (<reason>) plus the GSTACK_RESULT: skill=ship status=complete run=<dir> line means the receipt now names HEAD with a carry object holding the git range-diff evidence (ship-run.json gains carried_from and carry_reason); CARRY: current means nothing moved; CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED) or (RECEIPT_CARRY_REFUSED) with the refused result line means the product diff changed or the reviewed head left HEAD's history, so /ship runs again (ship-policy.md).

  • Every other workflow skill (/review, /qa, /design-review, /spec, /land-and-deploy and the rest) gets the session rules above (no prompts, approvals stay pending, nothing published), but has no step that writes a run directory or prints the line, so a parent should not wait for either. /eval-plan asks for a gate_pending line at its spend gate, but no helper writes its gate.json yet (gstack-gate renders and parses a gate list; it does not create one). /cso skips the shared startup, so these session rules do not reach it at all. Read each skill's own record instead: the review-log rows (gstack-review-read) for /review and the qa-report-*.md file for /qa. For a /ship run from before the result line, read the receipt instead: when the run did not push (an unattended run must not), read the receipt file in the child's checkout with gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json; use gstack-ship-receipt read --pr <n> --json only once a PR exists. In both cases compare the receipt's head to the branch SHA (git rev-parse HEAD in the child's checkout): read does not check that the receipt is current, so an older PR's receipt reads as valid; result does.

Every workflow command shares one exit table: 0 ok, 1 fail, 2 usage, 3 refused or needs a flag. Error lines end with a result code in parentheses, for example findings.jsonl:12: ... (ARTIFACT_SCHEMA); each code has an anchor in troubleshooting.md with a fix: clause. Grep the code, not the prose.

The run directory

/autoplan writes these files; the next section shows how a parent reads them.

File Schema Contents
run.json run v1 The manifest: run id, status, required_phases, each phase's native and outside outcome (status, model, provider), input snapshot hashes, counts, every artifact's path (relative to the run directory) and sha256, session_kind, gate_rev, deviations, consumed_by.
decisions.jsonl decisions v1 One row per decision: id (<run>-<label> at gate revision 1, <run>-r<rev>-<label> after a reopen, since labels restart at each gate), title, options, recommended, kind (auto, approval, user_challenge), status (pending, approved, overridden, rejected), gate_rev, and once answered chosen, answered_at, reply.
findings.jsonl findings v1 One row per reviewer finding: id, phase, voice (native/outside), model, severity, title, disposition, plan_items, native_counterpart (string or null), source. Rows share the review-log row shape, so gstack-review-log --findings derives review status from this file.
tasks.jsonl tasks v2 Implementation tasks with id, title, status, depends_on, findings (ids in findings.jsonl), blocked_by, acceptance, optional pr/item/tier.
timing.json timing v1 Per-phase started_at, ended_at, wall_s, outside_s, native_s; started_at and total_wall_s for the run; the Phase 0 estimate.
plan.md prose The ## Implementation plan section, with ## URGENT, outside this plan written above it.
review-record.md prose The review record and decision audit trail; carries the GUARD_NOT_INSTALLED line in an unattended run.
gate.json see gstack-gate --help The final gate list /autoplan renders and parses.

Ids are run-bound: every row's id starts with <run>-, and run.json is the only place the run id is declared. gstack-artifact schema <name> prints the JSON Schema for any of findings, decisions, tasks, timing, run, pregate and ship-receipt; schema --list prints the names.

How a parent consumes a run

  1. Decide whether the run finished. For an /autoplan run directory, gstack-artifact validate <dir>/run.json is the deterministic completion check (a /ship run= directory holds ship-run.json, read directly as described above). It exits 0 and prints ARTIFACT_VALID: run=<id> status=<status> only when the manifest parses and matches its schema, every required phase and each reviewer voice has a terminal outcome, every listed artifact exists inside the run directory with the recorded hash, every JSONL file parses row by row against its schema with run-bound unique ids, task dependencies and finding references resolve without cycles, counts agree with the files, and (unattended) the guard line is present. Otherwise it exits 1 with one error line per problem and ARTIFACT_INVALID: <n> error(s); --json returns the same as { valid, run, status, run_dir, errors[] }. A run that was interrupted shows up here as ARTIFACT_RUN_INTERRUPTED, never as a partial success.
  2. Read the terminal line. grep '^GSTACK_RESULT:' <log> | tail -1. With status=gate_pending, the gate is in the artifacts; nothing was approved.
  3. List what is pending.
    jq -c 'select(.status=="pending")' <dir>/decisions.jsonl
    gstack-gate render <dir>/gate.json --all    # the numbered list and reply grammar, every page
    
  4. Answer the gate by composing the reply grammar (rec, as-is, <id><option> tokens such as d3b uc1a, or rec except <tokens>; bare yes/no is rejected) and feeding it back with the gate revision you read:
    gstack-gate parse <dir>/gate.json --reply "rec except d3b" --gate-rev 1 --json
    gstack-gate decisions <dir>/gate.json --reply "rec except d3b" --gate-rev 1 >> <dir>/decisions.jsonl
    
    A stale revision is GATE_REV_STALE and a reply with unparsed tokens is GATE_REPLY_UNPARSED; both exit 1 and write nothing.
  5. Read findings and tasks without the prose.
    jq -c 'select(.severity=="Critical" and .disposition!="accepted")' <dir>/findings.jsonl
    jq -r '.id + "\t" + .title' <dir>/tasks.jsonl
    gstack-artifact urgent <dir>/findings.jsonl   # the URGENT block, or "None."
    
  6. Acknowledge consumption so a second parent (or a retry) can tell the run was already read: gstack-artifact ack <dir>/run.json --consumer <id> is idempotent and records consumed_by in the manifest.

Driving /autoplan from a parent: gstack-autoplan

On a host where the parent dispatches the reviewers itself (Capy, any orchestrator without a Claude Code harness), bin/gstack-autoplan is the explicit, durable state machine for the whole run. The parent never reads the review record; it runs a loop over two commands and reads the printed lines. A parent on any host reads autoplan/DRIVER.md (its path is printed as DRIVER: by gstack-autoplan --help and the first next) instead of autoplan/SKILL.md: the phase order, the artifact contract and the gate protocol in about a hundred lines, generated from the code.

AP=bin/gstack-autoplan
$AP next --out <dir> --plan <plan.md> [--ui|--no-ui] [--developer-tool] [--no-dx] [--light] [--spend-cap <usd>]
#   PHASE: ceo snapshot=<sha> order=ceo,design,dx,eng
#   SCOPE: ui=yes (...) dx=yes (detected: ...) source=ui:detected,dx:detected
#   DRIVER: <root>/autoplan/DRIVER.md
#   Skipped sections: none (scope: ui=yes,dx=yes,source=ui:detected,dx:detected; checklist=loaded)
#   ATTEMPT: <run>-a1 phase=ceo voice=native  prompt=<dir>/ceo-native-prompt.md  result=<dir>/ceo-native.a1.md  model_family=...
#   ATTEMPT: <run>-a2 phase=ceo voice=outside prompt=<dir>/ceo-outside-prompt.md result=<dir>/ceo-outside.a2.md model_family=differs from the native reviewer
#   SPEND: spent=0.00 reserved=0.00 unknown=0(0.00) worst_case=0.00 cap=none
#   GSTACK_RESULT: skill=autoplan status=awaiting_result run=<dir>

The first next prints SCOPE: … source=ui:<flag|marker|detected>,dx:<…> and, when CLI flags differ from the plan's marker, SCOPE_PIN: add <!-- gstack-autoplan-scope: … --> as the first line of <file>; gstack never writes the plan.

  1. Dispatch each ATTEMPT:. The native voice is a subagent on the parent's own model given the prompt file; the outside voice is a subagent on another model family, or bin/gstack-outside-voice run --runner codex-cli|api --prompt <file> --out <file> [--model <id>], which prints OUTSIDE_STATUS: <status> provider=<runner> model=<id> family=<f>. Every reviewer writes its result to the printed result= path under the RESULT FORMAT below. next is idempotent: it reprints open attempts and never redispatches on its own (EXECUTION_UNKNOWN lists attempts whose process died without a result; --redispatch <attempt> mints a fresh one and release --attempt <id> returns its reservation).
  2. Submit each result exactly once.
    $AP submit --out <dir> --phase ceo --voice native  --result <dir>/ceo-native.a1.md  --model <id> --attempt <run>-a1
    $AP submit --out <dir> --phase ceo --voice outside --result <dir>/ceo-outside.a2.md --model <id> --attempt <run>-a2 [--runner host-subagent (default)] [--usage-usd <n>]
    #   BOUND: <run>-a1 phase=ceo voice=native model=<id> findings=7 sha256=<result hash prefix>
    #   PHASE_CLOSED: ceo confirmed=4 disagree=1 new=2 native_only=1 coverage=both
    
    Submit seals the bytes it bound as <phase>-<voice>.<attempt>.bound.md (read-only); a later write to the reviewer's file changes nothing bound, and another attempt's result file is refused (ATTEMPT_MISMATCH). Binding checks the receipt line against the phase snapshot (RESULT_RECEIPT_MISSING), parses the canonical findings fence (RESULT_FINDINGS_MISSING), refuses an outside model from the native family (MODEL_FAMILY_CONFLICT), and refuses a second bind of the same attempt or a bind while the phase is not awaiting it (ATTEMPT_MISMATCH, PHASE_NOT_AWAITING, exit 3). Both voices in closes the phase: the reconciliation (confirmed / unverified / disagree / new per outside finding, with disagreement and resolution kept separate) is written to <phase>-consensus.{json,md}, and every later phase's prompts (DX, design and Eng alike) carry each closed predecessor's consensus as data; a phase refuses to open without one (CONSENSUS_MISSING).
  3. Loop next until status=gate_pending. Eng always runs last. The gate (gate.json, gate_rev) lists user challenges as approval items, disagreements as auto items and p1 plan approval; nothing is approved.
  4. Answer the gate with the reply grammar and the revision you read:
    $AP answer --out <dir> --gate-rev 1 --reply "rec" --by human            # every recommendation, plan changes included (APPLY: ...; amend_pending)
    $AP answer --out <dir> --gate-rev 1 --reply "as-is" --by human          # APPROVED: keeps every direction, no new paid phase
    $AP answer --out <dir> --gate-rev 1 --reply "rec except d1b" --by agent:<id> --owner-reply "<owner's words>"
    
    --by human means the owner typed the tokens; --by agent:<thread or id> means an agent relayed them, and then --owner-reply (or - for stdin, --owner-reply-file <path>) stores the owner's words verbatim (OWNER_REPLY_REQUIRED); owner words that parse to different choices are refused (GATE_OWNER_REPLY_MISMATCH). An unattended run refuses an answer without --by (ANSWER_BY_REQUIRED, exit 3) and records the value as answered_by on every decision row and in the review record's gate line, so a gate an agent closed is distinguishable from one the owner closed. gstack-autoplan recommend changes one recommendation before relay; it bumps the gate's rec_rev, and a later rec needs the --rec-hash from the render the owner saw (GATE_REC_STALE). A user challenge both voices raised on a confirmed finding (a title match, not file:line proximity alone, which is unverified) with the same changes value is recommended as b only with one shared proposal: a --reconcile record naming it, or the same remedy from both voices. Different or missing remedies keep a, and option b shows both verbatim with the basis. rec (alias all) takes every recommendation, so it accepts the items recommended b. Every accepted plan change prints APPLY: <decision id> <title> -> <chosen text> and becomes a pending amendment: next refuses with PLAN_AMEND_PENDING and export refuses a complete run until revise --incorporates names it, or revise --settle <id> settles a packaging or ordering change that needs no plan edit. A stale revision (GATE_REV_STALE, exit 3), bare yes/no, or a conflicting reply (GATE_REPLY_UNPARSED) writes nothing. A reply that leaves approval items pending records only the answered auto items and keeps the gate; a later reply never overwrites an earlier answer. An accepted change reopens nothing at answer (status amend_pending); revise reopens each phase whose reviewed plan changed plus Eng, archives their prompts and consensus as *.r<rev>.* (sealed *.bound.md results stay in place), and the next gate is gate_rev + 1; p1d reruns every phase on the current plan; p1e ends incomplete.
  5. Export and validate. $AP export --out <dir> writes plan.md (with the ## URGENT, outside this plan block first), review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json and run.json; then gstack-artifact validate <dir>/run.json and the consumption steps above apply unchanged. $AP status --out <dir> and resume work after the run directory moves (paths are re-rooted; journal paths are run-relative). With GSTACK_AUTOPLAN_CHECKPOINT_DIR set (on Capy, gstack-config set autoplan_checkpoint drive defaults it to the private project-user Drive folder; unset, the first next prints the export line), a run at rest, after each bound voice and after each phase transition publishes an immutable, hash-listed generation under <dir>/<run>/generations/ and switches current.json to it (the previous generation is kept; a stale copy never replaces a newer one; a directory overlapping the run is refused with CHECKPOINT_OVERLAP; a failure is a CHECKPOINT: failed warning that leaves the command's exit code alone). $AP checkpoints --from <dir> lists generations with their hash status, and $AP resume --out <new dir> --from <dir>/<run> restores the newest valid one after validating the staged run (CHECKPOINT_INVALID).

Concurrency and recovery: every state write is a locked read-modify-write (run.lock; a second writer sees RUN_LOCKED), attempts are journaled to attempts.jsonl before dispatch, and a parent killed before or after a submit resumes with next without a duplicate charge (test/autoplan-run-recovery.test.ts, test/autoplan-run-concurrency.test.ts, test/autoplan-answer-invalidation.test.ts). --spend-cap admits each attempt through spend.json (SPEND_CAP_EXCEEDED refuses, exit 3); host-subagent spend is unknown and counted in worst_case. --deadline <min> makes the run subcommand cancel the children it started and marks the run interrupted.

RESULT FORMAT (what every reviewer file must contain)

Each prompt file ends with this contract; submit enforces it.

  1. The first line is exactly INPUT: <phase> <snapshot sha256> (the receipt that binds the result to the snapshot both voices reviewed).
  2. The review is prose, then one fenced block of canonical findings, one JSON object per line: {"id":"F1","severity":"High","title":"...","file":"...","line":12,"fix":"..."} inside a ```gstack-findings fence. severity is Critical | High | Medium | Low | Informational; "user_challenge": true marks a recommended change to the plan's stated direction and needs "changes": "scope" | "cost" | "direction" (what accepting it changes); "urgent": true with "suggested_owner" marks a security or data-loss finding the plan does not own. An empty fence means no findings; a missing fence is refused.
  3. The whole result is written to the printed result= path.

Outside-voice runners

Runner Access Record label Needs
codex-cli repository, read-only, executes here repository review codex on PATH and logged in (bin/gstack-codex-login reads OPENAI_API_KEY from stdin)
api supplied input only supplied-input review --model <id> and OPENAI_API_KEY or ANTHROPIC_API_KEY; never silently equal to a repository review
host-subagent the host's host-dispatched review --result <file> the subagent wrote, --model <id>; spend unknown

bin/gstack-outside-voice runners prints each runner's availability on this machine; family <model> prints the model family submit compares. A completed api or host-subagent review is a full outside voice in the record, labeled by its runner. Result codes: EXECUTION_UNKNOWN, RUN_LOCKED, RUN_NOT_INITIALIZED, PHASE_NOT_AWAITING, ATTEMPT_MISMATCH, RESULT_RECEIPT_MISSING, RESULT_FINDINGS_MISSING, MODEL_FAMILY_CONFLICT, SPEND_CAP_EXCEEDED, CONSENSUS_MISSING, OUTSIDE_RUNNER_UNAVAILABLE (anchors in troubleshooting.md).

Hand-run records, revised plans and admission

  • Hand-run reviews. submit --record <file> binds a review that did not follow the RESULT FORMAT: 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 <id> (receipt attested; refused after a later revise), and gstack-artifact validate prints attested=<n>. --attempt may be omitted when exactly one attempt is open. submit --voice outside --unavailable "<reason>" (or --failed) closes the phase native_only once native is bound; next and resume never redispatch it.
  • Revising the plan in the same run. Edit the source plan, then $AP revise --out <dir> --plan <file> [--incorporates <decision id,...>]. Every phase whose reviewed Implementation-plan hash differs reopens (plus Eng); newly in-scope phases are added; archives keep .r<rev> names with .v<k> only on collision; a revise at gate_pending makes the next gate gate_rev + 1. --incorporates discharges only the named amendments and refuses an unchanged plan; revise --out <dir> --withdraw <decision id> drops one as a journaled owner decision. next prints PLAN_CHANGED and answer prints PLAN_SOURCE_CHANGED when the source plan moved since the snapshot. A plan without both ## Implementation plan and ## Review record refuses with PLAN_SECTIONS_INVALID; next --scaffold reviews a copy with the missing heading added. resume completes an interrupted revise.
  • Admission and upgrades. next, submit, answer, revise, export and run refuse on a Capy host (GSTACK_CAPY=1) without an ok install verdict for this checkout, HEAD and workflow at $GSTACK_INSTALL_VERDICT (INSTALL_UNVERIFIED), and in any unattended session when the running Bun is below gstack's supported minimum (RUNTIME_BELOW_MINIMUM); the decision is the same one gstack-skill-start makes (see Install admission); GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 overrides and records a run deviation. status and resume are never gated. A run directory written by an older gstack prints GSTACK_VERSION_CHANGED once and moves forward (legacy decision ids are renamed and journaled); one written by a newer gstack is refused.

Owner brief

gstack-owner-brief render --out <dir> is what the owner sees at the final gate, interactive or unattended: one page built from gate.json, decisions.jsonl and findings.jsonl (BRIEF_INPUT_MISSING when one is absent). It opens with the URGENT, outside this plan block, then a plain-language summary (phases closed, findings accepted, how many decisions take their default, the cycle time; --summary <file> or <dir>/summary.md replaces the generated sentence with the run's own), then the numbered decisions through the gate list: seven per page (--page N), stable ids, each auto item annotated with the default taken if unanswered, each approval item pending until you answer or decided <id><letter> on <date>. The last line names review-record.md, where the full review lives, and the gstack-autoplan answer call that answers the page. --write also saves brief.md in the run directory. /autoplan Phase 4 presents this output verbatim and writes the former long report to review-record.md.

After the final gate, gstack-owner-brief close --out <dir> --plan <file> rewrites every draft direction stands until the owner decides line in the plan to the decided option and date (DRAFT_DIRECTION: line <n> <id> rewritten); lines whose decision is still pending stay and are reported as DRAFT_DIRECTION_UNRESOLVED (exit 1). gstack-autoplan answer runs the same rewrite when a reply completes the gate (journal event draft_directions_closed).

Lane done: pregate, the sealed guard and the handoff lines

A lane under a coordinator runs gstack-pregate run --allow-repo-commands on its branch before it reports done (the unit tier on the changed files plus the repo's guards, docs/pregate.md#guards). In a repo whose .gstack/pregate.json has a sealed key it runs gstack-pregate sealed before any push or PR creation, since a PR tool may not run local hooks. Its handoff carries, right after the GSTACK_RESULT line, the gstack: ok|fail <rev> line and then the PREGATE: line. The coordinator contract (gstack-autoplan contract) states the same rule word for word.

Timing

/autoplan prints ESTIMATE: ... at Phase 0 (gstack-autoplan-timing estimate --run <id> --out <dir>, medians of the last ten recorded runs per phase, or no history), records each phase close (gstack-autoplan-timing close), and prints the CYCLE: line at the final gate (gstack-autoplan-timing summary). A close without an explicit start measures from the previous phase's end, else from the run start. The cross-run history lives at <state root>/analytics/autoplan-timing.jsonl and is skipped, with a printed line, on an ephemeral state root.

Reference run

test/fixtures/multi-agent-wave/reference-run/ is a complete unattended /autoplan run (the one that produced this wave's plan) and validates clean. test/fixtures/multi-agent-wave/INCIDENTS.md records what went wrong while it was produced by hand and which validator rule now catches each incident.

Install admission

An unattended run, and a GSTACK_SESSION_KIND=spawned run with GSTACK_CAPY=1, starts only on a supported Bun and, on a Capy machine (GSTACK_CAPY=1, set by the installer's GSTACK_LAUNCH line), with a passing install verdict for this checkout, its HEAD and the workflow. Otherwise gstack-skill-start prints INSTALL: fail runtime|verdict — <detail>; fix: <command> (<CODE>) and GSTACK_RESULT: skill=<s> status=refused run=-, and exits 3; stop and run the fix. GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits anyway (INSTALL_VERDICT: overridden), recorded as a deviation. TypeScript entrypoints use checkInstallAdmission from lib/install-verdict.ts; the Bash side is bin/gstack-install-verdict.sh. See capy-install.md.