v1.91.70.0 feat(unattended): artifact contract + validator, unattended session kind, findings-derived review log, gate list (multi-agent wave B-core) (#3115)

* v1.91.70.0 feat(unattended): artifact contract, unattended session kind, findings-derived review log, gate list

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

* test: register test/fixtures/multi-agent-wave as a free fixture (consumer: headless-artifacts.test.ts)

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-10 23:26:08 +00:00
1 parent dfccf5a750
commit 45a678323b
94 files changed
+5954 -1480

No files matched your search

+184
View File
@@ -1263,3 +1263,187 @@ history in 5 seconds, so known authors may be listed too.
`git config --add gstack.redact.allowEmail <address>`. If it is your own
address, check `git config user.email`. The allowlist only covers `pii.email`;
it never lets a HIGH finding through.
## Unattended runs and artifacts
Workflow commands (`gstack-artifact`, `gstack-gate`, `gstack-autoplan-timing`,
`gstack-review-log --findings`) end each error line with a code in
parentheses, for example `(ARTIFACT_STALE)`. The codes and anchors come from
`lib/result-codes.ts`; grep the code, not the prose. Every such command shares
one exit table: `0` ok, `1` fail, `2` usage, `3` refused or needs a flag. The
artifact contract itself is in [docs/unattended.md](unattended.md).
<a id="artifact-invalid-json"></a>
### `ARTIFACT_INVALID_JSON`
**Meaning.** `run.json`, `timing.json` or another JSON artifact did not parse.
**Fix.** Regenerate the run with the tool that writes it. A manifest is never
hand-edited; a partial write means the writer was interrupted.
<a id="artifact-unsupported-version"></a>
### `ARTIFACT_UNSUPPORTED_VERSION`
**Meaning.** The artifact's `schema_version` is one this gstack does not read
(`gstack-artifact schema <name>` prints the versions it validates).
**Fix.** Upgrade the consumer (`/gstack-upgrade`) or regenerate the run with
this gstack.
<a id="artifact-schema"></a>
### `ARTIFACT_SCHEMA`
**Meaning.** A required field is missing, has the wrong type, or holds a value
outside its enum. The message names the file, row and field.
**Fix.** Compare the row with `gstack-artifact schema <name>` and regenerate it.
<a id="artifact-missing"></a>
### `ARTIFACT_MISSING`
**Meaning.** `run.json` names an artifact that is not in the run directory.
**Fix.** Copy the whole run directory, not single files. If the file was never
written, rerun the phase that writes it.
<a id="artifact-stale"></a>
### `ARTIFACT_STALE`
**Meaning.** The file's bytes no longer hash to the value `run.json` recorded,
so the manifest does not describe this file.
**Fix.** Treat the run as partially rewritten. Regenerate it, or re-bind the
file through the tool that wrote it so the manifest is updated with it.
<a id="artifact-path-escape"></a>
### `ARTIFACT_PATH_ESCAPE`
**Meaning.** An artifact path resolves outside the run directory: a `..`
segment, an absolute path, or a symlink that points elsewhere.
**Fix.** Keep every artifact inside the run directory under a plain relative
name. A run directory is copied as one unit; nothing outside it is part of
the contract.
<a id="artifact-malformed-jsonl"></a>
### `ARTIFACT_MALFORMED_JSONL`
**Meaning.** A JSONL artifact has a line, usually the last, that is not one
JSON object. The writer was interrupted mid-append.
**Fix.** Rerun the phase that writes the file. Do not trim the line by hand;
the row it held is lost either way, and the manifest hash would change.
<a id="artifact-duplicate-id"></a>
### `ARTIFACT_DUPLICATE_ID`
**Meaning.** Two rows in one file share an id.
**Fix.** Ids are run-bound (`<run>-...`) and unique per file. Regenerate the
rows with the tool that assigns them.
<a id="artifact-unbound-id"></a>
### `ARTIFACT_UNBOUND_ID`
**Meaning.** A row id does not start with the run id in `run.json`, so it
could belong to another run.
**Fix.** Regenerate the row with this run's id.
<a id="artifact-dangling-ref"></a>
### `ARTIFACT_DANGLING_REF`
**Meaning.** A task's `findings`, `blocked_by` or `depends_on`, or a decision's
reference, names an id no artifact in this run defines.
**Fix.** Write the referenced finding, decision or task first, or drop the
reference. A `depends_on` entry may also name a `pr` label that another task
in the file carries.
<a id="artifact-dependency-cycle"></a>
### `ARTIFACT_DEPENDENCY_CYCLE`
**Meaning.** Tasks depend on each other in a cycle, so no lane could start.
**Fix.** Break the cycle in `depends_on`. The message lists the ids on it.
<a id="artifact-count-mismatch"></a>
### `ARTIFACT_COUNT_MISMATCH`
**Meaning.** A `counts` value in `run.json` disagrees with the rows in the file
it summarizes (for example `counts.findings: 65` beside 64 rows).
**Fix.** Regenerate `run.json` after the last row was written. A count never
replaces the file; the hash and the rows are the record.
<a id="artifact-run-interrupted"></a>
### `ARTIFACT_RUN_INTERRUPTED`
**Meaning.** `run.json.status` is not terminal, or a voice is still
`running`/`pending`. The artifacts describe an unfinished run.
**Fix.** Resume it (`gstack-autoplan resume --out <dir>`, PR B-runner) or
consume the artifacts as partial and say so.
<a id="artifact-reviewer-missing"></a>
### `ARTIFACT_REVIEWER_MISSING`
**Meaning.** A phase in `required_phases` is absent from `phases`, a voice has
no terminal status, or a completed voice has no bound output file (path and
hash) in `artifacts`.
**Fix.** Finish the phase. A missing voice is missing coverage; the validator
never fills it in as N/A.
<a id="artifact-guard-line-missing"></a>
### `ARTIFACT_GUARD_LINE_MISSING`
**Meaning.** The run says `session_kind: unattended`, but its
`review-record.md` does not carry the `GUARD_NOT_INSTALLED` line, so the
record does not state that publication order was unverified.
**Fix.** Start the run through `gstack-skill-start` (it prints the line) and
keep the line in the review record.
<a id="guard-not-installed"></a>
### `autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED)`
**Meaning.** The autoplan publication hook only executes inside Claude Code.
On every other host, and in every `unattended` session, the hook is present
but never runs, so phase publication order rests on the snapshot hashes
recorded per phase rather than on the guard.
**Fix.** Nothing on this host. The line is printed once per run and belongs
in the review record; `gstack-artifact validate` requires it for an
unattended run. On Claude Code a *missing* hook file still denies every
guarded call (a broken install must not pass as an absent one).
<a id="review-status-mismatch"></a>
### `review-log: status mismatch ... (REVIEW_STATUS_MISMATCH)`
**Meaning.** The row handed to `gstack-review-log` claims a `status`,
`unresolved`, `issues_found` or `critical_gaps` that the `--findings` file
contradicts (for example `status: clean` beside open findings). The row was
not written.
**Fix.** Fix the findings file, not the claim, and log again. Omit the claimed
fields and let the row be derived from the file.
<a id="gate-rev-stale"></a>
### `GATE_REV_STALE`
**Meaning.** The reply names a `gate_rev` older than the gate list's current
revision, so it may answer items that changed.
**Fix.** Render the current list (`gstack-gate render <file>`), read it, and
reply with the printed `gate_rev`.
<a id="gate-reply-unparsed"></a>
### `GATE_REPLY_UNPARSED`
**Meaning.** Part of the reply matched no gate item or option. Nothing was
applied; the unparsed tokens are listed.
**Fix.** Reply with `all`, with `<id><option>` tokens (`d3b uc1a`), or with
`all except <tokens>`. A bare `yes` or `no` is ambiguous across multi-option
items and is never guessed.
+151
View File
@@ -0,0 +1,151 @@
# 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`.
- **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>`), `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 # the numbered list and reply grammar
```
4. **Answer the gate** by composing the reply grammar (`all`, `<id><option>`
tokens such as `d3b uc1a`, or `all 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 "all except d3b" --gate-rev 1 --json
gstack-gate decisions <dir>/gate.json --reply "all 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.
## 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.