# 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= queue= 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: ; run /gstack-upgrade` (`+ (main at )` 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: 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 '')`. ## The terminal line A run that writes a record ends with one line the parent greps: ``` GSTACK_RESULT: skill= status= run= ``` `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= status=refused run=-` and stops. - **Every other workflow skill** (`/ship`, `/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 ship receipt in the PR body (`gstack-ship-receipt read --pr --json`) for `/ship`, the review-log rows (`gstack-review-read`) for `/review`, and the `qa-report-*.md` file for `/qa`. 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 `/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` (`-