merge origin/main (pre-READY base merge, v1.94.0.0; keep both review_exclude_paths and release_gate)

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

# Conflicts:
#	lib/ship-policy.ts
This commit is contained in:
garrytan committed 2026-11-03 04:04:54 +00:00
commit 62bbf6de0f
198 files changed
+6858 -770

No files matched your search

+2 -2
View File
@@ -131,8 +131,8 @@ determined leaker (a CHANGELOG line that does would fail a hostile screenshotter
→ glab → unknown(=public-strict). Public repos get STERNER per-finding
confirmation (no batch-acknowledge, no silent-proceed); MEDIUM is never
auto-promoted to HIGH.
- **Tool-attributed fences:** wrap Codex/Greptile/eval output in ` ```codex-review `
/ ` ```greptile ` fences so example credentials those tools quote WARN-degrade
- **Tool-attributed fences:** wrap Codex/review-bot/Greptile/eval output in ` ```codex-review `
/ ` ```review-bot ` / ` ```greptile ` fences so example credentials those tools quote WARN-degrade
instead of blocking. A live-format credential inside the fence still blocks.
- **Config keys:** `redact_repo_visibility` (public|private|unknown, local-only
override for repos gh/glab can't read), `redact_prepush_hook` (true|false).
+8 -5
View File
@@ -188,11 +188,14 @@ digest's first line shows its gstack version; re-copy it after upgrading.
For Codex, setup reads the top-level `model` from
`${CODEX_HOME:-~/.codex}/config.toml` and generates the matching behavioral
profile, falling back to `gpt-6-astra` when no usable model is configured.
`gpt-5.6-sol` automatically receives bounded-scope instructions that
finish the requested lake without expanding into adjacent cleanup or speculative
hardening. The Sol profile is exact-match only: dated snapshots and other 5.6
variants get the generic GPT profile, and setup warns on near-misses like
`gpt-5.6-sol-2026-08-01`. Override detection with `./setup --host codex --model <id>` — the
The current OpenAI tiers are `gpt-6-astra` (frontier), `gpt-6.1-sol` (smart, the
default eval tier) and `gpt-6-luna` (lowest cost); `gpt-6.1-sol`
and `gpt-6-luna` get the generic GPT profile. Only `gpt-5.6-sol` receives the
bounded-scope instructions that finish the requested lake without expanding into
adjacent cleanup or speculative hardening, because that patch was measured on 5.6
Sol; extending it to `gpt-6.1-sol` waits for its own scope eval. The Sol profile is
exact-match only: dated snapshots and other 5.6 variants get the generic GPT
profile, and setup warns on near-misses like `gpt-5.6-sol-2026-08-01`. Override detection with `./setup --host codex --model <id>` — the
override applies to that run only; set `model` in your Codex `config.toml` to
make it stick across upgrades. After changing your Codex model, rerun
`./setup --host codex` to regenerate the skills.
+129 -7
View File
@@ -85,11 +85,12 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) |
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
| `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)) |
| `stall_timeout_s` | seconds a test command may print nothing while using under a tenth of one CPU 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)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
| `remote_gate_runs` | `{ profiles, inventory, skipped? }`: the run profiles that together clear `pregate.json/remote_gate`, the command that prints the remote gate's test files, one per line, which their runs must cover, and an optional command that prints which selected files the gate itself never runs (see [run evidence](#run-evidence)) |
| `ship_lanes_from` | maps each exact /ship lane command to `{ profiles, inventory }`: the run profiles whose verified runs at HEAD's clean tree carry that lane instead of running it, and the command listing its test files, which their runs must cover (see [CI carry](#ci-carry)); read from the base branch only |
| `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
@@ -173,7 +174,7 @@ The list lives in its own file, not in `pregate.json`, because older pregate rea
```
gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--explain] [--json]
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <url> | lanes | validate | init
```
@@ -459,10 +460,17 @@ test-tier command:
a one-file run (each serial file) reports its file from the launch for any
runner. Other runners (node --test, vitest, jest, pytest, playwright test, flutter test, dart test, cargo test) get heartbeats
only. A heartbeat follows each 60 s with no progress line;
- is killed as **stalled** when its log does not grow for the stall timeout
(`--stall-timeout <s>`, else `stall_timeout_s`, else 300; `0` disables): the
check is `incomplete` with `PREGATE_STALLED` and names the last file
started; the next serial file still runs. The `--remote` gate is exempt: a
- is killed as **stalled** when its log does not grow and its process group
stays under a tenth of one CPU for the stall timeout (`--stall-timeout <s>`, else
`stall_timeout_s`, else 300; `0` disables): the check is `incomplete` with
`PREGATE_STALLED` and names the last file started; the next serial file
still runs. The group counts as busy when its CPU time, sampled each second,
grew by at least a tenth of the wall time, so a quiet suite that is working
(FOUN-63: a parallel `check.sh` silent for 9 minutes) is not killed, while
a hang that waits on nothing is. A hang that keeps burning CPU is left to
the timeout. Linux reads `/proc` (exited children's time included once
reaped); macOS and other BSDs read `ps -S`; a failed read skips that
sample. Windows reads no CPU time here and uses log growth alone. The `--remote` gate is exempt: a
gate that waits on CI is quiet by design, so only its timeout bounds it, and
its heartbeats add `; last output: <the gate's last line>`;
- is killed as **timed out** when that runner process reaches its timeout
@@ -486,8 +494,15 @@ 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 three ways:
the supervision above. Close the obligation one of four ways:
- by default, when the base declares `remote_gate_runs`, let CI clear it: the
tests stage clears it on its own from a verified run at this exact tree
([CI carry](#ci-carry)), and the uncleared obligation's detail names that
route first (`push, then gstack-ci-carry wait --base <base> and rerun
gstack-pregate --allow-repo-commands`, the whole pregate, so the record keeps
its preflight checks) and the VM only as the opt-in (under `--no-ci-carry` it
names the VM route); or
- after running it, `gstack-pregate clear --remote pregate.json/remote_gate
--receipt <file>` with the gate's output, validated as `--remote` validates it
(a run URL is refused, `PREGATE_RECEIPT_INVALID` for a bad receipt; a receipt
@@ -900,6 +915,113 @@ as a usage error. It changes nothing in the preflight stage: with `--stage all`
the preflight checks, the secrets scan included, run as before, and
`--stage tests` runs no preflight check in that invocation, with or without it.
<a id="ci-carry"></a>
### CI carry: pregate and /ship use CI's run instead of running again (GSTA-258)
CI is the one full test run. When a verified run already passed at the exact
tree being tested, pregate's tests stage and /ship's test lanes use it instead
of running the suite a second time. `lib/ci-carry.ts` `findCarry` decides, and
it accepts a run only when all of these hold:
- The working tree is HEAD's committed tree (the `gstack-wtree` fingerprint
equals `HEAD^{tree}`), read before the lookup and again after it. A dirty
tree, an untracked file or an edit made during the lookup never carries.
- The newest of HEAD's runs (up to 10) under each named profile passes
`verifyRun` above: same-repository head (a fork run never counts), the
profile's workflow, event and jobs, the newest attempt, no newer red or
running run on HEAD, within `max_age_hours`, and a `tree.json` naming HEAD's
tree. A `pull_request` run counts only when the pull request already
contained its base.
- Every run executed tests, and with an inventory command, the runs'
`tree.json` files cover every file it prints. Each run is read once more
afterward (`run_changed`).
Security and advisory scans, the sealed guard, guards and every preflight-stage
check always run; only test lanes carry.
**The tests stage.** Without `--from-run`, when the base branch declares
`run_profiles`, `gstack-pregate` looks up the newest verified run at HEAD under
the profiles `--from-run` would use and applies it as `--from-run` does: the
selected local files its `tree.json` lists count as passed, and only the rest
run. On a small machine a selection the run covers records no remote gate
obligation. It prints `CI_CARRY: accepted <receipt>` or `CI_CARRY: none
(<reason>)` on stderr; `--no-ci-carry` skips the lookup.
When files are left over, the remote gate obligation still clears from the
same run when the base declares `remote_gate_runs` naming that run's profile:
the run must pass every `clear --run` check, with the carried files counted
as selected files. So a remainder of only files the gate's `skipped` command
names (gstack's paid evals, which the gate itself reports as
`PREGATE_REMOTE_PAID_ONLY` with `tests=0`) clears with `skipped=<n>` in the
receipt instead of becoming an obligation nothing can clear. The line is
`CI_CARRY: cleared pregate.json/remote_gate <receipt>`, or `CI_CARRY:
pregate.json/remote_gate stays uncleared (<reason>)` when a check refuses, for
example a gate inventory file missing from the run's `tree.json`.
**gstack's own gate.** gstack's `.gstack/pregate.json` declares
`remote_gate_runs` with the `ci-reuse/free-tests` profile, so on gstack a green
Free Tests run at the same tree is how the remote gate obligation clears, and
the Ubicloud VM (`gstack-pregate --stage tests --remote`) is an opt-in for when
no such run exists. The order after a push: wait for Free Tests
(`gstack-ci-carry wait --base main`), then `gstack-pregate
--allow-repo-commands`. A `pull_request` run tests GitHub's merge commit, so on
a branch that does not contain the base tip it tested another tree and
`CI_CARRY` says `none (no_verified_run: … tree_mismatch)`: dispatch Free Tests
on the branch
(`gh workflow run free-tests.yml --ref <branch>`), which tests HEAD's own tree,
and wait again. The preflight stage (generated-file freshness, secrets, strays
and the other mechanical checks) and the sealed guard are unchanged and always
run here. The macOS and Windows lane
obligations still clear with `gstack-pregate clear --remote <lane> --run-url
<url>` from their CI runs.
**/ship lanes: `ship_lanes_from`.** A lane carries only when the base branch's
`.gstack/pregate.json` maps its exact command (the evidence ledger binds the
command's sha256; a label alone never carries) to the profiles whose runs carry
it and the command that lists its test files:
```json
"ship_lanes_from": {
"bun run test": { "profiles": ["ci-reuse/free-tests"], "inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'" }
}
```
A carried lane prints `LANE: <label> carried run=<id>/<attempt> tests_ran=<n>`
and writes an explicit CI-sourced ledger row (`gstack-evidence record-ci
--label <label> --base <base> -- <command>`, which finds and verifies the run
itself, so no caller hands it run ids or a test count: `source: "ci"`, its runs
and profiles, the run's finish time as `ts`, bound to the clean tree), never a
fictitious local run. `gstack-evidence check` grades it
like any row and names `source=ci runs=…`, so `finish`'s FRESH check and the
24-hour age limit apply to the CI run's own time. A mapped lane that cannot
carry prints `CI_CARRY: none label=<label> (<reason>)` and runs here. Every
other lane runs through `gstack-evidence run --reuse`: the newest row for its
label, command and working tree is reused when it passed with a counted run
under the same runtime (bun and node versions, OS, architecture, runner image)
within 24 hours; a newer failure on that tree vetoes an older pass, and the
reused copy keeps the original timestamp. The cache is this machine's ledger
only; across machines, CI is the trust boundary.
**Finish, push and accept.** `gstack-ship-release finish` commits the release
metadata, so the stamped tree has no CI run yet. A mapped lane that is not
already FRESH is deferred (`EVIDENCE: DEFERRED`), and the pregate runs only its
preflight stage on the stamped tree, written to its own file so the full
`.gstack/tmp/pregate.json` stays in place until `accept` replaces it. `gstack-ship-release push --early` (the
Phase 2 push that lets CI run beside the review) runs the secrets scan and the
sealed guard before it pushes. After the final push,
`gstack-ship-release accept --base <base> --lane …` waits (bounded by `--wait`)
for the run at the pushed HEAD, stops on a red or still-running run, carries
each mapped lane from the green run at that exact tree, reuses or runs the rest,
checks every lane FRESH and reruns the pregate there, whose tests stage carries
the same run. `gstack-ship-receipt write` records the carried runs as
`ci_carried: [{ lane, run, attempt, tree }]`, and the `SHIP_RECEIPT:` line names
them (`ci_carried=<lane>:<run>/<attempt>`).
**`gstack-ci-carry`.** `find [--profile <name>]… [--command <exact lane command>
--allow-repo-commands]` prints the verdict for HEAD; `wait [--timeout <s>]`
polls the newest run at HEAD until it completes (run it in the background) and
prints `CI_WAIT: green|red|running|none`, then `find`'s verdict on green.
<a id="ci-reuse"></a>
### Main pushes reuse the merged pull request's run
+3 -1
View File
@@ -139,7 +139,9 @@ harness-generic; only the thread and the PR tool are Capy.
default branch.
4. **Run `/ship` unattended** with the `GSTACK_LAUNCH` assignments
exported: it runs the pregate (on a machine under 8 cores the test tier
needs `gstack-pregate --stage tests --remote`, about $0.06 on a Ubicloud VM
clears from a verified CI run at the pushed tree when the base declares
`remote_gate_runs` ([CI carry](pregate.md#ci-carry)); otherwise it needs
`gstack-pregate --stage tests --remote`, about $0.06 on a Ubicloud VM
or free locally for small selections), both reviews, the gate-integrity
list, the docs audit, and writes the ship receipt into the PR body
(`gstack-ship-receipt write --pr <n>`). With `queue_mode:
+45 -10
View File
@@ -207,7 +207,14 @@ function but a same-file `url(#id)`, which closes `@import`, `image-set()`,
entity declaration, and no DOCTYPE but a bare `<!DOCTYPE svg>` or W3C's own SVG DTD, read after character references are decoded; anything else, a
script or `foreignObject` included, keeps it code. More than
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
and fails the depth closed. The scan first lists the files that contain a candidate's
extension and then searches only those, docs files left out, so a large data tree
costs one cheap pass. Each scan gets one 120-second deadline, set in milliseconds with
`GSTACK_CONSUMER_SCAN_TIMEOUT_MS`; a scan that runs past it, or whose matches pass
256 MB, is also `consumer_scan_failed`, and its detail says what stopped it and names
the deadline. One scan fits a minute inside the 180 seconds /ship gives
`gstack-review-plan`; a plan that also scans a fix range runs two scans, and if they
pass that limit the plan fails, which also leads to a full review. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same
function); every path-only caller (the receipt's gate-scan decision, the Step 16
post-review route, /autoplan's docs-only scope) keeps data files code. A numeric claim
@@ -424,6 +431,24 @@ Over the last 100 non-release commits on main (8663c3d2..8be7f213), money hit
which hit 43 of 100; those paths already get full depth, so the gate class was
narrowed to the gate scan's tagged hunks.
<a id="review-exclude-paths"></a>
## What readers read under `review_exclude_paths`
A repository can list content folders its reviewers leave out, on its base
branch only ([ship-policy.md](ship-policy.md#review-exclude-paths)). Under it,
every reader (specialists, the Red Team, the native and outside voices, the
targeted reader) reads the same selection: its `DIFF_CMD:` with one
`:(top,exclude,glob)` item per applied pattern, the fixture command with the
same items, and the `DIFF_KEEP:` command for the kept files under those
patterns. The outside voice's embedded diff is that selection. Each brief says
how many files and bytes were left out and that the reader must not report on
their absence. Excluded paths count as docs for the depth and never trigger the
numeric-claim scan or a targeted class; kept files keep their own class.
Independently of the key, no reader is handed more than 2,000,000 bytes of
diff: `gstack-review-dispatch` stops with `REVIEW_DIFF_TOO_LARGE` before it writes
anything, instead of a review of the part a reader happened to see.
## What the receipt records
`gstack-review-scope summary --since <REVIEW_SINCE> --run <run dir>` reads this invocation's
@@ -576,22 +601,30 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
second line for the same field starts another: `Severity` sets its label (a label leading the finding stays its floor),
`Blocks: yes` makes it CRITICAL and `Blocks: no` never lowers it; an inline
`Blocks: yes` is CRITICAL too, and with no finding above it is one. The native
adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167) unless collect `--manifest` names this
ledger's pass and its plan is the code or docs class with only `runtime=<n>` depth reasons (no `risk:`, `error:`,
`history:` or `floor:` reason). On such a plan a native finding's own `Severity` field decides and FIXABLE beside it
raises nothing once it also states `Blocks: no`; a native FIXABLE finding without `Blocks: no`, with no `Severity` field,
or whose field names only FIXABLE or INVESTIGATE still collects as CRITICAL (GST-987). An unnumbered, unlabeled bullet with no file:line
adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167), except that on every class a native
finding that states `Blocks: no` and one `Severity` field collects at that field (GST-1031 ruling A); a native FIXABLE
finding without `Blocks: no`, with no `Severity` field, or whose field names only FIXABLE or INVESTIGATE still collects
as CRITICAL. On a code or docs plan with only `runtime=<n>` depth reasons (collect `--manifest` naming this ledger's
pass; GST-987) FIXABLE also raises nothing through a label heading or a leading tag. A numbered finding heading keeps
every unlabeled line under it (located or numbered bullets, a bold subheading, a located paragraph) until its
`Severity` or `Blocks` line comes, so those fields never split off as a second record (GST-1031). 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 negated severity word still collects at its label ("found nothing CRITICAL", "No P0/P1 issues"), so a reader's
closing summary is a known phantom: it stays at that label in the judged set, lead judgment can fold it only into a
finding at least as severe, and it counts toward what blocks. GST-766 skipped negated labels and lost
closing summary is a known phantom: it collects at that label. When it is a summary candidate (no lead label, no
`Severity` or `Blocks` field, and no file:line or exception clause in any sentence naming a label: "so nothing is
CRITICAL", "the test covers the CRITICAL fix"), collect marks it `summary_candidate` and lead judgment may lower it
with a reason, which judge records as `dismissed` (GST-1031); otherwise lead judgment can fold it only into a finding
at least as severe, and it counts toward what blocks. GST-766 skipped negated labels and lost
real blockers named in an exception clause ("No CRITICAL issues except the token leak in log.ts:3"); GSTA-167
reverted that. 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.
instruction lives in a reviewer brief that changes only after a no-loss replay. A CRITICAL whose own text writes the
field `Blocks: no` (colon required, and no `Blocks: yes`) is not counted as a blocker by the ledger count, nor by the
review-log count or classify when the finding carries `stated` (GST-1031); lead judgment that raises such a line above
its label counts it again.
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:`
@@ -665,7 +698,8 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
working directory) and stops when that is another checkout or cannot be read: run /ship in the thread's own checkout.
A start directory outside any checkout, such as a multi-repo workspace root, holds no tree to review by mistake and
dispatches. Step 11's voices take the `adversarial-review` start
token by default, and a `logStart` list without it stops before anything runs.
token by default, and a `logStart` list without it stops before anything runs. Decision 3.5's fix-check pass turns a
`review` start into `review-fix-check`, so its row is never a pass (GST-1031).
## Where this is enforced
@@ -674,6 +708,7 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
- `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.
- `lib/review-exclude.ts`: the base policy's `review_exclude_paths` (matched set, kept reasons, the kept-file argument bound, the digest the receipt, carry and the queue compare) and the 2,000,000-byte reader ceiling (`test/review-exclude.test.ts`).
- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the depth (`depthFor`, `DEPTH_RULES`, `depthOfCommitInput`), each dispatch's `exclude` pathspecs and `DIFF_CMD`, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`).
- `lib/review-collect.ts`: `collectCommit` (git objects only, from any checkout) and `collectLive` (the working tree), per-path lines and the base commit's registry (`test/review-collect.test.ts`).
- `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`, `unrecorded`, `ledgers_read`); refusals are `REVIEW_SCOPE_INPUT`, `REVIEW_SCOPE_RANGE` and `REVIEW_SCOPE_LEDGER` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
+124 -1
View File
@@ -58,6 +58,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
| `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. |
| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log <file>` from its run on the gated head. Needs `min_gstack_version`. |
| `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`. |
| `review_exclude_paths` | git glob pathspecs | `[]` | Content files (data, docs, binary, `.log`) `/ship`'s reviewers leave out; read from the base only, with code, gate, config, executable and named-by-code files kept. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. |
| `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, 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`. |
@@ -1333,6 +1334,127 @@ opens no fence, and a fence that never closes stays prose, along with the rest
of the body. Data PRs (evals,
measurements, sweeps) run it before READY so the body's numbers are the run's.
<a id="review-exclude-paths"></a>
## `review_exclude_paths`: what reviewers leave out
Since the release that added it (`REVIEW_EXCLUDE_KEY_VERSION` in
`lib/ship-policy.ts`). A results PR can carry thousands of receipt files that
no reviewer needs to read. `review_exclude_paths` lists git glob pathspecs
whose content files `/ship`'s reviewers leave out:
```json
{ "review_exclude_paths": ["receipts/**"], "min_gstack_version": "<the release that introduced review_exclude_paths>" }
```
**Read from the base only.** `gstack-review-plan` reads the policy committed on
the base commit it already resolved (`origin/<base>`, or `<base>`), never
the branch's copy, so a branch cannot exclude its own files. A branch that adds
the key gets `REVIEW_EXCLUDE: none (head sets review_exclude_paths; it applies
after it lands on <base>)` and a normal review. A base policy that does not
validate stops the plan (`POLICY_INVALID`), and a base git cannot read stops it
(`POLICY_SOURCE_UNAVAILABLE`); neither turns exclusion off quietly.
**Git's glob dialect.** Each pattern is a `:(top,glob)` pathspec: root-anchored
and case-sensitive. The matched set comes from git itself (`git diff --raw -z`),
so the plan, the receipt and what readers see are one set. Untracked files are never part of it: no reader's `git diff` shows them, and the receipt reads the committed head.
| Pattern | gitignore would match | `review_exclude_paths` matches |
|---|---|---|
| `receipts/**` | everything under `receipts/` at any depth | everything under the top-level `receipts/` |
| `receipts/*` | files directly in `receipts/` | files directly in `receipts/` only (`*` never crosses `/`) |
| `data/` | any `data/` directory at any depth | nothing: write `data/**` (and `**/data/**` for any depth) |
A pattern that matches no changed file is listed as `unmatched=` on the plan line.
**What is excluded, and what is kept.** A matched file is excluded only when it
is content: a data file (JSON, JSONL, CSV, TSV, images), a docs-class file, a
`.log` file, or a file git reports as binary (`-\t-` in `--numstat`; readers would only see `Binary files differ`) whose bytes hold a NUL in their first 8,000, so a branch's `.gitattributes` cannot make a text file binary content. A binary file is content even under
`config/` or in a skill directory; only `policy-dir`, `gate-path`, `executable`, `link`, `gate-tagged`, or a source or script extension (`code`) keep it.
Everything else stays in every reader's diff with one reason code:
| Reason | Kept because |
|---|---|
| `policy-dir` | it is under `.gstack/` or `.github/` |
| `gate-path` | it is a gate path (lib/review-depth.ts) |
| `executable` | its old or new mode is executable |
| `link` | its old or new mode is a symlink or a submodule commit |
| `agent-prose` | it is a skill, section, resolver, template or rule file |
| `config` | it is a data-shaped config file (`package.json`, `tsconfig*.json`, `config/`, dotfiles) |
| `gate-tagged` | the gate scan tagged one of its hunks |
| `code` | it is code or a test, not content; a binary file with a source or script extension (`.js`, `.py`, `.sh`, `.wasm`) counts as code |
| `secret-line` | an added line reads a credential (the targeted pass's secret rule) |
| `named-by-code` | a code or test file that stays in review (not docs, a data file, a `.log` or binary, and not itself excluded) names its full repository path, at the head or the merge base |
| `consumer-scan-unavailable` | the `git grep` for those names failed, so every candidate is kept; also a live binary file whose bytes could not be read |
| `gate-scan-unavailable` | the gate scan failed or did not run at the head, so no hunk's tag is known and every candidate is kept |
Only a full path counts as a name. A basename such as `result.json` would keep
every receipt in the tree, and a directory reference such as `receipts/` (the
harness that writes the receipts names it) keeps nothing: the plan prints an
informational `REVIEW_EXCLUDE_REFERENCE:` line naming the code that reads the
folder. A data file never keeps another file: a results manifest such as
`docs/receipts-manifest.json` lists every receipt by full path, and it is
content, not a program that reads them. The name scan is one uncapped `git grep -F -f` over the candidates'
paths, not the collector's capped consumer scan.
**What readers run.** Every reader's `DIFF_CMD:` gains one
`':(top,exclude,glob)<pattern>'` item per applied pattern, and the fixture
command the security specialist, the Red Team and the native adversarial pass
read through gets the same items. A git exclude beats any positive pathspec, so
kept files under an applied pattern come back through a second command,
`DIFF_KEEP: <reader> git diff <diff-base> -- ':(top,literal)<path>' …`, which every
brief names. That command is bounded by its argument bytes (24,000 across all
patterns, under Windows' command-line limit): a pattern whose kept files would
pass the bound is not applied, and the plan line says so as
`skipped=<pattern>:kept=<n>:bytes=<b>`. Every brief also gets one line:
`Excluded by base policy <ref>@<sha12> (selection <digest>): <n> files, <bytes> bytes
on this branch matching <patterns>. They are not in your diff; do not report on
their absence.`
**Depth, numbers and the targeted pass.** Excluded paths count as docs for the
review depth everywhere it is computed (`gstack-review-plan`, the receipt
writer, carry and the queue), and their lines never trigger the numeric-claim
scan or the targeted money, gate, auth and secret classes. Defects inside a
correctly excluded data file are coverage the owner gives up by policy.
**The plan line.** `REVIEW_EXCLUDE: patterns=<applied>/<declared> excluded=<files>
bytes=<b> kept=<k> content=<kind>:<n>,… source=<ref>@<sha12> digest=<sha256>`
(`content=` counts the excluded files by kind: `binary`, `data`, `docs`, `log`), then up to 20
`REVIEW_EXCLUDE_KEPT: <reason> <path>` lines (`--json` lists every one). A
repository with no key anywhere prints none of these lines, and its plan, briefs
and diff commands are byte for byte what they were.
**The receipt, carry and the queue.** Each rendered brief records the digest it
names in the review ledger, and `gstack-review-dispatch verify` prints
`REVIEW_EXCLUDE_RECORD: digest=<d>` when every reader's newest brief names the
same one (a mixed set makes it `VERIFY: dirty`). `gstack-ship-receipt write
--review-exclude <d>`, with that digest, records `review_exclude` (source, patterns, counts, the gate-tagged kept
paths and three hashes). The `digest` hashes the declared patterns and the
excluded and kept lists; `source` is provenance only. Write, carry and the queue
each recompute the digest at the current base and compare it, so a base merge
or a moved base that leaves the patterns and the lists unchanged still carries
and binds. A different digest means the reviewers read another selection:
write refuses with `RECEIPT_INVALID field=review_exclude`, carry with
`RECEIPT_CARRY_REFUSED review_exclude changed`, and the queue with
`RECEIPT_INVALID field=review_exclude expected=<digest> recorded=<digest>`. The
fix is to rerun the review, never a receipt write alone.
**Adopting it.** (1) Open a PR on the base that adds the patterns and
`min_gstack_version` at least `REVIEW_EXCLUDE_KEY_VERSION`, and ship it like any
policy change. (2) Once it merges, bring the base into the results branch (a
crew needs its coordinator's word for that merge) and rerun `/ship`; the plan
prints `REVIEW_EXCLUDE:`. (3) Check the kept list: a file you expected to be
excluded names its reason.
<a id="review-diff-ceiling"></a>
**The review ceiling.** Independently of the key, `gstack-review-dispatch` reads
each reviewer's diff once before it writes a manifest or a brief (`pass`, `stage`
and `render`). A diff over 2,000,000 bytes (about 500K tokens) stops with
[`REVIEW_DIFF_TOO_LARGE`](troubleshooting.md#review-diff-too-large), exit 3, and a
fix chosen by where the policy stands; a `git diff` that fails or runs past
120 seconds stops with
[`REVIEW_DIFF_UNREADABLE`](troubleshooting.md#review-diff-unreadable), exit 1.
There is no override flag: the recovery is a policy PR on the base, or a split.
<a id="review-scope"></a>
## `review_scope`: how the review loop converges
@@ -1462,6 +1584,7 @@ receipt when the base moves:
| 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`) |
| a stamp on the branch's own rows in a stamp file the branch also changed as product: every changed line at a branch-added position, the file's mode unchanged, the base not touching it, the rest of the product diff unchanged | carries (reason `a stamp on the branch's own rows (<file>)`) |
**Stacked PRs (#3570).** A child PR's receipt is written against its parent
branch, so its `base` names that branch and its `product_patch_id` is the
@@ -1632,7 +1755,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and its copy at the base holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so.
2. **release_tool**: the policy command, under the contract below.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION. A base line whose text duplicates a line the branch added gets the branch's bound too, because the checks match branch lines by text, not position (GST-979). The /ship receipt carry can still refuse the move (`RECEIPT_CARRY_PRODUCT_CHANGED`), and when the branch keeps its VERSION claim, carry and finish still route the move as behavior (GST-980).
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), the /ship receipt carry, and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION. A branch line is named by its position in the gated file, so a base line whose text duplicates a line the branch added keeps the gated head's bound (GST-979). When the stamp keeps the VERSION (a slot that keeps its claim), only the branch's own lines may move, so a base line moved by hand is refused. The carry follows a stamp on the branch's own rows in a stamp file the branch also changed as product when every changed line of that file sits at a branch-added position, the file keeps its mode, the base did not touch it and the rest of the product diff is unchanged (`a stamp on the branch's own rows` in the carry reason, GST-980).
5. **mirror**: tier 2; declared and validated, reported as not run.
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
+8 -2
View File
@@ -631,6 +631,8 @@ One exception: a shortcut you took deliberately and logged. A `gstack-shortcut(d
**Design pass.** When the diff touches frontend files, the Design specialist reads `review/design-checklist.md`, which is generated from `lib/design-catalog.ts`, so `/review`, `/ship`, and `/design-review` flag the same patterns under the same rule ids. If you have [impeccable](https://impeccable.style) installed, its engine scans the changed frontend files first (`.dart` files get the checklist pass only): its rows bucket by tier (auto-fix, ask, possible), a detector hit and a checklist hit at the same file:line collapse into one row, and your repo's `.impeccable/config*.json` ignores are read as settled decisions. Without it, the checklist pass runs alone.
**PR type.** The checklist starts by naming the kind of PR from the changed paths: docs-only, CI/infra, scripts/tooling, application or mixed, and the review's first line says which (`PR type: <type>`). Application and mixed diffs run every category as before. A CI/infra, scripts/tooling or docs-only diff always gets Gate Integrity, Shell Injection, Completeness Gaps, Distribution & CI/CD Pipeline and Repo Conventions & Platform Fit; every other category with nothing to check is listed once, in one line, as not applicable, and never counts as passed. Repo Conventions & Platform Fit flags new files that don't match the languages and tools the repo already uses or the platforms its CI runs on, such as a `.bat` build script in a bash-and-Makefile repo.
### Example
Suppose the smart listing flow is implemented and the tests are green.
@@ -1538,7 +1540,7 @@ Install Greptile on your GitHub repo at [greptile.com](https://greptile.com) —
The problem with any automated reviewer is triage. Greptile is good, but not every comment is a real issue. Some are false positives. Some flag things you already fixed three commits ago. Without a triage layer, the comments pile up and you start ignoring them — which defeats the purpose.
gstack solves this. `/review` and `/ship` are now Greptile-aware. They read Greptile's comments, classify each one, and take action:
gstack solves this. `/review` and `/ship` read your review bots' comments (Greptile, CodeRabbit, Macroscope, or any bot you name), classify each one, and take action:
- **Valid issues** get added to the critical findings and fixed before shipping
- **Already-fixed issues** get an auto-reply acknowledging the catch
@@ -1548,7 +1550,11 @@ The result is a two-layer review: Greptile catches things asynchronously on the
### Learning from history
Every false positive you confirm gets saved to `~/.gstack/greptile-history.md`. Future runs auto-skip known FP patterns for your codebase. And `/retro` tracks Greptile's batting average over time — so you can see whether the signal-to-noise ratio is improving.
Every false positive you confirm gets saved to `~/.gstack/review-bot-history.md`, tagged with the bot that made it. Future runs auto-skip that bot's known FP patterns for your codebase. And `/retro` tracks each bot's batting average over time — so you can see whether the signal-to-noise ratio is improving.
### Choosing your bots
The bots are the two lists `gstack-pr-wait` uses: `gstack-config set pr_wait_gating_bots greptile,macroscope` (waited for, then triaged) and `gstack-config set pr_wait_advisory_bots coderabbit,copilot` (triaged, never waited for). Set both to `none` to turn bot triage off. Greptile's early review (`/ship` opening the PR early) runs only while `greptile` is in one of the lists.
### Example
+1
View File
@@ -21,6 +21,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-evidence` | **Verification-evidence ledger** — `run --label <lane> -- <cmd>` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint (tracked and untracked files that are not gitignored, minus an untracked `.bun/` folder at the repo root, which Bun can create during a run); a run whose files changed while it ran is recorded without a fingerprint and with up to five of the changed paths, which `check` names in its STALE reason. `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. |
| `gstack-flaky` | **Flaky-test rerun for any JUnit runner** — `run --junit <file.xml> -- <command>` runs your test command (Playwright, `bun test`, jest, vitest, pytest) and, when at most `--max-reruns` (3) tests failed and the runner made no retries of its own, reruns the whole command once (`--rerun-scope test` opts in to rerunning only the failed tests). A test that fails and then passes on that rerun is a flaky pass: the run stays green and one redacted line per flaky pass or failed rerun goes on a ledger (`GSTACK_FLAKE_LEDGER_JUNIT`, default `<state root>/flake-ledger-junit.jsonl`). A missing or malformed report, a crash or a killed runner never passes. `quarantine add|renew|release|check|report` keeps `.gstack/quarantine.json`: a quarantined test still runs, gets up to 3 attempts of the whole command, passes only on a full green attempt and is never counted as a pass (`run --base <ref>` reads the registry from the base branch). `ids --junit <file>` prints a report's test ids. Exit 0 pass, 1 a test failed, 2 usage, 3 no usable report. [Guide](flaky.md). |
| `gstack-pr-wait` | **PR event watcher** — `gstack-pr-wait <pr> [--repo owner/name] [--until green\|ready\|any-change] [--interval 20] [--timeout 60] [--exclude CHECK,...]` polls one pull request through `gh` and prints one stdout line per real event (a check failed with its link, all checks finished, a comment or review from someone other than the author, a review bot finished on the head, the base moved and its new commits touch this PR's files, BEHIND or DIRTY, merged or closed), with progress on stderr, so a monitor wakes the agent once per event instead of on a timer. `green` gates on every check on the head (an advisory bot's review is never waited for, its checks gate like any other), as `gstack-ci-gate` does; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission; two identical polls confirm a check set is complete; `--exclude` leaves a named non-required check out of the verdict as `gstack-ci-gate --exclude` does (a required name is refused and gates). Exit 0 when the condition is met, 1 when it needs action or `--timeout` passes (`PR_WAIT_BLOCKED`), 2 on bad input, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`). State under `<state root>/pr-wait/`, baseline discarded after 6 hours. Review bots are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`, `pr_wait_advisory_bots coderabbit` (advisory bots are reported, never waited on). `/land-and-deploy` Step 3 is one call of it. Design ported from tstack's pr-wait (MIT, Theo Browne; NOTICE.md). [Exit codes](troubleshooting.md#gstack-pr-wait). |
| `gstack-review-bots` | **Review-bot comments** — `fetch [--pr <n>]` reads every root line comment, conversation comment and newest review body from each bot in `pr_wait_gating_bots` and `pr_wait_advisory_bots` (Bot accounts only, all pages, through `gh`) into a private directory with per-bot counts, or prints `REVIEW_BOTS: skip` or `REVIEW_BOTS: error` (never a false zero). `record --dir <dir> --id <comment id> --type fix\|fp\|already-fixed --category <category>` appends one triage outcome to the project and global `review-bot-history.md`, taking the bot and file from the fetched comment. `/review` Step 2.5 and `/ship` Step 10 run it. [Triage reference](../review/greptile-triage.md). |
| `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue <n>`, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. |
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone or iPad over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](howto-ios-testing-with-gstack.md). |
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
+102 -6
View File
@@ -146,7 +146,9 @@ record. Another successful fetch alone cannot certify corrupted history.
Outside reviews send your diff, plan or question to a second AI provider. Their
verdict comes from `lib/outside-review-result.ts`, which prints
`VERDICT: clean|findings|unverified|unavailable`. A `findings` verdict with a
`VERDICT: clean|findings|unverified|unavailable`. When given an events file, it
also prints `OUTSIDE_USAGE:` with the token counters Codex reported, or
`OUTSIDE_USAGE: unknown` when it reported none. A `findings` verdict with a
P0 or P1 finding blocks exactly like a native P0/P1. `unverified` and
`unavailable` are missing coverage: /ship and /review continue, show the gap in
the readiness dashboard and the PR body, and never count it as a pass.
@@ -2434,7 +2436,10 @@ default `fetch-depth: 1`), a remote branch never fetched
first parent is missing from a shallow history. The tool never guesses a
base, because an empty diff against a wrong or absent base would read as "no
gate edits" and pass review. `gstack-diff-scope` prints `SCOPE_ERROR=no_base`
(all flags false, exit 2) for the same cause.
(all flags false, exit 2) for the same cause, and also when no base was given
and none resolves because `origin/HEAD` names the checked-out branch (or its
upstream, or HEAD's own commit) and no PR base, live remote HEAD, `origin/main`
or `origin/master` resolves (its comment line says so).
**Fix.** Run the command the line prints. It is built for this checkout:
`git fetch origin +<branch>:refs/remotes/origin/<branch>` for an unfetched
@@ -3330,6 +3335,72 @@ block. For the `--head` refusal, check out that commit and write there. For a
targeted refusal, run the targeted reader on HEAD, `gstack-review-dispatch verify
--targeted-out .gstack/tmp/targeted.json`, and write again with
`--review-targeted @.gstack/tmp/targeted.json`.
**`field=review_exclude`.** `gstack-ship-receipt write` and the queue's receipt check also
use this code when the receipt's review_exclude digest (or the
`--review-exclude <digest>` write was given) is not the digest HEAD has under the
base policy's [`review_exclude_paths`](ship-policy.md#review-exclude-paths). The line
names `expected=` (now), `recorded=` (what the review read, `none` when it read
without exclusion) and `source=` (the base commit). The reviewers read another
selection than the one the head now has, so a receipt write alone cannot clear
it: rerun `/ship`'s review on the head (`gstack-review-dispatch pass --base <base>`),
run `gstack-review-dispatch verify --ledger <ledger>`, which prints
`REVIEW_EXCLUDE_RECORD: digest=<d>` from the briefs the readers got, then
`gstack-ship-receipt write --pr <n> --review-exclude <d> --body <body>`.
<a id="review-diff-too-large"></a>
### `REVIEW_DIFF_TOO_LARGE`
**Meaning.** One reviewer's diff is over the 2,000,000-byte review ceiling
(about 500K tokens). `gstack-review-dispatch pass`, `stage` and `render` read
each reader's diff once before writing anything, and stop (exit 3, nothing
written) rather than hand a reader more than it can read. The line names the
reader, its bytes, the five largest top-level folders (`top=<dir>:<bytes>`) and
the base policy commit.
**Fix.** The line prints the fix for where the base policy stands:
- **No key:** copy the printed JSON (`{"review_exclude_paths":[…],"min_gstack_version":"…"}`)
into `.gstack/ship-policy.json` on the base as its own PR. A lane cannot land on
the base itself: ask the repo owner or the coordinator. Once it merges, bring
the base into the branch and rerun once; if it is still over, split the PR.
- **The branch declares the key but the base does not:** only the base copy
applies; land that hunk on the base first, as above.
- **A pattern was not applied** (`skipped=` on the `REVIEW_EXCLUDE:` line): its kept
files need more than 24,000 argument bytes. Narrow the pattern, move the kept
files out of it, or split the PR.
- **Still over after exclusion:** add the printed folders to the base policy, or
split the PR.
There is no override flag. See [the review ceiling](ship-policy.md#review-diff-ceiling).
<a id="review-diff-unreadable"></a>
### `REVIEW_DIFF_UNREADABLE`
**Meaning.** git could not read a reviewer's diff, so the pass stopped (exit 1)
before writing a manifest or a brief. `reason=timeout` means `git diff` ran past
`timeout_ms` (120,000 ms; fixed, no flag); `reason=git-exit` gives git's exit code.
The line carries the diff base, git's first stderr line with remote URLs
replaced by `<url>`, and `log=`, a file under the run directory holding git's whole
stderr, redacted the same way. It is also printed when git cannot list the
files `review_exclude_paths` matches.
**Fix.** By reason: a timeout, rerun once unchanged, then split the PR or
exclude its largest data folders on the base; git exit 128 (a missing object or
an unfetched base), `git fetch origin <base>` and rerun once; anything else, stop
and ask a person with the log file.
<a id="review-exclude-reference"></a>
### `REVIEW_EXCLUDE_REFERENCE`
**Meaning.** Informational, never a stop. A kept code file names a folder that
`review_exclude_paths` excludes (for example the harness that writes
`receipts/`). A folder reference does not keep files; only a full file path
does, so readers see that code without the files it reads.
**Fix.** Nothing for the review itself. If the code's correctness depends on the
excluded files, narrow the pattern on the base so they stay in the diff.
<a id="execution-unknown"></a>
### `EXECUTION_UNKNOWN`
@@ -3848,14 +3919,14 @@ the lane log, then rerun or split the selection. A tool missing from PATH is
<a id="pregate-stalled"></a>
### `PREGATE_STALLED`
**Meaning.** A test command the lanes check (or a guard) launched wrote nothing to its log for the stall timeout (default 300 s,
**Meaning.** A test command the lanes check (or a guard) launched wrote nothing to its log, and its process group used under a tenth of one CPU (Windows: log growth alone), for the stall timeout (default 300 s,
`stall_timeout_s` in the base branch's `.gstack/pregate.json` or the user-owned file, or
`--stall-timeout`). pregate killed its process group and names the last test
file started. The check is incomplete, never pass.
**Fix.** Open the printed log, find the hang in the named file (a blocked
child, a server that never exits, a synchronous wait), fix it and rerun
`gstack-pregate --stage tests`. A test that is legitimately quiet for longer
`gstack-pregate --stage tests`. A test that legitimately waits quietly for longer
needs a larger `--stall-timeout`; `0` disables the stall kill and leaves only
`--timeout`. The `--remote` gate never stalls: it is bounded by `--timeout`
(else `timeout_s`), so a gate that waits on CI needs a timeout long enough for
@@ -5488,6 +5559,29 @@ wait`'s `--timeout`.
**Fix.** Run `wait` again (the pause stays valid until it expires), or find who
owns the base's red.
<a id="base-contains-head"></a>
### `BASE_CONTAINS_HEAD`
**Meaning.** A base-relative step (`gstack-ship-queue ready`, `land` and
`train` receipt binding, `gstack-review-plan`, `gstack-pregate`,
`gstack-gate-diff`, `gstack-diff-scope`, `gstack-docs-candidate snapshot`)
was given a base that is the head or already contains it, while the branch
has commits its real base lacks. The comparison would read the branch against
itself and report no changes, a false green. The usual cause is a checkout
made by a PR flow: `refs/remotes/origin/HEAD` names the PR branch and there is
no local `main` (GST-774). The detail names the base, the head, and the real
base (the PR's base from `gh`, else the remote's live HEAD from
`git ls-remote --symref`, else `origin/main`, else `origin/master`). For
a pull request's head the queue refuses whenever the base contains it. On the
base branch itself, on a detached HEAD, and on a fresh branch with no commits
past its base, the step runs as before. `gstack-diff-scope` reports it as
`SCOPE_ERROR=base_contains_head` (all flags false, exit 2) with the reason on a
comment line.
**Fix.** Pass the real base (`--base main`, or the PR's base branch). When
`origin/HEAD` names the checked-out branch, `git remote set-head origin main`
repairs every default at once.
<a id="queue-receipt-depth-raised"></a>
### `QUEUE_RECEIPT_DEPTH_RAISED`
@@ -5895,8 +5989,10 @@ retargeted stacked PR the parent branch and the base.
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, 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
landed on the base, a stamp on the branch's own rows in a stamp file it also
changed as product (every changed line at a branch-added position, the mode
unchanged, the base not touching the file), 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)).
+54 -4
View File
@@ -216,8 +216,9 @@ review-log row is the same row every Step 11 pass writes
(the outside provider that reviewed, with the model recorded, for the
cross-family subagent; `codex` for completed Codex CLI output, as
`ship/sections/adversarial.md` prescribes),
`outside_provider` (the provider the host would have used, as the generated
prose names it), `outside_status` (`completed` for a finished outside pass;
`outside_provider` (on Capy, `host-subagent`, with `outside_model`, both copied
from the pass program's `OUTSIDE_RECORD:` line; elsewhere the provider the
generated prose names), `outside_status` (`completed` for a finished outside pass;
`unavailable` when neither the CLI nor a subagent completed, the cause on the
`gstack-outside-voice run:` stderr line and in `<out>.meta.json` rather than
in the row; `disabled` under
@@ -237,7 +238,16 @@ when `input.models['outside-voice']` is empty and the lane model is
rounds ran. Any other lane must name a model. An outside voice from the same
provider as the lane model or the native voice stops the pass (`STOP:`) before
either voice is created, including on a rerun, and each outside voice prints an
`OUTSIDE_VOICE:` line saying whether it is the certified configuration.
`OUTSIDE_VOICE:` line saying whether it is the certified configuration. An
outside voice still running with no output after `input.stuckMs` (20 minutes by
default) is stopped, and one that stops or fails without leaving findings, or
finishes empty, is collected as unavailable coverage (`COLLECT_UNAVAILABLE:`)
instead of stopping the pass, so Step 11 goes on to judge the native voice. A
stopped or failed outside voice whose reply holds findings is collected like any
finished one, so its findings are kept; one whose reply holds none is unavailable. That outside voice then gets its own
row (`source: "host-subagent"`, `status: "unavailable"`, `completed` and
`converged` false, written without `--finish` before the native row), so a
missing voice never reads as converged or as an unavailable Codex CLI.
### The carry across a base merge or a test-only fix
@@ -337,7 +347,7 @@ in about a hundred lines, generated from the code.
```bash
AP=bin/gstack-autoplan
$AP next --out <dir> --plan <plan.md> [--ui|--no-ui] [--developer-tool] [--no-dx] [--light] [--spend-cap <usd>]
$AP next --out <dir> --plan <plan.md> --full [--ui|--no-ui] [--developer-tool] [--no-dx] [--light] [--spend-cap <usd>]
# PHASE: ceo snapshot=<sha> order=ceo,design,dx,eng
# SCOPE: ui=yes (...) dx=yes (detected: ...) source=ui:detected,dx:detected
# DRIVER: <root>/autoplan/DRIVER.md
@@ -353,6 +363,46 @@ and, when CLI flags differ from the plan's marker, `SCOPE_PIN: add <!--
gstack-autoplan-scope: … --> as the first line of <file>`; gstack never writes
the plan.
### Kinds: fix, feature and full (R65)
The example above is the full run. The first `next` also takes `--kind`:
- **`--kind fix`**: a bug fix gets no plan review. `gstack-autoplan next --kind fix`
prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and
`GSTACK_RESULT: skill=autoplan status=skipped run=-`, exits 0 and writes
nothing (no `--out` or `--plan` needed; `--json` prints one object with
`status: "skipped"`). `run --kind fix` does the same before any paid call.
An `--out` that already holds a run is refused. The PR still owes the bug's
proof: a repro on current main or a failing test written first.
- **`--kind feature`**, the default for an unattended run on Capy
(`GSTACK_CAPY=1`) that names no kind, is the lean run. The run is the `eng`
phase alone with one native reviewer, whose prompt merges the CEO and Eng
lenses (Design and DX only when scope detects them) and always asks "Any
outside terms, licences or rights involved?". The reviewer reads the draft
code with the plan: `git diff` from the merge base with `--base <ref>`
(default `origin/HEAD`), untracked files included and the run directory
left out, written into the phase's snapshot directory as `draft.diff` and
hashed into `run.json` (`phases.eng.draft`) and the `phase_opened` journal
row. With no code yet, the run says so: `DRAFT: none (<why>)`. A git
command that fails is `DRAFT: none (draft capture failed: …)`, never a
partial draft. An untracked file whose name looks like a secret (`.env*`,
`*.pem`, `*.key`, `id_rsa`, `credentials*`, `*secret*`) is left out with a
note, and the `DRAFT:` line names the checkout it diffed (`root=`). The prompt inlines the diff in a fence longer than any tilde
run inside it, cut at 100,000 bytes (`shown=` on the `DRAFT:` line) with a
note asking the reviewer to name what it didn't see. The reviewer's
line after INPUT names the draft (`DRAFT: <sha256>` or `DRAFT: none`), or
submit refuses the result with `RESULT_RECEIPT_MISSING`. A hand-run record
bound with `--attest` has no receipt at all, so its deviation says the
draft was not bound. `run`, which pays for both voices, stays the full run
unless `--kind feature` asks for the lean one. There is no outside
voice, `--max-rounds` defaults to 1, carry-forward is off, and the gate has
one revision: `revise --incorporates` applies the owner's accepted changes
without re-review (`APPLIED: … (lean run: one gate revision; …)`) and
approves. The gate and `gstack-owner-brief render --captain` relay work as
in the full run. A paid-eval prereg counts as a feature.
- **`--full`** restores every phase and both voices. Off Capy, and in an
interactive session, the default stays full; `--ceo-only` stays its own run.
1. **Dispatch each `ATTEMPT:`.** The native voice is a subagent on the parent's
own model given the prompt file; the outside voice is a subagent on another
model family, or `bin/gstack-outside-voice run --runner codex-cli|api