Merge origin/main (83ae96e) into gsta210/ubicloud-macos-empty-project

Rule 19 grant (GStack Minder). #3585 split test/ubicloud-runner.test.ts; the
four tests this branch added move unchanged into test/ubicloud-runner.test.ts
and test/ubicloud-runner-verify-quota.test.ts, and the fake API's list/show
change moves into test/helpers/ubi-runner-fake-vm.ts.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 14:29:19 +00:00
commit 14e754f6fb
357 files changed
+25179 -7292

No files matched your search

+53 -9
View File
@@ -2,7 +2,7 @@
gstack uses a declarative host config system. Each supported AI coding agent
(Claude, Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes,
GBrain, Copilot, Antigravity) is defined as a typed TypeScript config object built by the
GBrain, Copilot, Antigravity, Gemini CLI) is defined as a typed TypeScript config object built by the
`defineHost()` factory. Rendering a new host means creating one file and
re-exporting it: the generator, tests, and validation pick it up. Installing
it is not free: setup still has one install arm per installable host (see
@@ -76,13 +76,13 @@ Inventory of every setup side effect, by phase (line numbers are approximate):
|---|---|---|
| Build | `browse/dist`, `design/dist`, `make-pdf/dist`, `bin/gstack-cso-*` in the source checkout | source |
| Hosts record | `.gstack-installed-hosts` in the source checkout: every host setup installed from it, additive (#1694) | source |
| Generation | renders for the recorded hosts only (`.agents/`, `.kiro/`, `.factory/`, `.opencode/`, `.cursor/`, `.copilot/`, `.agy/`) in the source checkout; `gstack-patch-names` rewrites Claude `name:` fields in place | source |
| Generation | renders for the recorded hosts only (`.agents/`, `.kiro/`, `.factory/`, `.opencode/`, `.cursor/`, `.copilot/`, `.agy/`, `.gemini-cli/`) in the source checkout; `gstack-patch-names` rewrites Claude `name:` fields in place | source |
| Render prune | renders of hosts the record does not name: links into the checkout removed, generated files moved to `$GSTACK_STATE_ROOT/backups/host-renders/<ts>-<id>/`, other files kept | source / state root |
| Chromium | Playwright cache (`~/.cache/ms-playwright`), lock under the state root | machine |
| Config | `skill_prefix`, `timeline_stop_hook`, team-mode keys in `$GSTACK_STATE_ROOT/config.yaml` | state root |
| Claude arm | `~/.claude/skills/gstack` link, one dir per skill with a `SKILL.md` link, alias copies | selected host |
| Codex arm | `${CODEX_HOME:-~/.codex}/skills/gstack` runtime root, `gstack-*` links into `.agents/skills`, `.agents/skills/gstack` sidecar | selected host |
| Kiro, Factory, OpenCode, Cursor, Copilot, Antigravity arms | the host's `skills/gstack` runtime root (built beside the live one and swapped in) and `gstack-*` links | selected host |
| Kiro, Factory, OpenCode, Cursor, Copilot, Antigravity, Gemini CLI arms | the host's `skills/gstack` runtime root (built beside the live one and swapped in) and `gstack-*` links | selected host |
| OpenCode commands | `~/.config/opencode/commands/gstack-*.md` with a managed marker (#2629); user command files are never touched | selected host |
| Registry | one row per activated install in `installs.tsv` | state root |
| Migrations | whatever each `v*.sh` repairs; marker in the state root | state root |
@@ -245,12 +245,15 @@ which Google's docs do not state, so the rendered text names no number.
**Shared `~/.gemini`.** Antigravity CLI and Gemini CLI both live under
`~/.gemini`. Gemini CLI reads `~/.gemini/skills` (and `~/.agents/skills`);
Antigravity CLI reads `~/.gemini/antigravity-cli/skills`. Setup writes only
gstack entries in the latter, uninstall removes only gstack-managed entries
there, and `--host auto` selects Antigravity only for `agy` on `PATH` or an
existing `~/.gemini/antigravity-cli`, never for a bare `~/.gemini`.
`test/setup-install-registry.test.ts` holds both directions. Gemini CLI as a
host is not supported. Two overlaps remain: workspace `.agents/skills` is also
Antigravity CLI reads `~/.gemini/antigravity-cli/skills`, and Google's
migration table lists `~/.gemini/skills` as an Antigravity global path too.
Each host's arm writes only gstack entries in its own directory, uninstall
removes only gstack-managed entries in each, and `--host auto` selects
Antigravity only for `agy` on `PATH` or an existing
`~/.gemini/antigravity-cli`, and Gemini CLI only for `gemini` on `PATH`,
never either for a bare `~/.gemini`. `test/setup-install-registry.test.ts`
holds every direction, including both hosts installed together. Gemini CLI is
its own host ("Gemini CLI" below). Two overlaps remain: workspace `.agents/skills` is also
where a project-local Codex install puts its Codex-flavored `gstack-*`
skills, which Antigravity will list in that repo, and the Antigravity 2.0 app
and IDE read `~/.gemini/config/skills`, which this host does not install
@@ -277,6 +280,46 @@ root's extra markdown files (`ETHOS.md`, `review/*.md`) as skills, the
partial-read behavior of `view_file` on long skills, `ask_question` option
handling, and an upgrade from an existing install.
## Gemini CLI (#1247)
Shipped as `experimental` (`hosts/gemini.ts`, `./setup --host gemini`, alias
`--host gemini-cli`). Verified against Gemini CLI 0.66.0 (its bundled
`docs/cli/skills.md` and `SkillManager.discoverSkills`) on 2026-10-31:
| Fact | Value | Source |
|---|---|---|
| User skills | `~/.gemini/skills/<skill>/SKILL.md`, and the `~/.agents/skills` alias, which wins inside the user tier | [Agent Skills](https://geminicli.com/docs/cli/skills/), `packages/core/src/skills/skillManager.ts` |
| Workspace skills | `.gemini/skills` and the `.agents/skills` alias, loaded only in a trusted folder (`/trust`) | same |
| Precedence | built-in < extension < user < workspace; same name: the higher tier wins | same |
| Discovery | `<dir>/<name>/SKILL.md`, one level deep; symlinked skill dirs are followed; the name comes from frontmatter `name` | same |
| Invocation | the model calls `activate_skill` after you approve; `/skills list` and `gemini skills list` show them | same |
| Tool names | `run_shell_command`, `read_file`, `read_many_files`, `write_file`, `replace`, `grep_search`, `glob`, `list_directory`, `ask_user`, `invoke_agent`, `activate_skill`, `enter_plan_mode`, `exit_plan_mode`, `write_todos`, `web_fetch`, `google_web_search` | 0.66.0 bundle |
| Context file | `GEMINI.md` | 0.66.0 bundle |
What the host does: skills in `~/.gemini/skills/gstack-*`, runtime root
`~/.gemini/skills/gstack` with `.source-path` for `/gstack-upgrade`, `name:`
equal to the `gstack-<skill>` directory, frontmatter limited to `name` and
`description` (1024-character limit), the Gemini model overlay, and a
one-paragraph tool glossary ahead of the preamble's STATUS rules. The glossary
also names Antigravity's `run_command`, `view_file` and `ask_question`,
because an Antigravity CLI that reads `~/.gemini/skills` sees these skills.
The render directory is `.gemini-cli/` (gitignored), not `.gemini/`, so this
repo's own trusted workspace never lists render output as skills. `--local`
is refused: it installs only Claude, and Gemini CLI reads workspace skills only
in trusted folders.
Overlaps: inside a trusted repo, a project-local Codex install's `.agents/skills`
(Codex-flavored `gstack-*` skills) is the workspace tier, which outranks
`~/.gemini/skills`, so Gemini CLI uses those there. gstack never writes
`~/.agents/skills`: that alias is shared with other tools and outranks
`~/.gemini/skills`. Antigravity's own install stays in
`~/.gemini/antigravity-cli/skills`.
Unverified without a Gemini API key on the build machines (why it is not
`full`): a live model run of any skill, `ask_user` option handling, and
plan mode with gstack's plan-review skills. Discovery is verified:
`gemini skills list` after `./setup --host gemini` lists the `gstack-*` skills.
## Instruction-only hosts and agent harnesses
Slate and GBrain have no install arm. OpenClaw and Hermes are harness hosts:
@@ -313,6 +356,7 @@ hosts/
├── gbrain.ts # GBrain
├── copilot.ts # GitHub Copilot CLI
├── agy.ts # Google Antigravity CLI
├── gemini.ts # Google Gemini CLI
└── index.ts # Registry: imports all, derives Host type
```
+1 -1
View File
@@ -50,7 +50,7 @@ send off the machine MUST write a hash-chained receipt to
polarity is per-class: fail-closed for sensitive sinks (brain-sync, memory-ingest,
gbrain-sync, telemetry, ngrok tunnels, mcp-verify, supabase-provision, and the
Memorable bridge's per-prompt memorable-recall hand-off), fail-open
+ stderr warning for user-facing ones (design OpenAI calls, update-check,
+ stderr warning for user-facing ones (design OpenAI and Gemini calls, update-check,
dashboards, git-class ops). The new-sink scanner in
`test/egress-receipt-wiring.test.ts` fails CI on an unreceipted `curl` /
`git push` / `fetch` to a non-loopback host unless the file carries a reasoned
+5
View File
@@ -195,6 +195,11 @@ registered as a short entry that tells the agent to read the skill's full
`SKILL.md` in `gstack/skills/`, a plain copy of every skill that OpenClaw
reads in pages (`.gstack-skills/` under `--skills all`).
Install, verify and remove: [hosts.md](hosts.md#agent-harnesses).
Agents in OpenClaw read gstack's skills but rarely run their start command, so
there is an opt-in [start hook](hosts.md#start-hook): `gstack-harness register
openclaw --start-hook` adds a `gstack-start` plugin that puts a one-line note on
every user turn, and grants it the conversation access OpenClaw requires for
`before_prompt_build` through `openclaw config set`.
The ACP artifacts in `openclaw/` are generated by `bun run gen:skill-docs --host openclaw`.
+1
View File
@@ -20,6 +20,7 @@ gstack/
│ ├── codex.ts, factory.ts, kiro.ts # Existing hosts
│ ├── opencode.ts, slate.ts, cursor.ts, openclaw.ts # IDE hosts
│ ├── hermes.ts, gbrain.ts # Agent runtime hosts
│ ├── copilot.ts, agy.ts, gemini.ts # GitHub Copilot CLI, Antigravity CLI, Gemini CLI
│ └── index.ts # Registry: exports all, derives Host type
├── scripts/ # Build + DX tooling
│ ├── gen-skill-docs.ts # Template → SKILL.md generator (config-driven)
+68 -18
View File
@@ -199,7 +199,7 @@ one duration-packed `--ci-plan`, 20 ordinary `--ci-run` shards plus a separate
exclusive-fixture shard, and a `--ci-verify` aggregate. CI shards still run on
independent machines without ordering unrelated jobs. Two workflow-level
constants in `free-tests.yml` set that size for every event: `FREE_SUITE_RUNNER`
(`ubicloud-standard-2`) and `FREE_SUITE_SHARDS` (`'20'`), which `free-plan`
(`ubicloud-standard-4`) and `FREE_SUITE_SHARDS` (`'20'`), which `free-plan`
resolves into the shards' runner and shard count. A `workflow_dispatch` run may
override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`) and
`free_shards` (1–40) for runner-sizing experiments only; its job summary says
@@ -1033,7 +1033,7 @@ repository content: its setup script and pull destinations must resolve inside
`--src`, and a name it lists under `pass` is forwarded only when the command
line grants it (`--pass NAME`, or all of them with `--trust-config`); `--pass`
never forwards `UBICLOUD_*`. All of this is pinned offline in
`test/ubicloud-runner.test.ts` against a fake API and a fake `ssh`, including
`test/ubicloud-runner*.test.ts` against a fake API and a fake `ssh`, including
a fixture whose fake API creates, so `run` and `verify` execute end to end.
`run` and `verify` start their setup, command and tier phases detached on the
@@ -1049,7 +1049,7 @@ start/pass/fail per tier under `UBI_CHECKPOINT_DIR/<run key>/`, so a resume on
a fresh VM reruns setup and only the tiers without a pass; `pregate-remote.sh`
adds its tree and planned-set digest to the key, so a rerun of
`gstack-pregate --stage tests --remote` reattaches and bills from the original
`created_at`. The fake VM in `test/ubicloud-runner.test.ts` runs these phases
`created_at`. The fake VM in `test/helpers/ubi-runner-fake-vm.ts` runs these phases
for real in a directory.
Under 8 cores (`gstack-doctor`'s `cores` row says so), run the full unit and
@@ -1542,7 +1542,7 @@ refresh to a maintainer.
`scripts/ubicloud/setup-free-suite.sh` and the Windows seed through
`windows-free-tests.yml`'s `record_durations` path. The recorder stays on
`ubicloud-standard-8` while the free-suite shards run on `FREE_SUITE_RUNNER`
(`ubicloud-standard-2`); both are Ubicloud standard runners. When either recording adds
(`ubicloud-standard-4`); both are Ubicloud standard runners. When either recording adds
or drops a test file, it force-updates the `bot/free-duration-seeds` branch,
opens or refreshes one seed PR whose body states the ratchet result, and
dispatches Free Tests, Windows Free Tests and Quality gate on it. Those
@@ -1647,9 +1647,11 @@ word list: `test/fixtures/arm-benchmark/` and its four children, `browse-judge/`
names under `paid-report-census/` and the `test/` subdirectories of
`state-matrix-6792/`. All of these are runner-side: no candidate's working
directory is one of them. Candidate-visible gaps are in the temporary roots
the harnesses create: `test/helpers/arm-benchmark-harness.ts` copies each fixture
into `os.tmpdir()/arm-<fixture>-<arm>-*`, so the candidate's working directory is
named after the arm rather than after a project; `test/skill-e2e-deploy.test.ts`
the harnesses create: `test/helpers/arm-benchmark-harness.ts` copied each fixture
into `os.tmpdir()/arm-<fixture>-<arm>-*`, so the candidate's working directory was
named after the arm rather than after a project (since GST-469 it is named after
the task's project: `hike-booking-*`, `notes-api-*`, `receipts-*`);
`test/skill-e2e-deploy.test.ts`
runs the /benchmark case in `skill-e2e-benchmark-*`, a root that carries a listed
word; `test/skill-e2e-test-value.test.ts` creates `skill-e2e-<id>-*` roots as the
candidate's working directory, and two of its ids (`review-test-value`,
@@ -1694,18 +1696,66 @@ own attempts. The arm gets:
**Reporting.** The no-gstack result is one row beside the variants in every
results table and every verdict, including when it wins. A comparison that
drops the row has not reported its result. The prereg check cannot see the
report, so the `report:` line states where the row goes and the reviewer of
the result holds the author to it.
drops the row has not reported its result. `gstack-eval-plan results --check
<prereg> <results.md>` enforces it: one table in the results file must hold a
filled row for every Arms identity (`EVAL_REPORT_NO_GSTACK_ROW` otherwise), and
no number is published before it passes.
**Precedent and its gaps.** `test/helpers/arm-benchmark-harness.ts` already has
a `without-skill` arm beside `with-skill`, and it is the closest existing
control. It falls short of this rule in three ways: each arm and task runs
once rather than as a panel; the judge (`armJudge` in
`test/helpers/llm-judge.ts`) scores each diff on its own, without `A`/`B`
labels; and the candidate's temporary folder is named after the arm
(`arm-<fixture>-<arm>-*`). Existing paid cases are not retrofitted by the
change that added this rule; an audit of them is tracked separately.
**Safety-rule and router evals: a descriptive third arm.** The W1 safety-rule
evals compare `GSTACK_SAFETY_ARM=rule` (the shipped rule) with `removed` (the
rule paragraph deleted), and the router failure-routing evals compare
`GSTACK_ROUTER_ARM=current` with `before` (the base branch's router). Both
variables also take `none` (GST-470): the same fixture, tools and user request,
with no skill excerpt, rule text, step file or installed skill. Any other value
throws, so a typo cannot run one arm under another's label. The `rule`,
`removed`, `current` and `before` prompts are byte for byte what they were
before `none` existed (`test/safety-none-arm.test.ts`,
`test/failure-routing-arm.test.ts`). To run the three arms as a comparison, run
the same case once per arm, each as its CI panel:
```bash
for arm in rule removed none; do
GSTACK_SAFETY_ARM=$arm bun run scripts/test-paid-shards.ts --tier gate --case safety-ship-stale-evidence
done
for arm in current before none; do
GSTACK_ROUTER_ARM=$arm bun run scripts/test-paid-shards.ts --tier gate --case router-failure-contradicted-cause
done
```
The arm variable is part of the shard-reuse environment, so one arm's result is
never reused for another. Report the three rows side by side in one results
table and pass `gstack-eval-plan results --check` on it.
**The descriptive exception.** Under `none` every behavioral check, the plain
`expect`s on model actions included, is recorded as an observation in
`none-arm-observations.jsonl` in the eval directory and on stderr
(`test/helpers/none-arm.ts`). It never fails the case and never writes
`contract-violations.jsonl`; harness preconditions (the fixture built, the CLI
ran, the session ended normally) still assert. This departs from "the same
panel" above because these contracts are gstack's own rules: a plain model was
never asked to keep them, so a failing `none` trial is the measurement, not a
regression, and a red verdict would block the gate on behavior nobody
specified. The router's `none` arm has no "routed to /investigate" metric,
since no skill is installed to route to; it records plain metrics instead:
whether the suite is green, whether an edit came before the first read, and
how many read-only calls came before the first edit. The first paid comparison
with these arms gets its own preregistration.
**Cases without a `none` arm.** `safety-pair-agent-block` and
`safety-ios-demo-ui-only` refuse `GSTACK_SAFETY_ARM=none` with the reason
(`NO_NONE_ARM` in `test/helpers/safety-rules.ts`). Their task exists only in
the skill: relaying /pair-agent's remote instruction block, and /ios-qa's demo
mode with its device routes. A plain prompt has nothing to relay and no device
interface to drive, so the arm would measure the prompt, not gstack.
**Precedent.** `test/skill-e2e-arm-benchmark.test.ts` runs a `with-skill` and a
`without-skill` arm (named `<model>#no-gstack` in its preregistration) as 3
pairs per task under a spend ledger and cap. A paired judge reads both diffs as
`A` and `B` in both orders, counts a win only when the orders agree, and
decides only after it passes calibration; the per-diff `armJudge` stays beside
it as the diagnostic. Candidate folders and git identity name a project, never
the arm. The model panel (`test/skill-e2e-model-panel.test.ts`) runs a
`<model>#no-gstack` arm beside each model (GST-468, GST-469).
## Running evals as an agent: detach
+35 -1
View File
@@ -248,7 +248,8 @@ a push included. The sequence that works unattended on Capy:
([base_red](ship-policy.md#base-red)): the lane is waiting, not failed. It
registers `gstack-base-red wait --pause <file>` as a background operation,
tells the controller which cases hold it, and on `BASE_GREEN` runs
`/ship --resume <record>`; no READY goes out on the conditional receipt.
`/ship --resume <record>`; no READY goes out on the conditional receipt,
except on a train with `gates` ([Merge trains for other repos](#merge-trains-for-other-repos)).
9. READY: `gstack-ship-queue ready --pr <n> --pregate <pregate.json> --screen|--no-screen`;
send the printed block.
10. After the slot call, the stamp commit keeps the pin's receipt; re-READY with the same
@@ -299,6 +300,18 @@ anything else goes to a repair lane. An infra-only census earns one re-dispatch.
backstop gate runs on every push to the base branch and freezes the queue when it finds
a tree no green train covered; one repair PR at its exact head clears the freeze.
**Wave PRs when the queue is long.** Every slot pays its own
stamp, its own round of required checks and its own version, one after another. When more
than 6 PRs are READY, `gstack-ship-queue wave plan` prints `WAVE_ADVISED` and suggested
area groups of 3 to 6 (`train build` prints the same line when handed more than 6 pins). The
controller asks the owner of each group's largest PR to integrate: `wave build` makes the
wave branch with every constituent merged unchanged, the integrator runs /ship once and
READYs the wave PR, which then takes one slot and one version. Constituent heads are frozen
once in; fixes go through the integrator, and a constituent that must change is dropped
(`wave build --wave <nonce> --drop <pr>`) and goes back to its owner. After the wave lands,
`wave settle` closes each constituent with a link. While the queue stays long, start new
work as area waves. The full flow is in [ship-policy.md](ship-policy.md#wave-prs).
**Slot 2 and later.** The base branch already carries the earlier slots' stamps, so a
stamp on the bare pin leaves the PR conflicting on the CHANGELOG. The lane merges the base
into its pin first (expect a CHANGELOG-only conflict; resolve it with `mergeChangelogs`
@@ -330,6 +343,27 @@ head while i merges); the severe race (several severe lanes told at once that th
urgent train is theirs, first complete READY takes it; never a race for a slot inside a
built train); whether a READY also carries a gate run id and tree hash.
### Merge trains for other repos
A repository without gstack's eval workflow declares `gates` in its train block
([ship-policy.md](ship-policy.md#train-gates)): its own CI workflows, a test
command on a Ubicloud VM, or both. Every prefix is gated, so a red train names
its culprit with no bisect and no rebuild. To turn one on, run `train init` in
a checkout, commit the printed block (and the workflow snippet) on the base
branch through a PR, then run `train doctor` until it reports no findings.
The controller's loop shortens to `train build --prs <pins>`, then
`train watch --train <nonce> --land`, which waits for every prefix run, reads
the verdict and lands each landable slot in order. Lanes READY with
`ready --pr <n> --no-screen`; there is no screen and no slot call. A lane whose
/ship paused on a red base READYs with `--base-red --fixer <pr>`, its fixer with
`--fixes-base-red <cases>`, and the train seats the fixer at slot 1. The `update` commit `train land` pushes to a PR
branch (the gated tree on [pin, main]) is the one exception to CREW rule 14's "never merge
the base into a PR": it is tool-made, recorded before the push, and pushed under the pin's
lease, like a stamped train's stamp commit. A peer crew
adopting this starts from
[captain/templates/merge-train-peer.md](../captain/templates/merge-train-peer.md).
## Approvals: three shapes
**A. The record.** Every lane keeps an `## Approvals` table in its report and PR body,
+6 -1
View File
@@ -241,7 +241,12 @@ keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and
clone too, through `GSTACK_CAPY_CLONED`).
2. Preflight: `git`, `curl`, `node`, `jq`, then Bun. The project's pins
(`--project <path>`, default the git toplevel of the current directory) are
read with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines,
read (a `--project` inside a repository resolves to that repository's git
toplevel, so `--project <repo>/<subdir>` means `<repo>`; outside a repository
it resolves to the directory's real path, and a value that is not a
directory, such as a repo slug, exits 2 with a usage error before anything
runs; the pins, the doctor and the install verdict all use the resolved
directory) with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines,
packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes
for this platform). CI exact pins for this platform are alternatives: a
repository may test several Bun versions (gstack's own CI pins 1.4.2 and
+5 -2
View File
@@ -145,8 +145,11 @@ archive bytes, not a verified response URL.
Image publication starts with the committed
[`build-inputs.json`](../lib/cso/images/build-inputs.json). Its reviewed revision
pins distinct native manifests for every stack and the SBOM generator. Each
profile records the inspected source tag and immutable index for provenance,
then `baseImages` keyed by `linux/amd64` and `linux/arm64`; Python additionally
profile records the inspected source tag and immutable index for provenance.
Every source tag (each base, Python's uv and the SBOM generator) must name an exact release (`<major>.<minor>.<patch>`, or
`<major>.<minor>` for PostgreSQL); a floating major or minor tag, `latest` or
another alias fails matrix generation with `FLOATING_<FAMILY>_SOURCE`. Each profile then records
`baseImages` keyed by `linux/amd64` and `linux/arm64`; Python additionally
records native uv manifests. CI fails if a source tag no longer resolves to the
reviewed index, a platform manifest is absent from that index, its image
configuration names another architecture, or an executable reports a different
+63
View File
@@ -24,6 +24,8 @@ neither does `./setup --local`.
**What it writes.** Copies in the harness's own skills folder, never edits to
`openclaw.json`, Hermes's `config.yaml` or any context file you did not name.
The one exception is the [start hook](#start-hook), which you turn on yourself:
it adds a plugin and enables it through the harness's own commands.
A context block is opt-in: `gstack-harness register openclaw|hermes|generic
--context <file>` writes the marked block into that file only, after printing
the file and its scope (`profile` inside the harness's own home, else
@@ -67,6 +69,30 @@ characters, the part of a description Hermes's skills index shows.
| Grok Bot, any chat-only skill library | nothing on disk | `gstack-harness register generic --print`, then paste the router and block into the app | ask it for a gstack skill | delete the pasted text |
| any other harness | the router in an Agent Skills folder you name, and/or a 600-byte routing block between `<!-- gstack:begin harness-routing -->` markers in a context file you name | `gstack-harness register generic --skills-dir <dir>` and/or `--context <file>`; `gstack-harness manifest generic` prints every skill as JSON | the harness's own skill list | `gstack-harness unregister generic` |
<a id="start-hook"></a>
### Start hook (OpenClaw and Hermes, opt-in)
Every gstack skill starts with a command in its Preamble (`gstack-skill-start`) that sets up the session: whether it runs unattended, questions, consent, upgrade checks. In OpenClaw and Hermes, agents read gstack's skills but almost never run that command (1 start in 29 chances, HARN-18). The start hook is a small `gstack-start` plugin that puts one note of about 210 characters on every user turn, telling the agent to run that command first, exactly as written, with no pipe.
It is off unless you turn it on, because turning it on changes the harness's own settings:
| | OpenClaw | Hermes |
|---|---|---|
| Plugin folder | `${OPENCLAW_STATE_DIR:-~/.openclaw}/extensions/gstack-start` (index.js, openclaw.plugin.json, package.json, note.txt) | `${HERMES_HOME:-~/.hermes}/plugins/gstack-start` (plugin.yaml, __init__.py, note.txt) |
| Hook | `before_prompt_build`, returning the note as `prependContext` | `pre_llm_call`, returning the note as `context` |
| Settings it changes, through the harness's own CLI | `openclaw config set plugins.entries.gstack-start.hooks.allowConversationAccess true` and `...enabled true` in `openclaw.json`. OpenClaw runs `before_prompt_build` from a plugin it did not bundle only with that grant, which also lets the plugin's hooks see conversation content (the plugin reads only its own note.txt) | `hermes plugins enable gstack-start` adds it to `plugins.enabled` in `config.yaml` (and creates `config.yaml` when there is none) |
| Check | `openclaw plugins inspect gstack-start --runtime --json` lists `before_prompt_build` under `typedHooks` | `hermes plugins list` shows `gstack-start` enabled |
**Turn it on.** `gstack-harness register openclaw --start-hook` (or `hermes`), or `gstack-config set harness_start_hook on` followed by `./setup`, or `GSTACK_HARNESS_START_HOOK=1 ./setup --host openclaw`. The first of these that is set decides: the `--start-hook`/`--no-start-hook` flag, then `GSTACK_HARNESS_START_HOOK` (`1`/`on` or `0`/`off`), then `harness_start_hook`, then the choice saved in the registration's ledger. Whatever decides, the choice is saved, so refresh and upgrades keep it. The hook goes only into the harness profile's own plugin folder; a registration with a custom `--skills-dir`, or OpenClaw run with `OPENCLAW_CONFIG_PATH` pointing at another config, gets no hook (`HARNESS_HOOK_UNSUPPORTED` when you ask for one). A hook you already turned on stays on when a later run happens to have `OPENCLAW_CONFIG_PATH` set.
**Check it.** The receipt and `gstack-harness status` show `start hook: active` (configured for new sessions; a running OpenClaw gateway or Hermes gateway picks it up when it reloads or restarts), `written` (files in place but not active, with the reason and the command that fixes it), `disabled` (you turned it off in the harness), `unchecked` (a run that left a hook already on alone, or `status --no-check`; `gstack-harness status` checks it) or `off`. When the run that turns the hook on (or `--start-hook`) leaves it inactive, `gstack-harness register` and `./setup --host` exit 4 after registering the skills (`HARNESS_HOOK_INACTIVE`). Later refreshes and upgrades try once more each time, without failing, until the hook is active or you disable the plugin in the harness.
**Turn it off.** `gstack-harness register openclaw --no-start-hook` (or `hermes`), or `gstack-config set harness_start_hook off` followed by `./setup`. Either turns the plugin off through the harness's CLI and removes its folder. You can also switch it off in the harness (`openclaw config set plugins.entries.gstack-start.enabled false`, `hermes plugins disable gstack-start`); gstack then leaves it off and never turns it back on unless you pass `--start-hook` again. `gstack-harness unregister` and `gstack-uninstall` remove it with the rest (Hermes only disables it when its folder holds an edited file or a kept `.edited-*` copy, so your edit survives), and print the harness command to run when the harness CLI is not on `PATH`.
**What it costs.** The note is about 50 tokens and lands on every user turn, and each copy stays in the session's history, so a long session pays more (mostly cache reads). HARN-18's panel measures the added cost per trial. OpenClaw also logs that it cannot verify where the plugin came from, because gstack writes it instead of installing it from npm or ClawHub.
**One owner.** A `gstack-start` folder this registration did not write stops the hook (`HARNESS_HOOK_OWNED_ELSEWHERE`): turn it off from the gstack install that wrote it, or move it aside.
OpenClaw's ACP route below is unchanged: native registration gives OpenClaw's
own agent gstack's skills; ACP spawns Claude Code with the full install.
@@ -137,6 +163,14 @@ Antigravity only when `agy` is on `PATH` or `~/.gemini/antigravity-cli` exists,
never for a Gemini-CLI-only `~/.gemini`. The Antigravity 2.0 app and IDE read
`~/.gemini/config/skills`, which this host does not install.
Gemini CLI loads gstack skills itself when a task matches (`activate_skill`,
after you approve); ask for `gstack-office-hours`, or list them with
`/skills list`. Setup writes only `~/.gemini/skills/gstack*` and never
`~/.agents/skills`, and `--host auto` selects Gemini CLI only when `gemini` is on
`PATH`. Inside a repo with a project-local Codex install, Gemini CLI uses the
Codex skills in `.agents/skills` (the workspace tier wins), and workspace skills
load only in trusted folders (`/trust`).
Outside reviews require the selected CLI to be installed and authenticated: Claude Code when using gstack in Codex, or Codex on other harnesses. External harnesses discover these commands as `/gstack-claude-code` and `/gstack-codex`; each harness omits its own wrapper. Explicit provider requests keep that provider. The existing `codex_reviews` setting controls automatic outside reviews where supported, regardless of the provider selected.
`/claude` has been renamed to `/claude-code`. Re-run `./setup --host <name>` to migrate managed installations, including other harnesses sharing the checkout. Setup preserves the previous installation if replacement generation or installation fails and prints repair instructions.
@@ -207,6 +241,35 @@ receipts record the gateway host. On Codex, `/design-shotgun` generates mockups
with Codex's built-in `$imagegen` skill instead, so it needs no
`OPENAI_API_KEY`; `$D` still builds and serves the comparison board.
### Gemini when there's no OpenAI key
With no OpenAI key, `$D` uses Google's Gemini instead, so people without an
OpenAI key (or with an OpenAI organization that hasn't passed ID verification)
get real mockups instead of wireframes. A Gemini key from
[Google AI Studio](https://aistudio.google.com/apikey) is free to create:
`export GEMINI_API_KEY=...` or `$D setup --provider gemini`.
| | OpenAI | Gemini |
|---|---|---|
| Picked when | an OpenAI key is set (always wins) | no OpenAI key, or `GSTACK_DESIGN_PROVIDER=gemini` |
| Key | `~/.gstack/openai.json`, then `OPENAI_API_KEY` | `~/.gstack/gemini.json`, then `GEMINI_API_KEY` |
| Images | `gpt-5.5` driving `gpt-image-2` | `gemini-nano-banana-2.1` (`GSTACK_DESIGN_IMAGE_MODEL`) |
| Screenshot reading | `gpt-5.5` | `gemini-3.8-flash` (`GSTACK_DESIGN_MODEL`) |
| Egress ledger sink | `design-openai` | `design-gemini` |
`GSTACK_DESIGN_PROVIDER=openai|gemini` picks one explicitly; when an OpenAI key
is also set, `$D setup --provider gemini` saves the key and prints that export
as its last line, because OpenAI still comes first otherwise. A failure on the
picked provider is never retried on the other one; an OpenAI
organization-verification error ends with the line that switches to Gemini.
On Gemini, model overrides must be `gemini-…` names, `--size` must be
1536x1024, 1024x1024 or 1024x1536 (sent as an aspect ratio), `--quality` is
ignored, and `OPENAI_BASE_URL` does not apply. On Google's free tier, Gemini
API prompts and images may be used to improve Google's products; `$D` says so
once per run. `bun run design/scripts/live-gemini-check.ts` checks a Gemini
key against the defaults (free model lookups, then one image and one vision
call).
## Adding a host
**Want to add support for another agent?** See [docs/ADDING_A_HOST.md](ADDING_A_HOST.md).
+155 -7
View File
@@ -88,6 +88,8 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)) |
| `remote_gate_runs` | `{ profiles, inventory }`: the run profiles that together clear `pregate.json/remote_gate`, and the command that prints the remote gate's test files, one per line, which their runs must cover (see [run evidence](#run-evidence)) |
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
Only the `origin/<base>` copy or the user-owned file below is honoured, so a
@@ -96,8 +98,8 @@ keeps `.gstack/` out of git (a shared repo where gstack is one contributor's
tool) can hold the same file at `<state root>/projects/<slug>/pregate.json`. The
state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`,
else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `<slug>` is what
`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository
(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none
`~/.claude/skills/gstack/bin/gstack-slug --get REPO_SLUG` prints in the repository
(`GSTACK_PROJECT_SLUG` overrides it; a `.gstack-project` marker does not). It is read only when the base carries none
(under `--policy-from head` it is the trusted policy the head file is compared with), it lives outside the repository so a PR still
cannot reach it, and the record names it (`policy user <path>`) and fingerprints
its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
@@ -112,9 +114,10 @@ the caller's environment, never the checkout's `.env`: the bin's shebang passes
`--no-env-file`, and a bare `bun gstack-pregate` launched beside any `.env` file
refuses (`POLICY_SOURCE_UNAVAILABLE`). `verify` reads both paths from the
record, not from its own environment.
It covers `gstack-pregate run` only: `sealed` and `price_file` are read from the
base branch's copy, so the user file refuses them (`PREGATE_CONFIG_INVALID`)
rather than holding a policy nothing reads. A user file that exists but cannot
It covers `gstack-pregate run` only: `sealed`, `price_file`, `run_profiles` and
`remote_gate_runs` are read from the base branch's copy, so the user file refuses
them (`PREGATE_CONFIG_INVALID`; an empty `run_profiles` is accepted) rather than
holding a policy nothing reads. A user file that exists but cannot
be read, or a symlink on its path that points nowhere, refuses the same way
instead of falling back to defaults. A base copy git cannot read (and
`git ls-tree` cannot confirm is absent) refuses with `POLICY_SOURCE_UNAVAILABLE`
@@ -175,7 +178,11 @@ Two stages with separate clocks, both printed:
simply not proven.
- `secrets`: `gstack-redact` over the branch's added lines (merge-base to the
working tree, untracked files included). HIGH, or MEDIUM in the `secret`
category, fails; other MEDIUM findings warn. Cannot be downgraded.
category, fails; other MEDIUM findings warn. Cannot be downgraded. A
changed Python (`.py`, `.pyi`) or Dart file is scanned whole and only
findings on its added lines count, so a line added inside an existing
`"""` or `'''` string is still read as a literal; its rows give the file's
line number (`app.py, line 10`).
- `strays`: a new file at the repository root, a new top-level directory, or a
scratch name (`zz-*`, `tmp*`, `scratch*`) anywhere.
- `literals`: for every changed exported constant or named timeout/retry
@@ -435,7 +442,7 @@ the selected local files as a `requires-remote` obligation with the command to
run, `pregate.json/remote_gate` (the declared command) or
`test_backend/<backend>` (gstack's `scripts/ubicloud/ubi-runner.sh run --src
<repo> -- '<runner> <files>'`). Without either, the tier runs locally under
the supervision above. Close the obligation one of two ways:
the supervision above. Close the obligation one of three ways:
- after running it, `gstack-pregate clear --remote pregate.json/remote_gate
--receipt <file>` with the gate's output, validated as `--remote` validates it
@@ -444,6 +451,10 @@ the supervision above. Close the obligation one of two ways:
accepted and recorded, see [below](#remote-gate-stamp-only)), or
`gstack-pregate clear --remote test_backend/<backend> --run-url <url>`, which
has no machine-checkable receipt and is recorded as `attested:<url>`; or
- when CI already ran the full suite on this tree, `gstack-pregate clear --remote
pregate.json/remote_gate --run <id|url> [--run …] --allow-repo-commands`, one
verified GitHub Actions run per `remote_gate_runs` profile (see
[run evidence](#run-evidence)); or
- `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`,
which runs the declared `remote_gate` now (under `--policy-from head`, still the trusted copy's gate;
a `test_backend` without `remote_gate` cannot be run this way). The gate runs
@@ -611,6 +622,129 @@ Every refusal line starts with a reason code:
| `PREGATE_REMOTE_INFRA` | 4 | create, setup, SSH or the stop deadline failed, or the runner's `UBICLOUD: left=` line is not `0`; the VM, its teardown line and `spent=` follow |
| `PREGATE_REMOTE_INTERRUPTED` | 130 | TERM or INT; ubi-runner tears the VM down on its own |
<a id="run-evidence"></a>
## Run evidence: one full suite per tree (`clear --run`)
A repository that runs its full suite in CI and again as its remote gate on the
same tree paid twice for one result (gbrain: a 30–45 min Ubicloud gate, then an
18–20 min full-ci dispatch, every serial slot). `clear --run` lets a passed
GitHub Actions run stand in for the gate, on the runner's own record and never
on prose: a pasted URL is only a pointer the verifier resolves.
**Declare it on the base branch.** In `.gstack/pregate.json`:
```json
{
"run_profiles": {
"test": { "workflow": "test.yml", "events": ["workflow_dispatch", "push"], "jobs": ["full-suite"], "artifact": "full-suite-tree" },
"e2e": { "workflow": "e2e.yml", "events": ["workflow_dispatch", "push"], "jobs": ["e2e-full-suite"], "artifact": "e2e-full-suite-tree" }
},
"remote_gate_runs": { "profiles": ["test", "e2e"], "inventory": "bash scripts/pregate-remote-gate.sh --list" }
}
```
Profiles are read only from `origin/<base>`, so a pull request can tighten
them but never relax them. That covers the profile, not the suite it names: the
workflow file a run executed and the inventory command are the branch's own, as
the remote gate's script already is, so a diff to either is reviewed like any
other gate change. Each profile names the workflow file, the events it
accepts, every job that must have passed (each exactly once), and the artifact
holding the run's `tree.json`. `max_age_hours` (1–168, default 24) bounds how
old the run may be, and `os` (`Linux`, `macOS`, `Windows`) requires
`tree.json` to record that runner OS. Each workflow uploads `tree.json` from
its own job: `run_id`, `run_attempt`, `event`, `sha` (or `head_sha`),
`tree` (`git rev-parse HEAD^{tree}` as tested), `files` (every test file the
run executed), `tests` (the count) and, with `os`, the runner OS.
**What `clear --run` checks** (`lib/run-evidence.ts`), for every run, online,
once:
- The run belongs to this repository and ran for a same-repository head. Its
workflow file is the profile's, its event is `push` or `workflow_dispatch`
(a `pull_request` run tests the merge commit, not the head tree, so no
profile may accept it), and it completed with success. The API answers with
the newest attempt, so an earlier green attempt under a newer red one is
refused, and the run is read again after the evidence is collected: a new
attempt meanwhile is `run_changed`.
- Every job the profile names appears exactly once in that attempt and
succeeded. Jobs and runs are read across every page.
- `tree.json` (an artifact of at most 8 MiB) names this run, attempt, event and
head (`sha` and `head_sha`, each checked when present). It is not a reuse
record (a reuse is never evidence). Its tree equals the tree git computes for
the run's head commit. `files` is a list of paths and `tests` a
non-negative integer when present; without `tests` the file count stands in.
- No run of the same workflow on that head, or on pregate.json's own head,
whose newest attempt started at or after the second this run was created
failed, was cancelled or is still running (`run_started_at`, so a red re-run
of an older run counts, a run started in the same second counts, and
re-running this run does not hide a failure in between).
- The run finished within `max_age_hours` (its jobs' latest `completed_at`,
else the run's `updated_at`), and on the profile's OS when it names one.
Re-running only the failed jobs refuses (`artifact_mismatch`) unless the job
that uploads `tree.json` re-runs too, because that file must name the
newest attempt. The 8 MiB cap is read from the artifact's zipped size before
download, so a workflow on the branch itself can still unpack a larger file
into the temporary directory.
Then, for the lane:
- pregate.json was recorded against this repository's base, whose profiles
apply (`base_mismatch` otherwise).
- Each profile gets its own run (a run id given twice is a usage error), and
every run verifies under its profile. The assignment is searched, so a run
valid for two profiles cannot starve the other. A lane with a profile needs
one run; `pregate.json/remote_gate` needs one per `remote_gate_runs`
profile.
- All runs tested one tree: pregate.json's tree, or a stamp-only predecessor of
it under exactly the rule [above](#remote-gate-stamp-only) (a policy whose
`stamp_sensitive_gates` names `tests` refuses that substitution).
- Every run executed something (`no_tests` names one that did not), and the
runs' `files` together cover the obligation's selected test files, plus, for
the remote gate, every file the `inventory` command prints. The inventory is
a pin-file command, so it runs only with `--allow-repo-commands`, and only
while the working tree is pregate.json's tree (checked before and after it
runs); an empty or failing inventory refuses, and it runs under `pipefail`,
so a pipeline whose first command fails refuses too.
- Every run is read once more after the inventory: a run that moved to a new
attempt or stopped being a success meanwhile refuses (`run_changed`).
Any API or git error refuses. Each refusal prints `REUSE: refused
reason=<code> — …` as `PREGATE_RECEIPT_INVALID`, and a run that fails
verification is named with its own reason (`fork_run`, `event_mismatch`,
`run_not_success`, `job_missing`, `artifact_mismatch`, `artifact_reused`,
`tree_mismatch`, `newer_run_vetoes`, `run_changed`, `run_stale`,
`os_mismatch`, `api_error`, …); the clear-level codes are `base_mismatch`,
`no_verified_run`, `tree_mismatch` (runs on different trees),
`tree_not_stamp_only`, `no_tests`, `inventory_failed`, `inventory_empty`,
`inventory_uncovered` (the remote gate) and `tests_uncovered` (a lane's own
selected tests). A wrong run count, a repeated run or a lane with no
profile is a usage error (exit 2), not a REUSE refusal.
Accepted, `clear` prints `REUSE: accepted <lane> <receipt>`, and the receipt
reads `remote-gate tree=<t12> tests=<n> runs=<id>/<attempt>,… profiles=<p>,…
heads=<h12> verified=<time>` (with `accepted-for=` and the substitution for a
stamp-only predecessor). A lane with its own profile gets `run tree=… runs=…`
the same way; for such a lane `--run-url` is verified as `--run` is, and
`--bundle-lane` is refused. A lane with no profile keeps today's `--run-url`
recording. When the base config or the GitHub origin cannot be read, `--run`
fails, and so do `--run-url` and `--bundle-lane` unless the base was read and
shows the lane has no profile; then the lane is recorded as before, with a
warning naming the cause. `gstack-ship-queue ready --dispatch-ci` still
records its dispatched runs by URL; routing it through the verifier is the next
change (P3b), so a repository that declares lane profiles clears those lanes
with `gstack-pregate clear --run`.
**`verify` stays offline.** The check ran at `clear`. `gstack-pregate verify`,
and `gstack-ship-queue ready` through it, refuse a run receipt whose `tree=`
is not pregate.json's tree unless a substitution for it is recorded. They make
no network call, so GitHub's availability and the run's age never sit on the
merge path.
**What is never reusable.** Security and advisory scans (osv, audit,
gitleaks) depend on the day, not on the tree, so they are not run profiles:
they run on every head as before. Pull-request required checks always run
fresh.
<a id="guards"></a>
## Guards and the lane-done rule
@@ -672,6 +806,20 @@ those, each with its scope and its reason:
the entry is inert for that finding and the row says so (`allowlist[0]
env.kv not tested on env.kv (<file>): the scanner gave no position for its
pattern; the finding is kept`) rather than silently keeping it.
- The position is where the flagged value starts, not the name before it.
For `session: HttpSession` in a YAML file the pattern is `HttpSession`;
`session: HttpSession` never matches there. An entry that names a finding
by id and path, and whose pattern matches the line but not at the value,
prints `allowlist[0] env.kv pattern did not match env.kv (<file>, added
line N, col C): …; the finding is kept`.
- In Python and Dart source, a parameter or field annotation
(`session: HttpSession,`) and a dict entry or Dart named argument that reads
a name (`"password": request.password`) are code, not credentials, as they
are in TypeScript (#3630). Python's keyword form (`password=request.password`)
still reports. The same line inside a `"""` docstring is text and still
reports; allowlist it by its value. The pre-push hook sees only added lines,
so it scans Python and Dart lines without this exemption and may still warn
(MEDIUM, non-blocking) on them.
The `secrets` row says `N allowlisted (<ids>)` and lists each allowlisted
finding with the entry (`allowlisted env.kv (evals/receipts/t1.json, added line
+16 -14
View File
@@ -34,21 +34,23 @@ needed, because every skill reads `PROACTIVE` from `gstack-skill-start` at the
start of each run and the router (`/gstack`) applies it to its routing rules. A
value gstack does not know (a hand-edited `proactive: maybe`) reads as `core`.
Two surfaces follow the setting only in part. The first is the `## Skill routing`
section gstack offers to append to your project's CLAUDE.md. Under `off` the offer is
not made, because those rules are the auto-invocation you turned off (a section
already in CLAUDE.md stays; delete it yourself). Under `core` the offered
section still lists every routing rule and tells the host to invoke any
matching skill. If you accepted it before the `core` default, trim that section
to the four core rules (or delete it and let gstack offer it again); making the
offered section follow `core` is tracked in #3446.
The `## Skill routing` section gstack offers to append to your project's
CLAUDE.md follows the setting too. Under `core` it lists only the four core
rules and says the other skills run when you type them or a running skill
hands off to them; under `all` it lists
every routing rule; under `off` it is not offered, because those rules are the
auto-invocation you turned off. gstack offers the section once per project and
never edits it afterwards. So a section you accepted earlier, or under another
setting, stays as it is: trim it to match (the core four are /office-hours,
/review, /qa and /ship), or delete it and let gstack offer it again.
The second is the full skill descriptions, which a `--catalog-mode=full` build
and the non-Claude hosts keep. Non-core skills still say "Proactively suggest
when ..." there, and that text does not change with the setting, so a host
that picks skills from those descriptions can still reach them under `core`.
The default Claude build uses one-line descriptions without that sentence.
#3446 tracks that too.
Skill descriptions cannot change with the setting, so they are written for the
`core` default. Only the four core skills say "Proactively suggest ..." in the
full descriptions that a `--catalog-mode=full` build and the non-Claude hosts
keep. The other skills keep their "Use when asked to ..." sentences, which
describe asks. On those hosts, `all` therefore loses the proactive hint the
non-core descriptions used to carry; the router's routing rules still apply when
`/gstack` runs.
gstack asks once, after the telemetry question, which of the three you want;
if you stored `off` before that, it takes the answer as given and does not ask.
+31 -5
View File
@@ -271,8 +271,9 @@ the specialists, audits and watchers at the host default too
### Behavior changes
- Pass 2 after a blocking fix on a diff below full depth, with a fix range of at
most 150 runtime lines, is a [scoped pass](#scoped-pass): a fix-check, the Red
Team and the native adversarial pass on the range instead of the specialist army.
most 150 runtime lines, is a [scoped pass](#scoped-pass): a fix-check and the Red
Team on the range, plus the native adversarial pass (on the range once Step 11 has
run, otherwise on the whole branch), instead of the specialist army.
- A tests-only diff (runtime 0) no longer dispatches the Testing specialist: the
core checklist and the gate scan review it.
- Every depth dispatches one batched specialist instead of one reader per
@@ -375,7 +376,8 @@ The receipt's summary line prints `passes=<n> blocking_found=<k> deferred=<m>`,
which is what a scorecard tracks (target: a median of 1 pass per PR). A
receipt carried across a base merge or a test-only fix
([ship-policy.md#receipt-carry](ship-policy.md#receipt-carry)) keeps these
counters, because the reviewed product diff is unchanged.
counters, because the reviewed product diff is unchanged or its only
difference passed a scoped review.
## The scope knob
@@ -467,7 +469,7 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
3. `collect` hashes each reader's raw output and appends its findings to
`merged.jsonl` with the label as written (a JSON line's `severity`, or the most
severe of a prose line's Critical, High, Medium, Low, P0-P3, FIXABLE or INVESTIGATE
wherever they stand, headings and table rows included; a top-level list item under a
wherever they stand outside a negation (below), headings and table rows included; a top-level list item under a
label-only heading takes the stronger of the two). A `Severity:` or `Blocks:` line
continues the finding above it until a list item, heading, bold paragraph opener or a
second line for the same field starts another: `Severity` raises its label,
@@ -476,6 +478,15 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167). An unnumbered, unlabeled bullet with no file:line
under a paragraph finding whose Severity or Blocks line has not come yet is that finding's detail, so the field line after it
lands on the finding; a numbered item is its own finding.
A label a negation word governs ("found nothing CRITICAL", "no new **High** or Critical issues", "No P0/P1 issues":
no, nothing, not, none, zero, without or never, optionally followed by new, remaining, other, further, additional, more
or at, through Markdown, and carried to labels joined by a comma, slash, or, nor or and) is no finding unless it opens a
field (`High:`, GST-766). A line whose labels are all negated closes the open finding and heads the next, so a finding
worded around a negated label still arrives through its Severity or Blocks field. Each prose finding's line also
records the Severity and Blocks its own text states as `stated`, beside the collected label and never in its place;
a classified finding that carries `stated` shows it in its deferred-review issue next to the label ("CRITICAL (the
reviewer wrote Severity: Low, Blocks: no)"). /ship's Step 9.4 does not copy `stated` into its findings file yet: that
instruction lives in a reviewer brief that changes only after a no-loss replay. What blocks is unchanged.
A phantom finding costs a fold; a lost one costs the gate.
A reply with no finding counts as a review only with the clean marker its own brief asks for: `NO FINDINGS` alone on a
line for the specialists, the batched specialist, the targeted reader, the Red Team and fix-check, the `Recommendation:`
@@ -513,11 +524,26 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
create whose result was lost by its title and never starts a reader twice; a
missing or doubled title stops the pass. On Capy the pass runs as one call to the
checked-in program `lib/capy-review-pass.js`, served by `program` and loaded
unchanged by the gstack Capy skill.
unchanged by the gstack Capy skill. A full repeat pass takes `--diff-base` (RANGE_BASE; a scoped pass, none), a
fix-check plan renders its reader from `--fix-list`, `--hunks`, `--tests` and `--callers`,
`--audit-only` runs just the `--extras` readers (Step 14.5's docs audit), and the fork
point is found against `origin/<base>` first, as preflight does.
7. Every brief, audit readers' included, ends with a workspace paragraph naming the
checkout the bins run in (GST-482): a reader on a shared machine starts in the
machine's working directory, so it is told to `cd` there on every command, to give
every file tool an absolute path under it, and to start its reply with
`REVIEWED_TREE: <HEAD^{tree}> <toplevel>` measured there. The paragraph follows the
section's render, which stays byte for byte. `collect` and `mark --state collected`
compare that line with the fingerprint recorded at render: a missing or foreign line is
`COLLECT_WRONG_TREE`, a later commit in the same checkout `COLLECT_TREE_MOVED`; nothing
is merged, the reader becomes `failed`, and `verify` reports `NOT_COLLECTED`. An
outside voice's tree failure is recorded `unavailable` only when its reply holds no finding. The line catches a reader that
worked elsewhere by accident; it is not proof of reading, since diff text can predict it.
## Where this is enforced
- `lib/review-brief.ts`, `lib/review-ledger.ts`, `lib/review-pass.ts`, `lib/capy-review-pass.js`, `bin/gstack-review-dispatch`: [verbatim dispatch](#verbatim-dispatch) (`test/review-dispatch.test.ts`, `test/review-pass.test.ts`).
- `lib/ship-parts.ts`, `bin/gstack-ship-parts`: the section text each /ship phase uses, returned once per run inside the phase program before it; `next --resend` sends an unreceived batch again (`test/ship-parts.test.ts`).
- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=<diff-base>`: the merge base on pass 1, `RANGE_BASE` from pass 2 on) beside its plan's `DIFF_CMD` excludes, because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above.
- `ship/sections/review-army.md` Step 9: the plan's `REVIEW_DEPTH`, `GATE_SCAN` and `--depth-floor` on every run, the depth fields of the review-log row, passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way.
+1 -1
View File
@@ -41,7 +41,7 @@ unless `--label` is given.
**Counting calls.** `gstack-ship-budget calls --events <file> [--since <iso>] [--until <iso>]` reads
the host's transcript items (a JSON array, or `{ "items": [...] }` as Capy's `tools.thread.get`
transcript returns) and prints
transcript returns; a leading `~` or `~/` in `<file>` means the home directory) and prints
`CALLS: total= decision= keepalive= idle_gaps_over_300s= max_gap_s= first= last= source=`. Every tool
item is one parent call; a `wait`, or a program that only reads status (plus at most one bash for its
note), is a keep-alive, and a gap over 300 s between calls outlives the prompt cache
+215 -11
View File
@@ -60,7 +60,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
| `review_scope` | `frozen` \| `diff-plus-blocking` \| `all` | `frozen` | The finding scope of `/ship`'s review loop; see [review_scope](#review-scope). `frozen` fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. `diff-plus-blocking` is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. `all` is the earlier loop: every finding is fixed on every pass. Needs `min_gstack_version` when not `frozen`. |
| `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. |
| `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base <base>` (the caps come from `origin/<base>`'s policy, never the branch's copy; `--policy <file>` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: <path> <bytes>/<max> ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. |
| `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
| `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
| `base_red` | `{ setup, select, timeout_sec }` or `null` | `null` | How `/ship` Step 5 proves a failure is the base branch's own before pausing on it; see [base_red](#base-red). Read from the base revision only. Needs `min_gstack_version`. |
| `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. |
@@ -345,13 +345,14 @@ The surface is off unless the base revision's `.gstack/ship-policy.json`
declares a `train` block. Without one, `train …` and `verify --train` refuse
with `QUEUE_TRAIN_OFF`, and `ready --pr <n>` prints exactly the per-PR READY
block above (`--screen`, `--no-screen` and `--broad` are ignored with a
warning on stderr). A block for gstack itself would read:
warning on stderr). A repository without gstack's eval workflow declares
[`gates`](#train-gates) instead. A block for gstack itself would read:
```json
"train": {
"gate_workflow": "evals.yml", "free_workflow": "free-tests.yml",
"full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record",
"max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid",
"max_size": 5, "fill_hours": 1, "screen_selection": "gstack-paid",
"trial_reservation_usd": 1.5
},
"min_gstack_version": "<the version that introduced train>"
@@ -439,6 +440,199 @@ covers main only when its exact tree is some S_i of a green record (plain
T_i records still cover modulo stamps). A dropped slot changes every later
version, so the train is rebuilt, as today.
<a id="train-gates"></a>
### Trains for any repository: `gates`
A repository without gstack's eval workflow declares its own gates instead of
`gate_workflow` and the other gstack-evals keys. Such a train gates every
prefix, not just its tip, so a red train names its culprit without a bisect:
```json
"train": {
"gates": [
{ "kind": "workflows", "workflows": ["ci.yml"], "required_jobs": ["ci.yml/test", "ci.yml/lint"] },
{ "kind": "command", "command": "bun test", "timeout_sec": 1800, "spend_cap_usd": 3 }
],
"land": { "merge_method": "squash", "required_checks": ["test", "lint"] },
"max_size": 5, "fill_hours": 3
},
"min_gstack_version": "1.92.0.0"
```
A block with `gates` holds exactly these four keys; a gstack-evals key beside
them is refused, and a block without `gates` is the gstack-evals kind and
behaves exactly as above. `min_gstack_version` must be at least
`TRAIN_GATES_KEY_VERSION` (lib/ship-policy.ts), so an older gstack refuses the
policy by name. `max_size` is at most 5, because each gate runs on k+1
prefixes.
- **`workflows` gates.** Each workflow is dispatched on a pushed
`train-prefix/<nonce>/t<i>` ref with a `nonce` input; the train records the
nonce before it sends the dispatch and adopts only the workflow_dispatch run
whose run-name carries `[eval-bg <nonce>]`, never a run found by commit.
A prefix is GREEN only when that run tested the prefix head exactly, every
`required_jobs` entry (`<workflow file>/<job name as GitHub reports it>`)
concluded success and the run concluded success. With `record` set (an
artifact name), the run must also upload a JUnit XML or `{ "tests": [...] }`
JSON file reporting at least one executed test, and failed test ids become
the red cases. A red run gets one `gh run rerun --failed`: green on the
rerun is GREEN with a `FLAKY:` line, red again is real, and both attempts'
jobs stay in the record. `post_merge_checks` names checks that must succeed
on main after each land before the next slot lands (`verify --post-merge
<sha> --checks <names>`; `train land` checks the previous slot's merge and
refuses the next slot until they are green, and `train watch --land` runs it
between slots). A workflow whose concurrency
group is not keyed by `github.ref` (or the nonce) gets one prefix at a
time.
- **`command` gates.** Each prefix is checked out clean and detached and runs
`command` on a Ubicloud VM through scripts/ubicloud/ubi-runner.sh
(standard-16, `UBI_OWNER` required). The VM's `git write-tree` must equal
the prefix tree, and exit 0 is GREEN. Only names in `pass_env` reach the VM
(`UBICLOUD_*` never). Each run reserves its timeout plus 10 minutes at the
list price, and each command gate's `spend_cap_usd` covers every run of that
gate in the train, reruns included (3 dollars covers six prefixes and one rerun at 1800 s; `train init`
prints that default and `train doctor` flags a cap below a full train). The
next run past the cap refuses (`QUEUE_TRAIN_SPEND_CAP`); raising the cap is a
policy commit that moves main, so it is followed by a new train.
**The verdict.** T0 red is red on main (`QUEUE_TRAIN_RED_ON_MAIN`, fix
forward). Otherwise the first non-green prefix decides: incomplete stops the
train, red with a later prefix green is unattributed (repair or a new order,
never an unchanged rebuild), and red with every later prefix red names its
slot as the culprit (`QUEUE_TRAIN_RED`); the slots before it may land.
`train bisect` and `train rebuild` refuse such trains, since every prefix
already has its verdict. A green T0 run of the same gate at main's exact
commit in another train is reused instead of run again.
**Base-red PRs.** A lane whose /ship paused on a red base (its receipt carries
`base_red`) READYs with `ready --pr <n> --no-screen --base-red --fixer <pr>`:
the condition must have been classified on today's base and not have expired.
The fixer READYs with `--fixes-base-red <case,...>`. The fixer rides at slot 1
(moved there unless `--order` says otherwise, which then refuses), T0 may be
red on exactly the fixer's cases and nothing else, and every conditional PR
behind it is gated on its own prefix with nothing excluded. A conditional PR
without its fixer at slot 1, or on a gstack-evals train, refuses
(`QUEUE_TRAIN_BASE_RED`, `QUEUE_BASE_RED_NO_TRAIN`).
**Landing.** `train land` publishes T_i, or S_i when the train stamps
(`changelog_fragments`, or `gates` with `queue_mode: stamp-at-merge`), with
main's tree equal to the prefix before it. It records the mode, and the update
commit when there is one, before its first remote effect:
1. `direct`: `gh pr merge --<merge_method> --match-head-commit <pin>`, used
only when branch protection is readable and not strict, the pin merges onto
main as the gated tree exactly, and the pin's own checks (every
`required_checks` name) are green.
2. `update`: the gated tree committed on [pin, main], pushed under the pin's
lease, its checks waited for, then merged at that commit. It is forced for
a stamped train, strict or unreadable protection, a conditional PR, a merge
that resolved release files, and a pin whose checks are red. `rebase`
always refuses (`QUEUE_TRAIN_LAND_METHOD`): it would replay commits, and
the replayed tree is not the gated one.
Required checks are `changelog_fragments.required_checks`, else
`train.land.required_checks`. Fork heads refuse. Either way main's tree must
be the prefix afterwards, or the queue freezes as above; the backstop
dispatches each gate workflow on main by nonce. `train watch --train <nonce>
--land` runs build, verdict and every landable slot's land in order, and stops
at the first refusal.
**Setting one up.** `train init [--command "<test command>"]` reads branch
protection, the base's workflows and the open PRs, and prints a block mapping
each required check to its workflow job, the `nonce` input, run-name and
concurrency snippet each gate workflow needs, and the land mode slots will
use. It recommends `changelog_fragments` when open PRs bump the version, and
refuses (`QUEUE_TRAIN_INIT_REFUSED`) only when they do and the policy neither
declares fragments nor stamps at merge. After the block lands on the base,
`train doctor` re-checks it: a missing workflow, nonce input or run-name, a
required job no workflow defines, a concurrency group not keyed by ref, a push
trigger that matches `train-prefix/` refs, a workflow never green on the base,
a command gate cap below what a full train reserves, a merge method GitHub has
turned off, and `rebase` as the merge method (`QUEUE_TRAIN_DOCTOR_FINDINGS`).
Neither command writes anything.
<a id="wave-prs"></a>
### Wave PRs: several PRs in one slot
When the queue is long, PRs that touch the same part of the code travel
together as one **wave PR**: one integrator branch, one /ship, one READY, one
train slot and one version (Garry's rule R46, 2026-10-31: "consolidating
functional areas into larger wave PRs ... so we can land them faster with fewer
test passes"). Each slot otherwise costs its own stamp commit and a full round
of required checks before it merges, one slot after another, so a wave of
three saves two of those rounds and two versions.
```bash
gstack-ship-queue wave plan # read-only: QUEUE, WAVE_ADVISED, WAVE_GROUP lines
gstack-ship-queue wave build --area ship --prs 41@<sha>,42@<sha>,43@<sha>
gstack-ship-queue wave build --wave <nonce> --continue # after resolving a conflict in the printed worktree
gstack-ship-queue wave build --wave <nonce> --drop 42 # rebuild without #42 as a new wave
gstack-ship-queue wave build --wave <nonce> --release # end the wave unlanded; its PRs ride alone
gstack-ship-queue wave status [--wave <nonce>]
gstack-ship-queue wave settle --wave <nonce> [--dry-run] # after the wave PR merged
```
- **When.** `wave plan` counts the live READY PRs (open, head equal to their
READY receipt). Over the trigger (6) it prints `WAVE_ADVISED` and suggested
groups of 3 to 6: PRs that change a common file always share a group (their
conflicts are resolved once), each group takes its largest PR's functional
area (a skill directory, the first word of a `lib/`, `bin/` or `test/`
module name, or `docs`), smaller groups ride solo, and the integrator is
the PR with the most changed lines. `train build` handed more than 6 pins
prints the same `WAVE_ADVISED` line. It is advice: urgent and repair PRs
still ride alone. While the queue stays long, plan new work as area waves
from the start, so each piece skips its own /ship.
- **Build.** `wave build` checks every pin is an open, same-repository PR at
that head (`QUEUE_WAVE_MOVED`, `QUEUE_TRAIN_UNTRUSTED`), not already in a
live wave (`QUEUE_WAVE_DUPLICATE`), 3 to 6 of them (`QUEUE_WAVE_SIZE`;
`--min`/`--max` change it). It merges the heads in order onto main with the
train's merge rules, each as a merge commit whose second parent is the pin,
so no constituent is rebased or squashed. The wave record (kind `wave`) is
written locally and to `refs/notes/gstack-trains` before `wave/<nonce>` is
pushed, and each constituent gets one "frozen in wave" comment. Then the
integrator opens the wave PR (the printed `gh pr create` and body file, or
the host's PR tool), runs /ship on it and READYs it.
- **Conflicts.** A merge that conflicts outside the CHANGELOG and the stamp
files stops with `QUEUE_WAVE_CONFLICT` and leaves the merge in progress in a
worktree under the state root. The integrator resolves and commits it there,
then `--continue` checks that commit merges exactly that pin onto the
recorded commit and finishes the chain.
- **Frozen heads.** Once in, a constituent's head does not move. Fixes go on
the wave branch as integrator commits, which the chain check allows. A
constituent that has to change goes back to its owner: `--drop <pr>` builds
a new wave on a new branch (never a force-push; the integrator closes the
old wave PR first, and the old record is marked superseded), refused with
`QUEUE_WAVE_DEPENDENT` when another pin contains the dropped one. A drop
that would leave fewer PRs than the minimum releases the wave instead, and
`--release` does the same for a wave PR closed without merging: the record
is marked released and every constituent can be READY'd on its own again.
Both wait until the wave PR is closed, since a train that already admitted
it would still land it. A constituent must target the base branch; a
stacked PR is refused, because its pin carries its parent's commits.
- **READY and the train.** `ready --pr <wave pr>` and `train build` read the
wave record for a `wave/<nonce>` head branch (rebuilt from the notes ref on a
fresh machine; `QUEUE_WAVE_UNKNOWN` when there is none). They walk the
branch's first parents back to the recorded base: every pin must be merged
unchanged, in order, and any other merge is refused. Every constituent must
still be open at its pin. READY gains `WAVE:` and `CONSTITUENTS:` lines.
`ready` and `train build` refuse a constituent of a live wave on its own
(`QUEUE_WAVE_DUPLICATE`), so it cannot land twice. A wave head may carry
train land's stamp commits after its last constituent, so a stamped wave
is re-READY'd like any stamped PR. From there the wave PR is an ordinary slot: `train
land`, `verify`, the stamp and the backstop treat it like any PR, and under
`changelog_fragments` its one stamp folds every constituent's fragment into
one version section.
- **After the merge.** `wave settle` finds the wave PR merged into the base
branch, checks that its merge commit is on that branch and carries its head
(the head's tree, or the head merged onto the merge's parent when the train
merged the PR directly), and walks its chain (train land's stamp or update
commits on top are allowed). A
constituent still at its pin gets a "Landed in wave #W as vX (sha)" comment
naming any integrator commits or hand-resolved merges that touched its
files, and is closed. One whose head moved after the wave froze stays open
with a comment saying its newer work did not land. Comments carry a marker, so a
rerun posts nothing twice; `--dry-run` shows the plan.
<a id="ship-before-ready"></a>
**0. Lane: /ship before READY.** Every READY follows a /ship run on the branch
at the head being READY'd, including a re-READY after a push (Garry,
@@ -568,8 +762,8 @@ forked (the chain's merge onto the pin's own prefix is the verdict), refuses
`QUEUE_TRAIN_CONFLICT` for a pin that does not merge, and otherwise prints
`MERGE_CHECK: T1..T<k> merge clean onto <base>@<sha>`, so a
`QUEUE_TRAIN_WAITING` result is also a clean dry-run merge of the pool (the
crew template's sibling trial merge, done by the tool). gstack's own block fills at 3 PRs or one hour (it was 5 PRs or
three hours until 2026-10-26, which left READY PRs waiting most of a day). The same pins on the same base return the live train
crew template's sibling trial merge, done by the tool). gstack's own block fills at 5 PRs or one hour (it was 5 PRs or
three hours until 2026-10-26, which left READY PRs waiting most of a day, then 3 PRs or one hour until 2026-10-31). The same pins on the same base return the live train
instead of a second one; `train build --train <nonce>` resumes a train from
its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin),
@@ -774,7 +968,7 @@ repair. When verify confirms the repair merged, it prints `FREEZE: cleared`
and `NEXT:` builds a train of the PRs still unmerged.
**5b. Coordinator: after the merge.** `gstack-ship-queue verify --post-merge
<sha> [--timeout <s>]` (the merged commit's full 40-character SHA) watches
<sha> [--timeout <s>] [--checks <a,b>]` (the merged commit's full 40-character SHA) watches
every check run attached to it (the push workflows: free tests, the Windows
subset, the main backstop) and every commit status until none is still
running, prints one `CHECK: <name> <conclusion>` per check and
@@ -783,7 +977,10 @@ Green needs at least one completed check and no failure; `neutral` and
`skipped` (the backstop superseding itself) count as skipped; a pending
commit status keeps the commit pending and a failed one makes it red. A
failure is `QUEUE_POST_MERGE_RED` naming the checks; a timeout (default 2700 s) is
`QUEUE_TRAIN_PENDING` with the same command as `NEXT:`. It keeps no process
`QUEUE_TRAIN_PENDING` with the same command as `NEXT:`. With `--checks` (a
workflows gate's `post_merge_checks`), each named check must also run and
succeed: one that did not succeed is `QUEUE_POST_MERGE_RED`, and one that
never ran is `QUEUE_TRAIN_PENDING` at the timeout. It keeps no process
state, so rerunning it after a machine restart continues the watch.
**6. Clean up.** `gstack-ship-queue train prune [--train <nonce>] [--dry-run]`
@@ -1080,7 +1277,9 @@ expiry), finishes every other step, and writes its receipt with `--base-red
`expires_at`) makes it **conditional**. `gstack-ship-receipt result` prints
`SHIP_PAUSED: base-red …` and `status=incomplete`; `ready`, `train build`,
`verify --train`, `carry` and every other receipt consumer refuse a conditional
receipt with `QUEUE_RECEIPT_CONDITIONAL`.
receipt with `QUEUE_RECEIPT_CONDITIONAL`. The one exception is a train with
`gates`: `ready --base-red --fixer <pr>` READYs the PR to ride behind its fixer
([base-red PRs](#train-gates)).
To resume, a parent runs `gstack-base-red wait --pause <file>` as a background
operation. On each new base head (`git ls-remote`) it reruns `setup` and
@@ -1100,7 +1299,8 @@ receipt when the base moves:
| What changed since /ship | The receipt |
|---|---|
| a merge of the base, product diff unchanged (tests, release metadata, registered outputs the pregate's regen check proved) | carries; no review reruns |
| a merge of the base whose resolution changed product code | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) |
| a merge of the base that edited lines next to the branch's unchanged hunks (the branch's +/- lines identical and in the same places) | carries after one fix-check reviewer reads the hunks and comes back clean (`RECEIPT_CARRY_SCOPED_REVIEW`, then `carry --scoped-review <record.json>`) |
| a merge of the base whose resolution changed product code, or moved the branch's lines elsewhere | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) |
| a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) |
| any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) |
| a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent <branch> landed`) |
@@ -1141,7 +1341,8 @@ other `CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`; an `openapi.spec.yaml` is
product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, each
`<changelog_fragments.dir>/<slug>.md` fragment when the base policy sets
`changelog_fragments` (not the directory's README.md or a nested path; a README
change also makes the range's fragments product), and `VERSION`
change the branch itself makes also makes the range's fragments product, while one
a base merge brings in does not), and `VERSION`
or `package.json` only while the change is a version stamp and nothing else: a
dependency, a script or a new key makes them product). A path the policy's
`stamp_files`, `stamp_paths` or `release_outputs` name is metadata only while
@@ -1176,7 +1377,10 @@ skill=ship status=complete` line, and its summary line ends with
A changed product path, or a product patch-id that differs, is
[`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed)
(exit 3; the refusal names the paths), and a working tree with uncommitted
(exit 3; the refusal names the paths), except when the only difference is
base context next to the branch's unchanged hunks, which is
[`RECEIPT_CARRY_SCOPED_REVIEW`](troubleshooting.md#receipt-carry-scoped-review)
until one fix-check reviewer clears the packet; a working tree with uncommitted
changes is [`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused).
**Rewritten history.** `write` records `head_tree` (the reviewed head's tree)
+3
View File
@@ -964,6 +964,9 @@ runs every row through the stub model for $0 and flags malformed rows
(`EVAL_DRY_RUN_FAILED`); `pilot start|settle|release|report` runs three priced
items, records actual spend, extrapolates to the full set against the cap
(`EVAL_BUDGET_EXHAUSTED`) and prints the spend table the approval question shows.
`results --check <prereg> <results.md>` refuses a finished results file unless one
table holds a filled row for every arm, each `<model>#no-gstack` row beside its
variants (`EVAL_REPORT_NO_GSTACK_ROW`); no number leaves the run before it passes.
The skill's closing section lists the tier-3 items (instrument audit, steady
state, sealed paths, off-machine persistence, memory preflight, power tables, QA
soak) as `tier 3, after P7`; none of them run yet.
+72
View File
@@ -137,6 +137,78 @@ yourself, for example in the project's `.claude/settings.local.json`
remote, or for a vendored sub-repo that should be its own project. The pin is
applied per run and never cached.
### Nested repos and umbrella folders
By default, a repository inside another repository files under the **outer**
repository's slug, even when the inner one has its own remote. To make a folder
a project of its own, create an empty marker file in it:
```bash
touch path/to/folder/.gstack-project
```
- **A nested repo** (`~/projects/app-one/` with its own remote inside a
workspace repo): put the marker at the nested repo's top folder. It and every
folder under it file under its own remote's slug. The outer repo is unchanged.
- **An umbrella folder** (a plain folder over `backend/` and `frontend/`
repos): put the marker in the umbrella. The umbrella and both repos share
one memory under the umbrella folder's name, so a plan made in the umbrella
carries into the repo where you build it. A repo under the umbrella that should
keep its own memory gets its own marker; the innermost marker wins.
The marker's contents are ignored. It counts only when it is a regular file
you own, in a folder whose name has a letter or digit, and either at a
repository's top folder or in a folder that isn't inside any repository. When
you work under your home folder, a marker in your home folder itself or above
it is ignored; outside your home folder (a CI or cloud checkout under
`/workspace`, say) every folder up to `/` is checked, so keep stray markers out
of shared parents. A marker in a subfolder of a repository, a link or a
directory is ignored, so a repository you clone can't choose another project's
memory. An umbrella without a remote of its own files under its folder name, so
two umbrellas with the same name (`~/clientA/work` and `~/clientB/work`) share
one memory: rename one, or pin each with `GSTACK_PROJECT_SLUG`. Commit
it in a nested repo you share, if you like; it changes nothing for someone who
clones that repo on its own.
Checkpoints, learnings, decisions, the timeline and design docs follow the
marker. Records that belong to one repository never do: pregate policy and run
scratch, approvals, `/verify` results, the per-branch review log, the `/ship`
spend-cap pointer, the cached solo or collaborative repo mode and the Greptile
early-push answer stay
under the slug the repository has without a marker (`gstack-slug --get
REPO_SLUG`), so a branch that commits a marker can't change which policy
applies.
| To | Use | Lasts |
|---|---|---|
| Make a folder its own project | `.gstack-project` | until you delete it |
| Pick an exact bucket name | `GSTACK_PROJECT_SLUG` in the project's `.claude/settings.local.json` | per Claude Code project |
| Pick a name for one shell | `export GSTACK_PROJECT_SLUG=<name>` | that shell |
`GSTACK_PROJECT_SLUG` wins over a marker. Check which applies:
```bash
~/.claude/skills/gstack/bin/gstack-slug --identity
```
`PROJECT_MARKER=` names the marker in use; each `MARKER_IGNORED=` line names
a marker that didn't count and why. Deleting the marker restores the slug the
folder had before.
**Bringing earlier checkpoints across.** Checkpoints saved before the marker
stay in the old bucket, and the first run after you add the marker names it.
Preview the ones saved inside the marked folder, then copy them:
```bash
~/.claude/skills/gstack/bin/gstack-slug --adopt-legacy --from <old slug> --under-root
~/.claude/skills/gstack/bin/gstack-slug --adopt-legacy --from <old slug> --under-root --apply
```
Only checkpoints stamped with a folder at or below the marked one are copied,
and they are restamped so `/context-restore` finds them. Checkpoints from
before identity stamps, and other projects' files, stay where they are. The old
bucket is never changed.
## Ephemeral machines
A Capy cloud machine, a CI runner or a throwaway VM loses its disk when the
+461 -19
View File
@@ -755,13 +755,67 @@ If the message names a missing timeout tool, install coreutils (`gtimeout`) or p
**Expected result.** The next design skill prints `DESIGN_READY`.
<a id="design-image-model-invalid"></a>
### `GSTACK_DESIGN_IMAGE_MODEL="<value>" is not a gpt-image model name (expected something like gpt-image-2); fix it or unset it to use gpt-image-2`
### `GSTACK_DESIGN_IMAGE_MODEL="<value>" is not a gpt-image model name ...` (or `... is not a Gemini model name, and the design provider is gemini`)
**Meaning.** The design binary refuses an image tool model override that is not
a `gpt-image-*` model name, before sending any request.
**Meaning.** The design binary refuses a model override that does not fit the
picked provider, before sending any request. On OpenAI the image tool model
must be a `gpt-image-*` name; on Gemini, `GSTACK_DESIGN_IMAGE_MODEL` and
`GSTACK_DESIGN_MODEL` must be `gemini-*` names. A Gemini 404 for a model also
points here.
**Fix.** `export GSTACK_DESIGN_IMAGE_MODEL=gpt-image-2` (or another gpt-image
model your key can use), or `unset GSTACK_DESIGN_IMAGE_MODEL`.
**Fix.** `unset GSTACK_DESIGN_IMAGE_MODEL GSTACK_DESIGN_MODEL` to use the
defaults, or set models the picked provider and your key can use.
<a id="design-no-image-key"></a>
### `No image API key found (OpenAI or Gemini).`
**Meaning.** `$D` found neither an OpenAI key (`~/.gstack/openai.json`,
`OPENAI_API_KEY`) nor a Gemini key (`~/.gstack/gemini.json`,
`GEMINI_API_KEY`). Design skills fall back to HTML wireframes.
**What is kept.** Nothing was sent or spent.
**Fix.** Get a free Gemini key at https://aistudio.google.com/apikey, then
`export GEMINI_API_KEY=<key>` or `printf %s "$KEY" | $D setup --provider gemini --key-stdin`.
**Expected result.** The next design command prints `Using Gemini key from ...`.
<a id="design-provider-invalid"></a>
### `GSTACK_DESIGN_PROVIDER="<value>" is not a design provider (expected openai or gemini).`
**Fix.** `export GSTACK_DESIGN_PROVIDER=gemini` (or `openai`), or
`unset GSTACK_DESIGN_PROVIDER` to let `$D` pick (OpenAI when its key is set,
else Gemini).
<a id="design-provider-no-key"></a>
### `GSTACK_DESIGN_PROVIDER=<provider> but no <Provider> key was found`
**Meaning.** You picked a provider explicitly and it has no key, so `$D`
stopped before any request instead of quietly using the other one.
**Fix.** Set that provider's key (`GEMINI_API_KEY` or `OPENAI_API_KEY`), run
`$D setup --provider <provider>`, or unset `GSTACK_DESIGN_PROVIDER`.
<a id="design-gemini-key-rejected"></a>
### `Gemini API error (400|403, model <model>, key from <source>): ...`
**Meaning.** Google rejected the key (`API_KEY_INVALID`), or the key's Google
Cloud project has the Generative Language API turned off. The message names
where the key came from, never the key.
**Fix.** Create a key at https://aistudio.google.com/apikey and run
`$D setup --provider gemini`, or enable the Generative Language API for the
key's project. To use OpenAI instead: `export GSTACK_DESIGN_PROVIDER=openai`.
<a id="design-gemini-quota"></a>
### `Gemini API error (429 ...): ... no image quota left today`
**Meaning.** The key's daily quota for the model is used up, or the key is on a
free tier with no quota for image models. `$D` does not retry this; a
short-term 429 is retried after Google's suggested delay.
**Fix.** Enable billing for the key in Google AI Studio, wait for the daily
reset, or `export GSTACK_DESIGN_PROVIDER=openai`.
<a id="design-taste-profile-unavailable"></a>
### `TASTE_PROFILE_UNAVAILABLE: could not resolve the project slug (gstack-slug failed). Fix: run ./setup.`
@@ -1109,6 +1163,28 @@ conflict yourself instead, finish or abort your merge (`git -C '<dir>' merge
--abort`, `git -C '<dir>' checkout -- <file>`); gstack retries at the next
check.
<a id="auto-update-tracked-by-project"></a>
### `gstack auto-update: update held (tracked-by-project: ...)` / `(version-reverted: ...)` / `gstack-upgrade: tracked-by-project: ... (UPGRADE_REFUSED)` / `gstack-upgrade: version-reverted: ... (UPGRADE_REFUSED)`
**Meaning.** Your gstack install sits inside another git project, and that
project has committed gstack's files as ordinary files. Every time you switch
branches or check out files in that project, git puts its old copies of
gstack's files back, so VERSION and other files fall behind the install's own
HEAD while updates seem to succeed (#1022). `version-reverted` is the same
symptom seen from the install: its VERSION disagrees with HEAD. Neither
`gstack-upgrade`, `/gstack-upgrade` nor the auto-updater moves the install
until it is fixed. A project that holds gstack as a git submodule is fine.
`version-reverted` with no enclosing project means VERSION was edited by
hand; only the `stash push` step below applies.
**Fix.** Print the exact commands with
`bash <install>/bin/gstack-ff-update --preflight <install>`. They do three
things: untrack the install in the project (`git rm -r --cached`), add it to
the project's `.gitignore`, and commit both on every branch you check out
(or vendor gstack as a submodule instead). Then
`git -C <install> stash push` puts the install's own files back and keeps
any edit you made in the stash, and the next update proceeds.
<a id="auto-update-autostash-unrecoverable"></a>
### `gstack auto-update: update held (autostash-unrecoverable: <reason>); your local edits are where they were and nothing was installed. Look first: git -C '<dir>' status --short. Fix now (...)` / `autostash-unrecoverable: <reason>`
@@ -1163,8 +1239,8 @@ git -C ~/.claude/skills/gstack update-ref -d 'refs/gstack/rescue/<oid>'
<a id="setup-runtime-root-contains-checkout"></a>
### `gstack setup: refusing to replace <root> for <host>: this runtime directory contains the source checkout setup runs from (<checkout>), so replacing it would delete the checkout. Nothing was changed.`
**Meaning.** Setup installs Codex, Factory, OpenCode, Cursor, Copilot and
Antigravity by building a runtime root (such as `~/.cursor/skills/gstack`) and
**Meaning.** Setup installs Codex, Factory, OpenCode, Cursor, Copilot,
Antigravity and Gemini CLI by building a runtime root (such as `~/.cursor/skills/gstack`) and
swapping it in for the old one, and Kiro by writing into `~/.kiro/skills/gstack`.
Your gstack checkout lives at that root or inside it, so the swap would delete
the checkout (#3084), and Kiro would link the checkout into itself. Setup refuses before
@@ -1551,6 +1627,8 @@ directory is not a gstack checkout, HEAD is detached or on another branch (a
pin), a tracked file is modified, a local commit is not on the remote branch,
the incoming release's Bun floor is above the Bun on PATH, or another updater
holds `<state root>/.setup-lock`. It never stashes, resets or discards.
A `tracked-by-project` or `version-reverted` cause has its own section:
[an enclosing project tracks the install](#auto-update-tracked-by-project).
**Fix.** Resolve the named cause with the printed command (commit or move the
listed changes, check out the default branch, move local commits to another
@@ -2128,6 +2206,25 @@ anything.
---
<a id="setup-deps-migrate-failed"></a>
### `gstack: WARN kept the full dependency install (SETUP_DEPS_MIGRATE_FAILED; ...): <cause>`
**Meaning.** Your install was made before v1.91.231.0, so it still carries the
test and eval packages (about 520 MB, mostly the Claude Agent SDK). setup moved
`node_modules` aside to install production dependencies only, that install
failed, and setup put the full `node_modules` back. Nothing was removed, and
gstack keeps working on the full install.
**Fix.** Fix the cause the warning names (no network, a proxy, a full disk),
then run `./setup` again. Developing gstack in this checkout? Mark it, and
setup keeps the dev packages from then on:
```bash
git config gstack.contributor true
```
---
<a id="chromium-install-exit-127"></a>
### `Chromium bootstrap: chromium-install — <bun> x playwright install chromium exited 127 (command not found); install Bun from bun.sh (winget Bun ships no bunx), or skip with ./setup --no-browser (Git Bash) or .\setup.ps1 --no-browser (PowerShell)`
@@ -2818,6 +2915,34 @@ fetching it cannot create that ref, so pass a branch that exists on `origin`
(`git branch -r --list 'origin/*'` lists them). `gstack-pregate` says so in
its fix instead of suggesting the fetch.
<a id="history-read-failed"></a>
### `cannot read <path> at <ref>. <cause> (git version <v>). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)`
**Meaning.** A CI job or a local release tool read a file at an older commit
and git could not produce it. The PR and train path jobs (Free Tests' plan, the Version Gate,
and E2E Evals' route, screen summary, planner, slices, reruns, bisect and
report) check out blobless clones (`filter: blob:none`): every commit and
tree is there, but an older file's contents are fetched from GitHub the first
time something reads them. When that fetch fails three times (a GitHub outage,
a network drop), or the ref does not resolve at all, the read fails here
instead of counting as "the file is absent". A file the commit really does not
have still reads as absent, as before. No ship policy at the base means no
train, and no VERSION means `0.0.0.0`. The route and backstop policy reads (inline
in their workflows), `.github/scripts/history-read.sh` (the Version Gate's base
`VERSION`) and `lib/history-read.ts` (the Version Gate's train classification)
print this line. The Version Gate prints the same code when
`git fetch origin <base>` fails three times. `gstack-restamp` and
`gstack-ship-queue land` print it too when they cannot read the base branch's
CHANGELOG, which they check fragments against (`lib/changelog-fragments.ts`
reads it through `lib/history-read.ts`); outside a partial clone they do not
retry.
**Fix.** Rerun the failed job once GitHub is reachable. If it fails the same
way on a healthy network, check that the ref exists on `origin` and that the
runner's git supports partial clone (`git --version` 2.22 or newer; the eval
image asserts it at build time in `.github/docker/Dockerfile.ci`). For a local
tool, run `git fetch origin <base>` and rerun it.
<a id="policy-invalid"></a>
### `POLICY_INVALID`
@@ -3229,6 +3354,54 @@ makes the row a check rather than a claim.
`REALITY: already-done pass 0 hits since 2026-10-09 for bin/gstack-doctor 91bbd9e`
or `REALITY: surface-check finding bun upgrade --version absent on 1.3.14 bin/gstack-capy-install:52`.
<a id="office-hours-probe-refused"></a>
### `OFFICE_HOURS_PROBE_REFUSED`
**Meaning.** The office-hours spec reviewer may check a cited flag with
`gstack-office-hours-review probe --help <command> [<subcommand>]`, and nothing
else runs. The command must be a gstack bin or one of the listed read-only CLIs
(git, gh, bun, node, npm, pnpm, yarn, jq, rg, python3, uv, go, cargo, make,
docker, claude, codex, gemini), found on PATH outside the repository; a gstack
bin must handle `--help`. Subcommand words are allowed for git, gh,
docker, cargo, go, npm and uv (one word for git, cargo and npm; up to two for gh, docker, go and uv), each from that CLI's list of built-in names in
`lib/office-hours-probe.ts`; words that run a named program, script, image or
extension (`uv run`, `docker run`, `go test`, `npm exec`, a gh extension) are
refused. The probe never runs a shell or a repository-local program (PATH
entries inside the repository are dropped), closes stdin, runs in an empty
temporary directory, passes no keys or tokens in its environment and stops after
10 seconds.
**Fix.** Nothing to repair. The claim stays unverified, not disproved; check it
from the command's source or docs in the repository instead.
<a id="tasks-supersede-refused"></a>
### `TASKS_SUPERSEDE_REFUSED`
**Meaning.** `gstack-tasks` refused a task list. Either `add` found a line in
the rows file (`.gstack/tmp/tasks-<phase>-rows.jsonl`) that is not a valid
task or disposition, or it leaves a task from the branch's earlier list neither
carried nor closed (the error names each `<run_id>/<id>`), so nothing was written; or `supersede` was asked to retire the earlier lists
but the new list (`--keep`) is missing, outside the tasks directory, has a
malformed line, or holds a row for another phase or branch, so nothing was
moved and both lists stay live.
**Fix.** Fix the line the error names in the rows file (`gstack-tasks --help`
shows both row shapes), and rerun `gstack-tasks add --phase <phase>`.
<a id="reality-receipt-unresolved"></a>
### `REALITY_RECEIPT_UNRESOLVED`
**Meaning.** `gstack-plan-reality check` opened a receipt and it is false in
the checkout: the file is missing, it is a directory or a dangling link, the
line is 0 or the range runs backward, or the cited line is past its end. The `RECEIPT_UNRESOLVED: <row> <receipt> <reason>` line names each
one. A receipt the checkout cannot judge (a path outside every root, a bare
file name no root has, a sha a shallow clone lacks) prints
`RECEIPT_UNVERIFIABLE:` instead and never blocks.
**Fix.** Open the file and cite the line that holds the evidence. When the
evidence lives in another checkout, run `check` with `--root <dir>` for it
(repeatable; the default is the git top level of the working directory).
## Pre-gate: generated-file registry, gstack-pregate, issue links
Both tools read their pin file (`.gstack/generated.json`, `.gstack/pregate.json`)
@@ -3293,8 +3466,9 @@ repository's lockfile), then rerun `gstack-regen check` or the pre-gate.
**Meaning.** `.gstack/pregate.json` has an unknown key, a wrong type, a glob
that escapes the repository, a runner without `{files}`, or `secrets` in `warn`.
The user-owned `<state root>/projects/<slug>/pregate.json` also refuses when it
sets `sealed` or `price_file` (both are read only from the base branch's copy)
or exists but cannot be read (a dangling symlink on its path counts).
sets `sealed`, `price_file`, a non-empty `run_profiles` or `remote_gate_runs`
(all read only from the base branch's copy) or exists but cannot be read (a
dangling symlink on its path counts).
**Fix.** `gstack-pregate validate`. `secrets` and required-lane obligations
cannot be downgraded; every other check id in `warn` is honoured only from the
@@ -3490,6 +3664,12 @@ reports failures. The obligation stays uncleared. A run URL alone never clears
this obligation; a `test_backend/<backend>` obligation takes a run URL, recorded
as attested rather than validated.
`gstack-pregate clear --run <id|url>` refusals use this code too: the line
reads `gstack-pregate clear: receipt refused — REUSE: refused reason=<code> —
…`, and the GitHub Actions runs offered did not verify against the base
branch's `run_profiles`. Each reason code and
what it checks is listed in [run evidence](pregate.md#run-evidence).
Another tree is accepted in exactly one case: it differs from `pregate.json`'s
tree only in the repo's stamp rule (`.gstack/ship-policy.json` on the base
branch: `stamp_files`, `stamp_patterns`, the built-in version, manifest,
@@ -3585,7 +3765,9 @@ run, named by `workflow.yml/job`, or the small-machine `pregate.json/remote_gate
tier) has no receipt; or a `remote_gate` receipt accepted from a stamp-only
predecessor tree records `accepted_for` as a tree other than `pregate.json`'s,
or a `gated_tree` other than its receipt string's `tree=` (the file was edited
after `clear`), so it clears nothing here.
after `clear`), so it clears nothing here; or a `clear --run` receipt names
another tree than `pregate.json`'s with no substitution recorded (`run
receipt(s) verified for another tree`).
**Fix.** Dispatch that job, then
`gstack-pregate clear --remote <workflow.yml/job> --run-url <url>` (or
@@ -3593,7 +3775,11 @@ after `clear`), so it clears nothing here.
`clear --remote pregate.json/remote_gate --receipt <gate output>`: the gate's
own run on this tree, or a run on a predecessor tree that differs only by the
repo's stamp rule ([gate ahead of the stamp](pregate.md#remote-gate-stamp-only));
`verify` then prints the accepted substitution on its ok line. The ship receipt
`verify` then prints the accepted substitution on its ok line. When the base
branch declares run profiles, `clear --remote <lane> --run <id|url>` clears a
lane from verified GitHub Actions runs on this tree (for the remote gate, one
run per `remote_gate_runs` profile, with `--allow-repo-commands`); a profiled
lane refuses `--bundle-lane` ([run evidence](pregate.md#run-evidence)). The ship receipt
carries each obligation with its receipt.
<a id="issue-unreadable"></a>
@@ -3672,6 +3858,21 @@ goes beside the variants. When no arm loads a skill stack, write
`stack: none` with a `why:` line instead. The rule is in
[docs/TESTING_INTERNALS.md](TESTING_INTERNALS.md#no-gstack-arm).
<a id="eval-report-no-gstack-row"></a>
### `EVAL_REPORT_NO_GSTACK_ROW`
**Meaning.** `gstack-eval-plan results --check <prereg> <results>` found no
single markdown table in the results file with a filled row for every arm
identity the preregistration's Arms section lists. Usually the
`<model>#no-gstack` row is missing, sits in a separate table from its
variants, or holds only placeholders (`-`, `n/a`, `tbd`). It also fires when
the preregistration itself fails `prereg --check`.
**Fix.** Put one row per arm identity, each no-gstack arm beside its variants,
in one table, and rerun the check. Publish no number before it prints
`RESULTS: ok`. A preregistration with `stack: none` needs no row. The rule is
in [docs/TESTING_INTERNALS.md](TESTING_INTERNALS.md#no-gstack-arm).
<a id="eval-arm-duplicate"></a>
### `EVAL_ARM_DUPLICATE`
@@ -4563,6 +4764,12 @@ GitHub accepted the dispatch). It is never dispatched again blindly.
run carrying the nonce, or marks the dispatch absent; only then does
`train build --train <nonce>` dispatch it again, under the same nonce.
On a train with `gates`, a workflow is sent at most twice per prefix. When
neither send shows a run carrying the nonce, `train build` (and `train watch`)
refuses with this code instead of sending a third: run `train doctor` (a
run-name without the nonce is the usual cause), fix the workflow on the base
branch, then build a new train.
<a id="queue-train-waiting"></a>
### `QUEUE_TRAIN_WAITING`
@@ -4583,9 +4790,12 @@ one-PR train.
**Meaning.** The train's run (free tests, the full gate or the bisect) has
not finished, so there is no verdict yet. The same code covers a `train watch`
that reached its timeout, `verify --pr <n> --screen` on a screen run still
going, `verify --post-merge` on checks that have not settled, and a
`ready --dispatch-ci` workflow run still going at its `--timeout`; each prints
the command to rerun as `NEXT:`.
going, `verify --post-merge` on checks that have not settled, a
`ready --dispatch-ci` workflow run still going at its `--timeout`, a `train
build` or `train verdict` while another process holds the train's lease or
runs one of its command gates, and a `train land` whose previous slot's
post-merge checks have not settled; each prints the command to rerun as
`NEXT:`.
**Fix.** Rerun the printed `NEXT:` command later. Nothing is dispatched again
while a run is pending.
@@ -4602,6 +4812,10 @@ record. An incomplete gate is never green.
one re-dispatch EVAL_POLICY.infraRedispatch allows (`train rebuild --train
<nonce> --infra`) and report both runs. Otherwise diagnose the run.
On a train with `gates` it means a prefix gate is incomplete (a required job
that was skipped or never ran, for example); the `PREFIX:` line names the
run. `train rebuild` refuses, so diagnose that run and build a new train.
<a id="queue-train-red"></a>
### `QUEUE_TRAIN_RED`
@@ -4612,6 +4826,11 @@ verdict.
**Fix.** Bisect the red cases (`train bisect --train <nonce> --spend-cap
<usd>`). After a rebuild's second red, route the cases to a repair lane.
On a train with `gates` there is no bisect: T<i> is the first red prefix after
a green one and every later prefix is red, so slot i is the culprit. The slots
before it land (`train land`, or `train watch --land`), the culprit goes back
to its lane, and `NEXT:` builds the rest into a new train.
<a id="queue-train-red-on-main"></a>
### `QUEUE_TRAIN_RED_ON_MAIN`
@@ -4621,6 +4840,9 @@ included: main is broken, not the train's PRs.
**Fix.** Fix forward on main first (a one-PR train), then rebuild this train
unchanged.
On a train with `gates`, no bisect runs: T0 (main) is red on its own gate run,
or red on anything other than exactly slot 1's `FIXES_BASE_RED` cases.
<a id="queue-train-unattributed"></a>
### `QUEUE_TRAIN_UNATTRIBUTED`
@@ -4631,6 +4853,10 @@ monotone. One trial per prefix cannot tell a flake from a break.
<nonce>`), reporting both runs. Any other pattern: a repair lane, then a
changed train once the repair merges.
On a train with `gates` it means a red prefix is followed by a green one.
Repair the red cases or seat the PRs in another order (`--order`); `train
rebuild` refuses, so this train never runs again unchanged.
<a id="queue-train-bisect-capped"></a>
### `QUEUE_TRAIN_BISECT_CAPPED`
@@ -4700,6 +4926,83 @@ ran with `--no-fetch`, verify freezes as before (`QUEUE_TRAIN_FROZEN`). A queue
another machine already froze stays frozen; the refusal prints its freeze
lines.
<a id="queue-train-base-red"></a>
### `QUEUE_TRAIN_BASE_RED`
**Meaning.** A conditional (base-red) PR or its fixer cannot ride this train.
The train's policy has no `gates`, the fixer is missing or not at slot 1, two
PRs declare `FIXES_BASE_RED`, the condition expired, or the fixer's own prefix
T1 is red.
**Fix.** Seat the fixer at slot 1 of a train with gates and build again. A red
fixer prefix is the fixer lane's to repair; an expired condition is classified
again by its lane ([ship-policy.md](ship-policy.md#train-gates)).
<a id="queue-train-spend-cap"></a>
### `QUEUE_TRAIN_SPEND_CAP`
**Meaning.** A command gate's next Ubicloud run would pass that gate's spend
cap for the train, or `UBI_OWNER` is not set. Nothing more ran; finished runs keep
their outcomes.
**Fix.** Set `UBI_OWNER` to your thread code and rerun `train build --train
<nonce>`. For the cap, raise `spend_cap_usd` (or lower `timeout_sec`) on the
base branch, then build a new train on the new main: the policy commit moves
main past T0.
<a id="queue-train-land-method"></a>
### `QUEUE_TRAIN_LAND_METHOD`
**Meaning.** This slot must land through an update commit (stamped train,
strict protection, conditional PR, or release-file resolution), and
`train.land.merge_method` is `rebase`, which would replay commits instead of
landing the gated tree.
**Fix.** Set `merge_method` to `squash` or `merge` on the base branch, then
build a new train on the new main: the policy commit moves main off this
train's prefixes.
<a id="queue-train-init-refused"></a>
### `QUEUE_TRAIN_INIT_REFUSED`
**Meaning.** Open PRs bump the version and the policy neither declares
`changelog_fragments` nor stamps at merge, so every train merge would conflict
on the version. The `VERSION_PRS:` line names them.
**Fix.** Adopt `changelog_fragments` (recommended) or `queue_mode:
stamp-at-merge` on the base branch, then run `train init` again.
<a id="queue-train-doctor-findings"></a>
### `QUEUE_TRAIN_DOCTOR_FINDINGS`
**Meaning.** The live train block and the repository disagree. Each
`FINDING:` line names one problem: a gate workflow missing, without the
`nonce` input or run-name, a required job it does not define, a global
concurrency group, a push trigger on `train-prefix/` refs, a workflow never
green on the base, a command gate cap below what a full train reserves, a
merge method GitHub refuses, or `rebase` as the merge method.
**Fix.** Fix each finding on the base branch (`train init` prints the
snippet), then run `train doctor` again.
<a id="queue-base-red-no-train"></a>
### `QUEUE_BASE_RED_NO_TRAIN`
**Meaning.** `ready --base-red` or `--fixes-base-red` needs a train that gates
every prefix, and the base policy has no train block with `gates`.
**Fix.** Wait for the base to pass the cases and run `/ship --resume`, or turn
on a train with gates on the base branch.
<a id="ship-base-red-fixer"></a>
### `SHIP_BASE_RED_FIXER`
**Meaning.** `ready --base-red` names no fixer, the receipt carries no
`base_red` block, or the condition was classified on an older base.
**Fix.** Pass `--fixer <pr>` naming the PR whose merge fixes the cases; after
a base move, run `gstack-base-red classify` and /ship again first.
<a id="queue-train-frozen"></a>
### `QUEUE_TRAIN_FROZEN`
@@ -4724,6 +5027,91 @@ trains and other trains' merges are refused until a repair lands.
<n>@<sha>`). The freeze clears when verify finds main's tree after that merge
in a green record; `NEXT:` then builds a train of the PRs still unmerged.
<a id="queue-wave-conflict"></a>
### `QUEUE_WAVE_CONFLICT`
**Meaning.** `wave build` merged the constituents in order with the train's
merge rules, and one conflicts with the wave so far outside the CHANGELOG and
the stamp files. Nothing was pushed. The record (state `conflict`) names the
PR, the files and a worktree under the state root where the merge is in
progress. `--continue` refuses until that worktree holds one committed merge
of the constituent's pin onto the recorded commit with a clean tree.
**Fix.** Resolve the files in the printed worktree, `git -C <worktree> commit
--no-edit`, then `gstack-ship-queue wave build --wave <nonce> --continue`. Or
build the wave without that PR.
<a id="queue-wave-moved"></a>
### `QUEUE_WAVE_MOVED`
**Meaning.** A wave no longer lands what its record names: a constituent was
pushed, closed or merged after it joined (heads freeze once in), the wave
branch carries a merge that is not a constituent's pin (main merged in, for
example), a pin is missing from the branch, or the wave was rebuilt and this
PR is the old one. The same code refuses a constituent that targets a branch
other than the base (a stacked PR's pin carries its parent's commits), a
wave that was released, and a `--drop` or `--release` while the wave PR is
still open, since a train that already admitted it would still land it.
**Fix.** Close the wave PR if one is open, then rebuild without the moved PR,
`wave build --wave <nonce> --drop <pr>`, which makes a new wave branch and PR (never a
force-push); run /ship and READY the new wave PR. The moved PR goes back to
its owner and rides on its own or in a later wave. A stacked PR rides on its
own; after a release, each constituent is READY'd on its own.
<a id="queue-wave-duplicate"></a>
### `QUEUE_WAVE_DUPLICATE`
**Meaning.** A PR would land twice or waves would nest: it is already a
constituent of another live wave, `train build` was handed both a wave PR and
one of its constituents (or two waves sharing one), a wave PR was named as a
constituent, or a constituent of a live wave was READY'd or handed to `train
build` on its own.
**Fix.** Keep each PR in one place: drop the separate pin from `train build`,
or rebuild one wave without the shared PR. A constituent of a live wave lands
through the wave PR.
<a id="queue-wave-size"></a>
### `QUEUE_WAVE_SIZE`
**Meaning.** A wave carries 3 to 6 PRs by default (R46). `--min` and `--max`
change the bounds for one command.
**Fix.** Add or remove PRs; `wave plan` suggests area groups. Fewer PRs than
the minimum ride as single train slots.
<a id="queue-wave-unknown"></a>
### `QUEUE_WAVE_UNKNOWN`
**Meaning.** A PR's head branch is `wave/<nonce>`, or a command named a
nonce, but no wave record exists in local state or on
`refs/notes/gstack-trains`. `ready` and `train build` refuse rather than
treat the PR as an ordinary one, since its constituents cannot be checked.
**Fix.** `gstack-ship-queue wave status` lists the known waves. When the
record is gone, rebuild the wave with `wave build --prs`.
<a id="queue-wave-dependent"></a>
### `QUEUE_WAVE_DEPENDENT`
**Meaning.** `wave build --drop <pr>` was asked to drop a PR whose pin is an
ancestor of another constituent's pin (a stacked PR), so the rebuilt wave would
still land the dropped code.
**Fix.** Drop the containing PR too, or keep the PR in the wave.
<a id="queue-wave-not-merged"></a>
### `QUEUE_WAVE_NOT_MERGED`
**Meaning.** `wave settle` found no PR merged into the base branch whose head
branch is the wave branch, its merge commit is not on the base branch, or that
merge's tree is neither the PR's head tree nor that head merged onto the
merge's parent, so it cannot say the constituents landed. Nothing was commented or closed.
**Fix.** Land the wave PR through its train (`train land`), then rerun
`gstack-ship-queue wave settle --wave <nonce>`.
<a id="queue-receipt-missing"></a>
### `QUEUE_RECEIPT_MISSING`
@@ -4765,7 +5153,9 @@ to fix this: the stamp comes only when the slot is called.
**Meaning.** The PR body's ship receipt carries a `base_red` block: /ship's
Step 5 failed on cases `gstack-base-red` proved are the base branch's own, so
/ship paused before READY ([base_red](ship-policy.md#base-red)). Nothing
READYs, trains, carries or merges on it.
READYs, trains, carries or merges on it, except on a train with `gates`, where
`ready --base-red --fixer <pr>` READYs it to ride behind its fixer at slot 1
([base-red PRs](ship-policy.md#train-gates)).
**Fix.** Wait for the base to pass those cases (`gstack-base-red wait --pause
<file>`), then run `/ship --resume <record>`, which reruns Step 5 and rewrites
@@ -4957,7 +5347,10 @@ or `--timeout` (default 2700 s) passes, prints `POST_MERGE: <sha12>
green|red|pending|none (passed= failed= pending= skipped=)` and one
`CHECK: <name> <conclusion|status>` line per check, and a timeout is
`QUEUE_TRAIN_PENDING` with the same command as `NEXT:`, so rerunning it after
an interruption continues the watch.
an interruption continues the watch. With `--checks <a,b>` (a workflows
gate's `post_merge_checks`, which `train land` and `train watch --land` pass),
each named check must also succeed; one that never ran is `QUEUE_TRAIN_PENDING`
at the timeout.
**Fix.** Read the red run named on its `CHECK:` line. Repair on a new PR or
roll back the merge; `gstack-ship-queue train status` names the freeze if the
@@ -5183,11 +5576,37 @@ retargeted stacked PR the parent branch and the base.
**Fix.** Run /ship on the new head. The receipt carries only across a
conflict-only base merge, a test-only change, release metadata, a registered
output the pregate's regen check proved, a rebase onto a covered tree with
the recorded product diff, or a stacked PR's retarget once its parent fully
landed on the base, which the
the recorded product diff, a stacked PR's retarget once its parent fully
landed on the base, or base context next to unchanged hunks after a clean
scoped review ([`RECEIPT_CARRY_SCOPED_REVIEW`](#receipt-carry-scoped-review)), which the
`carry` object in the carried receipt records with the product patch-id and
the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)).
<a id="receipt-carry-scoped-review"></a>
### `RECEIPT_CARRY_SCOPED_REVIEW`
**Meaning.** Every product path the branch touches has the reviewed +/- lines
in the same places, but the base edited context lines next to some of them, so
the 3-line patches differ (GST-384: main reworded the line above the PR's
hunk). The merge may have spliced the two, as in a JSON entry where the branch
changed one key and the base the next, so the receipt does not carry silently.
The refusal names the paths and a packet file with the hunks that differ and
the base's own change. The same lines moved somewhere else are
`RECEIPT_CARRY_PRODUCT_CHANGED`, not this code.
**Fix.** Dispatch one fix-check reviewer
([review-policy.md](review-policy.md)) on the packet. When it comes back clean,
write `{"head":"<HEAD>","paths":[...],"reviewer":"<model>","verdict":"clean"}`
and rerun `gstack-ship-receipt carry ... --scoped-review <record.json>`; the
carry reason records the reviewer and the paths. A blocking finding means /ship
on the new head. `gstack-ship-release finish` stops the same way with
`context=N` and takes `--context-reviewed <record.json>` bound to the tree;
write that record beside the packet, outside the worktree, so it does not change
the tree it is bound to. Finish also stops this way for test code changed since
review with no gate edit (`tests=N`): the packet holds those test hunks, and the
reviewer checks that each test still tests what it did. A packet that cannot be
written (an unwritable `.git`) is a STOP or `RECEIPT_CARRY_REFUSED` naming the path.
<a id="receipt-prose-mismatch"></a>
### `RECEIPT_PROSE_MISMATCH`
@@ -5242,7 +5661,10 @@ over` line names (or raise its cap on the base branch). When the owner accepts
the named risk instead, record it as `docs.exception: { accepted_by, date, via,
reason }` and carry the `Documentation exception: accepted by <owner> <date> via
<ref>: <reason>` line in the PR body's `## Documentation` section, word for word
([ship-policy.md](ship-policy.md#ship-receipt)).
([ship-policy.md](ship-policy.md#ship-receipt)). Then save the post-audit snapshot with
`gstack-docs-candidate snapshot --exception .gstack/tmp/docs-audit.json`;
`gstack-ship-release finish` stops on a blocked audit whose snapshot lacks the
exception.
<a id="gstack-pr-wait"></a>
## gstack-pr-wait
@@ -5600,6 +6022,26 @@ The router's description (at most 300 bytes) and the context block (at most 600
There is no ledger for that harness or id under `$GSTACK_STATE_ROOT/harness`. `gstack-harness status` lists every registration on this machine with its id.
<a id="harness-hook-unsupported"></a>
### `gstack-harness: <harness> has no start hook` / `the start hook goes into the <harness> profile <dir>, and this registration uses another skills folder` (HARNESS_HOOK_UNSUPPORTED)
The [start hook](hosts.md#start-hook) exists for OpenClaw and Hermes only, and it goes into the harness profile's own plugin folder, the one the harness scans. A registration with `--skills-dir` somewhere else has no profile gstack can trust, and with `OPENCLAW_CONFIG_PATH` set OpenClaw reads a config outside the profile, where gstack's settings change would not land, so `--start-hook` is refused and nothing is written. Register without `--skills-dir` (set `OPENCLAW_STATE_DIR` or `HERMES_HOME` to pick the profile), or drop `--start-hook`. When the choice comes from `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` instead of the flag, such a registration just gets no hook, and its receipt says so.
<a id="harness-hook-choice-invalid"></a>
### `gstack-harness: GSTACK_HARNESS_START_HOOK="<value>" is not on or off` (HARNESS_HOOK_CHOICE_INVALID)
The start hook switch takes `on` or `off` (`GSTACK_HARNESS_START_HOOK` also takes `1` or `0`). Nothing was registered. Fix the value, or unset it so each registration keeps its saved choice.
<a id="harness-hook-owned-elsewhere"></a>
### `gstack-harness: a gstack-start plugin already exists at <dir> and this registration did not write it` (HARNESS_HOOK_OWNED_ELSEWHERE)
Each harness profile has one start hook, owned by the gstack registration that wrote it, so that removing one install can never take another install's hook away. This folder is not in this registration's ledger: another gstack checkout or state root wrote it, or someone made it by hand. Nothing was written. Run `gstack-harness register <harness> --no-start-hook` from the gstack install that wrote it (its `gstack-harness status` shows the hook), or move the folder aside, then register again.
<a id="harness-hook-inactive"></a>
### `gstack-harness: <harness> start hook is written: <why>. Turn it on: <command>` (HARNESS_HOOK_INACTIVE, exit 4)
The skills are registered and the start hook's files are in place, but the hook is not active in the harness. gstack turns the hook on through the harness's own CLI and then reads it back, so this means the CLI was not on `PATH`, one of its commands failed or timed out (the message quotes its last output), or the harness's settings block the plugin: OpenClaw's `plugins.allow` set without `gstack-start`, `gstack-start` in `plugins.deny`, or `hooks.allowPromptInjection: false`. gstack never edits your allow or deny lists. Run the printed command, then `gstack-harness status`; `--start-hook` again retries the whole step. `./setup --host` exits 4 for the same reason. Refreshes and upgrades retry on their own without failing, and stop once you disable the plugin in the harness.
<a id="harness-qm-credentials"></a>
### `gstack-harness: no QM admin credential in the environment` / `... needs --yes` (HARNESS_QM_CREDENTIALS)
+6 -2
View File
@@ -237,8 +237,12 @@ and reruns carry with `--pregate .gstack/tmp/pregate.json`. `CARRY: carried from
`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, and
`ship-run.json` gains `carried_from` and `carry_reason`; the `review_loop`
counters are kept, because the reviewed product diff is unchanged. `CARRY:
current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`,
counters are kept, because the reviewed product diff is unchanged or its only
difference passed a scoped review. `CARRY: current` means nothing moved.
`CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)` means the base edited context
next to reviewed hunks: one fix-check reviewer reads the packet the refusal
names, and a clean record reruns carry with `--scoped-review <record.json>`
([troubleshooting](troubleshooting.md#receipt-carry-scoped-review)). `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`,
`(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused
result line means the product diff changed, or history was rewritten onto a
tree no review saw, so /ship runs again;
+9 -1
View File
@@ -8,7 +8,7 @@ If gstack is installed on your machine:
~/.claude/skills/gstack/bin/gstack-uninstall
```
This handles skills, symlinks, harness registrations (the files `gstack-harness` wrote into OpenClaw, Hermes, a Dot or another harness, from their ledgers), global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics. Use `--force` to skip confirmation.
This handles skills, symlinks, harness registrations (the files `gstack-harness` wrote into OpenClaw, Hermes, a Dot or another harness, from their ledgers, plus the opt-in [start hook](hosts.md#start-hook), which it turns off through the harness's own CLI), global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics. Use `--force` to skip confirmation.
## Option 2: Manual removal (no local repo)
@@ -54,6 +54,7 @@ rm -rf "${HERMES_HOME:-$HOME/.hermes}/skills/gstack" 2>/dev/null
rm -rf ~/.cursor/skills/gstack* 2>/dev/null
rm -rf ~/.config/opencode/skills/gstack* 2>/dev/null
rm -rf ~/.gemini/antigravity-cli/skills/gstack* 2>/dev/null
rm -rf ~/.gemini/skills/gstack* 2>/dev/null
# 6. Remove temp files
rm -f /tmp/gstack-* 2>/dev/null
@@ -71,6 +72,13 @@ hook, the AskUserQuestion PreToolUse/PostToolUse hooks, and the Stop hooks
(session timeline, plus verify-gate if you opted in). Left in place, they error
on every matching event once the install directory is gone.
If you turned on the OpenClaw or Hermes [start hook](hosts.md#start-hook),
manual removal also leaves its `gstack-start` plugin turned on. Turn it off
with `openclaw config unset plugins.entries.gstack-start` or `hermes plugins
disable gstack-start`, then delete
`${OPENCLAW_STATE_DIR:-~/.openclaw}/extensions/gstack-start` or
`${HERMES_HOME:-~/.hermes}/plugins/gstack-start`.
## Clean up CLAUDE.md
The uninstall script does not edit CLAUDE.md. In each project where gstack was added, remove the `## gstack` and `## Skill routing` sections.
+6 -1
View File
@@ -11,4 +11,9 @@ on.
The separate `triggers:` list in a skill's frontmatter is gstack metadata, not a
matching surface. Claude Code and Codex choose a skill from its name and
description, so a phrase that appears only under `triggers:` does not activate
the skill (Codex output drops the field; the GBrain host keeps it).
the skill (Codex output drops the field; the GBrain host keeps it). The four core
skills (/office-hours, /review, /qa, /ship) carry their `triggers:` phrases in
the description itself: every phrase in the full description, and at least one
in the one-line catalog entry Claude Code reads ("test the site" for /qa, "ship
it" for /ship). The other skills do not, because under the `core` default they
wait for you to type them (docs/proactive.md).