* 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> * v1.91.73.0 feat(ship): repo ship policy, gstack-restamp stamp-at-merge, tree receipt, evidence bundle, ship receipt Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ci): result-codes is selection data, receipt counts come from files, C-stamp tests leave the Windows subset - scripts/pr-dependencies.ts: lib/result-codes.ts joins SELECTION_DATA_MODULES; its fix: prose names commands a person reruns, not files the module consumes (test/pr-dependencies.test.ts traced review cases through it to bin/gstack-next-version) - ship/sections/pr-body.md.tmpl: gstack-ship-receipt write reads --gate/--ci from agent-written .gstack/tmp/ship-*.json files and --spend-usd from the allowlisted <spend-usd> placeholder (generated-bash lint: no free text in commands) - scripts/lib/windows-curation.ts: the five C-stamp test files spawn bin/ shebang scripts through test/helpers/restamp-fixture.ts, which the source scan misses Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the receipt step names where each input comes from The workflow judge (eval-slices 3, median 3/4) found pr-body's gstack-ship-receipt command demanding a tree receipt, CI counts and a spend figure no earlier step produces, with RECEIPT_INVALID halting publication. The command now takes only Step 16 stage 4's lane counts (file-written JSON); --receipt is added only when Step 16 wrote .gstack/tmp/tree-receipt.json; RECEIPT_INVALID says to fix the named input file and rerun. The unused <spend-usd> allowlist entry is removed. 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>
16 KiB
The repo ship policy: .gstack/ship-policy.json
/ship reads one machine-readable file per repository that says how releases
are stamped and merged: the bump level, the queue mode, the repo's own release
tool, the paths where version mentions are rewritten, the CHANGELOG, a paired
mirror, the history policy and the preregistered evals. Without the file
nothing changes: every key has a default that reproduces today's behavior.
The file is read from the committed copy on the base branch
(origin/<base>), never from the PR's working tree, so a PR cannot smuggle a
command into its own gate. File presence never authorizes execution:
gstack-restamp runs release_tool only under /ship or with
--allow-repo-commands; standalone it lists the commands and exits 3 with
repo commands not executed (pass --allow-repo-commands). A PR that
introduces or changes the policy runs under the reviewed bootstrap path
(--policy-from head --allow-repo-commands) and its receipt says
policy: head (unreviewed).
Tools: gstack-ship-policy init|validate|show-effective|check-history,
gstack-restamp, gstack-tree-receipt, gstack-ship-receipt,
gstack-evidence bundle|ancestor. They share the exit table (0 ok, 1
fail, 2 usage, 3 refused or needs a flag) and end every error line with a
code from troubleshooting.md.
Schema v1
gstack-ship-policy init writes every key at its default; init --explain
prints this table. JSON carries no comments, so examples live in
.gstack/ship-policy.example.json. Unknown keys are errors.
| Key | Values | Default | Meaning |
|---|---|---|---|
bump |
patch | ask |
ask |
patch skips the MINOR/MAJOR prompt in /ship Step 12 and gstack-restamp --next bumps PATCH; ask keeps the prompt. |
queue_mode |
claim-at-open | stamp-at-merge |
claim-at-open |
See stamp-at-merge. claim-at-open is today's queue (gstack-next-version claims a slot when the PR opens). |
release_tool |
command or null |
null |
The repo's own release command (bun run release:restamp). Runs in an isolated staging worktree under the release-tool contract. |
release_outputs |
globs | [] |
Paths the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Anything else is RESTAMP_OUTPUT_OUTSIDE_ALLOWED. |
stamp_paths |
globs | [] |
Where exact (was X), since: X and vX mentions of the branch's tentative version are rewritten on a restamp. |
changelog |
path or null |
CHANGELOG.md |
Re-headed on a restamp: the PR's ## [Unreleased] (or old-version) section moves to the top as ## [<version>] - <date>. |
mirror |
relative path or null |
null |
A paired checkout to restamp identically (tier 2: validated and confined today, not yet run). |
history |
merge-only | squash-ok |
squash-ok |
merge-only makes /ship refuse a squash or rebase plan; gstack-ship-policy check-history reports a rewritten branch (HISTORY_POLICY_VIOLATION). |
preregistration_shas |
commit ids | [] |
Each must be an ancestor of HEAD before push: gstack-evidence ancestor --from-policy; the ship receipt records the result. |
stamp_sensitive_gates |
evidence labels | [] |
Gates that read VERSION or generated bytes (gstack's own tests lane does, through the agents-digest freshness test). The tree receipt never marks them reusable. |
pr_count |
{ default_pr_count, split_requires_owner } |
{1, true} |
The PR-count row in plan reviews. |
maintainers |
GitHub logins | [] |
Maintainer set for /review contributor mode; empty means repository push permission. |
Containment, checked by validate and again at write time: paths and globs are
repo-relative with no .. segment, under 256 characters, at most 64 per list,
and a glob's regex expansion under 512 characters; release_tool is under 512
characters with no newline; a matched file that resolves through a symlink to
outside the repository is refused (POLICY_CONTAINMENT). A mirror may start
with .. (a sibling checkout) but has no interior ..; mirror writes are
confined to that directory.
gstack's own files
gstack's policy (.gstack/ship-policy.json, the only tracked file under the
otherwise ignored .gstack/) keeps today's behavior and names the one release
tool gstack-version-bump --regen-digest used to hard-code:
{
"bump": "ask",
"queue_mode": "claim-at-open",
"release_tool": "bun scripts/gen-agents-digest.ts",
"release_outputs": ["agents-digest/gstack-AGENTS.md"],
"stamp_paths": [],
"changelog": "CHANGELOG.md",
"history": "squash-ok",
"stamp_sensitive_gates": ["tests"]
}
.gstack/ship-policy.example.json shows every key set the way a serial-merge
repo with a release script, docs mentions and a mirror would set them.
gstack-ship-policy show-effective --base main # resolved values with the source of each
gstack-ship-policy validate # the working-tree file (what you are editing)
gstack-ship-policy init --explain # key meanings and defaults
What /ship reads from it
/ship Step 3 runs show-effective --base <base> and follows its ship: lines,
which carry the workflow consequences the skill prose would otherwise spell out:
| Line | Effect |
|---|---|
ship: history merge-only: … |
never rebase, squash or force-push; check-history --plan <merge|squash|rebase> refuses a rewrite plan (HISTORY_POLICY_VIOLATION, exit 3) |
ship: queue_mode stamp-at-merge: … |
Step 12 items 1–4 are replaced by gstack-restamp --next --base <base> --pr <n> --expect-base <sha> when the PR is next, --after <predecessor-pr> when it is not; the restamp's own next: line says what follows (NEW_VERSION, CHANGELOG entry text only, the merge's expected head) |
ship: gate-ahead: … |
with .gstack/tmp/gate-ahead.json present, Step 16 reuses only the lanes gstack-tree-receipt … --json > .gstack/tmp/tree-receipt.json marks reusable; stamp-sensitive and not-eligible lanes run live; --force-gate <reason> is the coordinator's call |
ship: bump patch: … |
Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size |
ship: preregistration: … |
gstack-evidence ancestor --from-policy --base <base> must print only ANCESTOR: ok before push |
ship: release_tool: … |
the command runs only inside gstack-restamp (staging worktree, outputs confined to release_outputs) |
The PR body's first section is the ship receipt (gstack-ship-receipt write … --body),
written by the pr-body section of /ship after the body is composed.
queue_mode: stamp-at-merge
Under stamp-at-merge nothing is claimed when a PR opens. The stamp happens
when the PR is next, from the base branch's version at that moment, through
the repo's own release tool, across every stamp location at once:
gstack-restamp --next --base main --allow-repo-commands --pr 42 \
--expect-base <sha of origin/main the queue assumed> --expect-order 40,41,42
prints ship, next in queue at <version>, STAMP_VERSION: <version>, one
line per stage, the diff limited to stamp files, MERGE_CONDITION: sha=<head>
(pass it to the merge API's expected-head sha so a check-to-merge race is
rejected by the server) and the GSTACK_RESULT: line. claims: ignored (stamp-at-merge) says the legacy claim reader was not consulted. When the base
or the order moved, the result is QUEUE_STALE (exit 3) with the exact
recompute command; nothing is written.
Stages, each journaled in .gstack/tmp/restamp-journal.json:
- version:
gstack-version-bump write --version <X>in the staging worktree (VERSION, the manifest, npm lockfiles). - release_tool: the policy command, under the contract below.
- changelog: the PR's
[Unreleased]or old-version section moves to the top under<X>, above its predecessor's entry. Duplicate headings (CHANGELOG_DUPLICATE_HEADING) and a top entry that is not<X>(CHANGELOG_TOP_MISMATCH) are errors. - stamp_paths: exact
(was X),since: XandvXrewrites within the declared globs.Xis the branch's own tentative stamp (its VERSION when it moved past base), else--was <X>, else the version an unstamped branch would have taken next (base + bump); mentions of a version the base never released (not a CHANGELOG heading at base, not a tag) are rewritten too, so a queue reorder that moves the base under a branch still finds itssince:fields. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. - mirror: tier 2; declared and validated, reported as not run.
- apply: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is
RESTAMP_CONFLICTand the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
Idempotent: a second run with the same version is noop. --dry-run prints
the full diff and writes nothing (not even the journal). --version <X> stamps
exactly X; --bump overrides the level; --queue-mode overrides the policy.
Gate-ahead: --after <pr>
A reorder is cheap only when the gate can run before the predecessor merges.
gstack-restamp --after <pr> --predecessor-ref <ref> merges HEAD and the
predecessor's head in a throwaway worktree (.gstack/tmp/gate-ahead-worktree),
resolving only version-file and CHANGELOG-ordering conflicts, stamps N+1
relative to the predecessor's version there, commits, and records
.gstack/tmp/gate-ahead.json (predecessor_head, branch_head,
gated_head, gated_tree, version, evidence_branch_key). The PR branch's
history is never touched, so squash-history PRs stay clean. The gate runs in
that worktree; gstack-evidence bundle there exports the evidence.
The release-tool contract
The release tool runs in an isolated staging worktree seeded from the working
tree's content fingerprint (gstack-wtree), with the repo's ignored
node_modules linked in. Its outputs are enumerated: the version files,
lockfiles, the CHANGELOG, stamp_paths and release_outputs. A write outside
that set refuses the whole restamp before anything reaches the branch
(RESTAMP_OUTPUT_OUTSIDE_ALLOWED); a non-zero exit is
RESTAMP_RELEASE_TOOL_FAILED. Preimages and postimages of every write are
recorded in the journal. Pinning the command string does not freeze its
implementation (bun run release:restamp still runs the PR's package script),
so the receipt names the policy revision, the command, its working directory
and the trust scope (policy: base origin/main@<sha> or head (unreviewed)).
The tree receipt
gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr 41 --bundle evidence-bundle.json
gstack-tree-receipt --gated <sha> --current HEAD --json
Compares two committed heads (a working-tree fingerprint can name objects unreachable once the gate VM is gone) and prints the gated and current heads, both tree ids, the version pair, the predecessor the gate assumed, the diff limited to stamp files, one line per file with its rule and verdict, one line per gate, then two verdict lines:
TREE: same-modulo-stamps | changed (<residual paths>)
gate-reuse: eligible (reusable: tests; rerun: digest) | not-eligible (<what changed>) — fix: <rerun command> (GATE_REUSE_NOT_ELIGIBLE)
Hunks are classified per file type: the version file (old → new only), the
manifest (version key only), npm and bun lockfiles (root version keys
only, where gstack-version-bump writes; a dependency entry is changed),
the CHANGELOG (a heading move or re-heading, compared as an order-independent
multiset of entries), stamp_paths and release_outputs (every
removed/added pair must be an exact (was X) / since: X / vX rewrite with
X the gated version and the replacement the current one, so a dependency pin
that happens to equal the old version is changed). Any other byte is a change.
Equivalence is decided per gate. A lane in stamp_sensitive_gates always
reruns; a verified lane is reusable only when the tree is same-modulo-stamps
and the execution identity in the bundle equals the current one: Bun and Node
versions, OS and arch, every lockfile's identity (root version normalized
away), the .github/workflows tree id, runtime pin files and
package.json#engines, and the runner image when either side knows it. Without
a bundle the identity is unknown and the receipt is not-eligible. When the
recorded predecessor head is no longer the predecessor PR's head (checked
through --predecessor-ref or gh pr view --predecessor-pr) the receipt
prints PREDECESSOR MOVED <old> -> <new> — fix: gstack-restamp --after <pr> again (RESTAMP_PREDECESSOR_MOVED) and refuses eligible.
/ship Step 16 consults the receipt; when it says eligible, a repeated full
gate needs --force-gate <reason>, and the coordinator makes the call.
The evidence bundle
gstack-evidence is machine-local by design, so the portable form is a bundle:
gstack-evidence bundle --out evidence-bundle.json --label tests --label e2e --require tests,e2e \
--selection tests='bun run test' --run-url tests=https://github.com/.../actions/runs/1
A bundle binds to a committed head (repo.commit, repo.tree) and carries the
comparison material (version source, CHANGELOG path, stamp_paths,
release_outputs), the execution identity above, the required-lane manifest,
and per lane the command, cmd_sha256, exit, timestamp, executed selection,
run URL, log hash and whether it is verified (produced by the ledger on a
clean committed tree at this head) or not (dirty tree, other commit, or an
--imported assertion). A fresh coordinator machine reads it with
gstack-tree-receipt --bundle after the gate VM is gone; the receipt refuses
reuse when the bundle was produced on another tree or lacks the identity
(EVIDENCE_IDENTITY_UNKNOWN).
The ship receipt
The handoff record is a fenced block, identifiers first, validated against
gstack-artifact schema ship-receipt:
```gstack-ship-receipt
{ "schema_version": 1, "pr": 42, "head": "<sha>", "version": "1.2.7.0", "base": "main",
"gated_tree": "<tree>", "predecessor": "<sha>", "tree": "same-modulo-stamps",
"gate_reuse": "eligible (reusable: tests; rerun: digest)", "gate": { "tests": { "pass": 10, "fail": 0, "skip": 1 } },
"ci": { "checks": 3, "failed": 0 }, "spend_usd": 0.42, "session_kind": "unattended",
"artifacts_consumed": "1/1", "run": "<run id>", "thread": "<thread id>", "policy": "base origin/main@<sha>" }
```
gstack-ship-receipt write --pr <n> --receipt <tree-receipt.json> --gate '<json>' --ci '<json>' --body <pr-body.md>
makes it the first section of the PR body (/ship Step 18) and prints
SHIP_RECEIPT: pr=… head=… version=… for the first line of the completion
message. /land-and-deploy reads it with gstack-ship-receipt read --pr <n> --json (RECEIPT_MISSING, RECEIPT_INVALID).
The adoption census (P7)
gstack-ship-receipt census --repo garrytan/gstack --since v1.91.70.0 --cohort threads.txt
reads merged PRs since the tag through gh, parses each receipt, deduplicates
by run and thread, and prints CENSUS: … unattended=<n> consumed=<n> adoption=<consumed threads>/<cohort> durable=yes. The cohort file lists one
thread id per line (threads that never invoke gstack count in the
denominator); without it the denominator is the threads the receipts name.
History policy and preregistration (C7)
Under history: merge-only, /ship refuses a squash or rebase plan and merges
the base branch instead: gstack-ship-policy check-history --plan squash
prints HISTORY: refused (…) (HISTORY_POLICY_VIOLATION) with exit 3, and
without --plan it prints HISTORY: ok or HISTORY: rewritten … (HISTORY_POLICY_VIOLATION) (exit 1) when the pushed branch is no longer an
ancestor of HEAD. gstack-evidence ancestor --from-policy --base main prints one ANCESTOR: ok|missing <sha> line per
preregistration_shas entry (PREREGISTRATION_NOT_ANCESTOR, exit 1), and the
ship receipt records the result in preregistration.