Files
gstack/docs/unattended.md
T
garrytanandcapy-ai[bot] 421e66d2a4 feat(update-check): an equal VERSION with a moved main is an upgrade, via the compare API (W12)
When VERSION matches but ls-remote's main SHA differs from this checkout's
HEAD, bin/gstack-update-check asks the GitHub compare API (status, ahead_by)
through the existing receipted curl and prints
UPGRADE_AVAILABLE <ver> <ver>+<n> <sha>; a diverged or unreadable compare is
CHECK_FAILED (unknown), and behind/identical is up to date. Never fetches.

The cache gains an IDENTITY line (head, compared remote SHA, upstream): an
outcome replays only for the same HEAD and upstream, a same-remote refresh
reuses it without another compare, and a git checkout ignores a legacy
one-line cache (non-git installs keep the one-line format). The snooze is
keyed by the SHA. gstack-upgrade's question, snooze note and What's New step
handle the four-field line; gstack-skill-end reports it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-16 23:25:36 +00:00

397 lines
25 KiB
Markdown

# 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` and
`UNATTENDED_SESSION: true`, 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 <n> 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](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
Every workflow skill ends an unattended run 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 the directory that holds the artifacts below. `/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.
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](troubleshooting.md#unattended-runs-and-artifacts) with
a `fix:` clause. Grep the code, not the prose.
## The run directory
| 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.** `gstack-artifact validate <dir>/run.json`
is the deterministic completion check. 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.**
```bash
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:
```bash
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.**
```bash
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.
```bash
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.**
```bash
$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:
```bash
$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, a run
at rest 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](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](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](capy-install.md#the-launch-line).