diff --git a/.gstack/pregate.json b/.gstack/pregate.json
index f9cb2832c..0454e74f3 100644
--- a/.gstack/pregate.json
+++ b/.gstack/pregate.json
@@ -20,5 +20,5 @@
"windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list"
},
"warn": [],
- "strays": { "allow": [] }
+ "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
}
diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json
index d188eced9..d50acbc38 100644
--- a/.gstack/ship-policy.json
+++ b/.gstack/ship-policy.json
@@ -14,5 +14,5 @@
"pr_count": { "default_pr_count": 1, "split_requires_owner": true },
"maintainers": ["garrytan"],
"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", "trial_reservation_usd": 1.5 },
- "min_gstack_version": "1.91.146.0"
+ "min_gstack_version": "1.91.299.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2e5a8554..b43cecf5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,138 @@
### Changed
- `lib/decision-trail.ts` holds the trail append rules shared by `gstack-decision-trail` and the carry.
+## [1.91.313.0] - 2026-10-29
+
+**The PII scanner stops flagging two kinds of ordinary text: a bare power-of-two or hash constant in code, and a package@version-style filename.** Eval artifacts and PR bodies that print a PRNG range or name a versioned results file no longer raise a MEDIUM phone or email finding, and real phone numbers and addresses next to them still report.
+
+### Fixed
+- **`lib/redact-patterns.ts`**: `pii.phone.e164` skips a bare number that is exactly a power of two from 2^30 through 2^49, one less than one, or one of eleven well-known 32-bit hash and PRNG constants. The same digits written with a `+`, spaces, dashes, dots or parentheses still report (#3577).
+- **`lib/redact-patterns.ts`**: `pii.email` skips a filename whose part after the `@` is a version or tag followed by file extensions no top-level domain uses, or a dotted version followed by an extension that doubles as one. A file named after a real address, an address at a numeric domain such as 163.com, and an address under a top-level domain that doubles as an extension (`.sh`, `.md`) still report (#3576).
+
+### Tests
+- `test/redact-constant-and-at-filename.test.ts`: pins both exemptions in both directions, including files named after a real address.
+
+## [1.91.312.0] - 2026-10-29
+
+**An install check run from inside /ship no longer upgrades gstack under it.** /ship's docs step runs `scripts/install.sh --for document-release`. That run fast-forwarded the checkout from 1.91.298 to 1.91.299 in the middle of a lane's /ship, so the receipt writer's rules changed partway through the run (#3571).
+
+### Fixed
+- **`bin/gstack-capy-session`**: in install mode, a live workflow lease under 120 minutes old held by the caller's own process chain now skips the self-upgrade: `SELF_UPGRADE: skipped (a gstack run in this session holds a workflow lease on this installation: pid
(m ), so the upgrade waits until the lease is 120 minutes old; …(UPGRADE_BUSY))`. The install still certifies the installed revision and the trailer stays ok with `behind=`. `bin/gstack-upgrade` deliberately ignores the caller's own lease, and an install started from inside the run passed that check. Each `gstack-skill-start` renews the lease. The age bound matters on Capy, where the leased pid is the machine's long-lived `capy-server`, so a lease is never released by its process exiting. An older lease, or none, upgrades as before, and `GSTACK_UPGRADE_FORCE=1` or a hand-run `bin/gstack-upgrade` still moves the checkout.
+
+### Tests
+- `test/capy-skill-wrapper.test.ts`: an install run under a shell that holds a fresh lease leaves HEAD where it was and stays ok. Once that lease is 121 minutes old, with its holder still alive, the same install upgrades. Before this change the first run printed `SELF_UPGRADE: done 1.0.1 -> 1.0.2`.
+
+## [1.91.311.0] - 2026-10-29
+
+**A sealed policy can now refuse `sealed//tasks.json` while allowing the `MANIFEST.json` beside it, and can catch `HELDOUT/` with a `heldout` glob.** The sealed guard only had deny globs and one-commit waivers, so the gstack-evals layout, which keeps a public manifest of hashes and counts next to its sealed tasks, needed a fresh `.gstack/sealed-allow.tsv` line for every manifest commit (gstack-evals wiki/board/2026-10-28-gsta-9-sealed-manifest-glob-gap.md). Their first draft also wrote `sealed/**`, which never matched their nested `tasks//sealed/` directories, and garrytan/gstack-evals#3 (dc54796) hand-listed `Sealed/`, `HELDOUT/` and other case variants.
+
+### Added
+- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry whose file name contains `**` or is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`) is refused. Each exempt path prints `exempt commit (sealed.allow_paths )` on every push.
+- **`sealed.case_insensitive: true`**: `paths` and `transcripts` match regardless of case, so `**/heldout/**` also refuses `HELDOUT/` and `HeldOut/`. The value is the base branch's OR the pushed one, because it only tightens. `allow_paths` never fold case. Separator and spelling variants such as `hold-out/` still need their own globs.
+
+### Changed
+- `gstack-pregate sealed` prints `exempt=` after `waived=` on its `SEALED:` line (`policy=` stays last), and `--json` carries an `exempt` array. Both bins build their error report through one `sealedErrorReport`.
+- An unknown `sealed` key now says to upgrade gstack if the key comes from a newer one. A gstack older than this release refuses `allow_paths` and `case_insensitive` this way on every push and `gstack-pregate run`, so a repository raises `min_gstack_version` before landing either key.
+- `docs/pregate.md` § Sealed output explains glob anchoring: patterns match from the repository root, so `sealed/**` misses `tasks/x/sealed/a.json` and the docs say to write `**/sealed/**`. It also documents case handling, exceptions and the version skew.
+
+### Gate integrity
+- This is a gate edit: `allow_paths` lets the sealed guard pass files it refused before, but only from the reviewed base branch. No existing test, threshold or allowlist changed. New cases in `test/sealed-guard.test.ts` (10) and `test/pregate-guards.test.ts` cover an allowed exception, a non-matching one, an exception added on the pushed head, a text rule inside an exempt file, transcripts, the waiver order, the error report, case folding in both directions and the root-anchoring example.
+
+## [1.91.310.0] - 2026-10-29
+
+**The cookie workflow judge now asks for structured output, so a quote the model forgets to escape can no longer fail the gate.** Train 42 went red because both of its `setup-browser-cookies/SKILL.md workflow` samples came back as free text with a bare quote inside `reasoning` ("one --domain per run"), and neither parsed. The GSTA-91 comparison had measured that failure: 2 of 144 free-text samples failed to parse, against 0 of 72 with the schema.
+
+### Changed
+- **Cookie workflow judge transport:** `setup-browser-cookies/SKILL.md workflow` now sends `WORKFLOW_JUDGE_RESPONSE_SCHEMA`, released on GStack Minder's ruling: "GStack Minder, 2026-10-29: release the GSTA-91 hold; a07 is matched by old-b; free-text parse errors cost train 42". Its model, thresholds (4/3/4), sample count and case kind are unchanged. The ruling is recorded in `test/fixtures/judge-calibration/cookie-workflow/release.json`, and the held comparison stays recorded as held.
+
+### Tests
+- `test/cookie-workflow-judge-input.test.ts` runs the registered cookie callback through the real `callJudge` and pins its request to the schema. It replays train 42's two replies (msg_011CgVnRtVj5vyLvb56m6KJH, msg_011CgVne5GStZ6f9pRoCvgWE): their free-text bytes do not parse, and the same judgment returned as schema output scores and passes. `test/judge-calibration.test.ts` reads the release record. Both fail without the transport change.
+
+### Gate integrity
+- Gate edit: the cookie judge's response transport moves from free text to the JSON schema, on the ruling above. No threshold, sample, trial, deadline or case-kind change. The Haiku free-text failure pin in `test/llm-judge-frontier.test.ts` is untouched.
+
+## [1.91.309.0] - 2026-10-29
+
+**The judged review and Eng prose each give one rule where the paid judges kept finding two, and a judge that runs out of time now says what it was waiting on.** Every sample of two red panels on #3527's screens (review 40617200857, plan-eng-review 40634787316), attempt and rerun, named the same contradictions; a third red (ship, train 35 gate 40660626349) hit the 120 s judge deadline with nothing logged but "No completed model response".
+
+### Fixed
+- **`/review` Step 5.8**: an unresolved non-gate defect (an unanswered question) is recorded as `open`, so every finding has a record action. A repeat pass's reruns of superseded probes are revalidation, so they run after smoke expiry, in the same probe directory; earlier provisional QA sections link checkpoints and only the final pass runs the once-only `materialize`.
+- **`/plan-eng-review`**: the shared Voice now loads once. Eng no longer carries a near-copy whose only difference was its em-dash clause; the Scope gate's format precedence says required formats keep their em dashes.
+- **`/benchmark` Phase 6 example**: the hero image recommendation no longer suggests lazy-loading (that delays the largest paint) and names one remedy, as the rule under it asks.
+
+### Changed
+- **Judge cancellations leave evidence.** When a judge request is cancelled (the workflow judge deadline), `callJudge` prints one `llm-judge-aborted` line: elapsed time, wrapper attempts and 429 waits, HTTP requests and statuses (so the SDK's own retries show), connection, first and last stream event times, the open block type, thinking and text character counts, message and request ids, and the reason (at most 200 characters). Counts only, never content; an observer that throws cannot change a judge result. Deadlines, effort, samples and thresholds are unchanged.
+
+### Tests
+- `test/review-workflow-clarity.test.ts` and `test/eng-scope-entry-ap.test.ts` pin the repaired rules; both fail with the prose change stashed.
+- `test/llm-judge-stream.test.ts` and `test/llm-judge-abort.test.ts` replay a stream cancelled mid-thinking, a stream with no events, one cancelled after headers, an SDK-retried 429, a cancelled 429 wait, an event the observer cannot read and an overlong reason, with no-abort controls; each fails with the helper change stashed. `test/workflow-judge-cache.test.ts`'s SDK stream stand-ins gain the real stream's `on()`.
+- Focused paid judge runs: review 4/4/4 at 1bdc4ce and again at 3463250, plan-eng-review 3/5/4 at 1bdc4ce ($0.18).
+
+### Gate integrity
+- No threshold, sample, trial, deadline or case-kind change. `scripts/harness-version.json` records the `llm-judge.ts` change as non-behavioral (no series reset). Generated SKILL.md size pins for review and plan-eng-review were lowered to their new sizes on every host.
+
+## [1.91.308.0] - 2026-10-29
+
+**`gstack-restamp` no longer cuts a four-part package.json version to three parts in a repo whose convention is four.** On garrytan/gbrain#7878, `/ship` restamped gbrain's `package.json` to `"0.60.483"` beside `VERSION` `0.60.483.0`; gbrain requires the two to match and its CI version gate fails when they drift, so the stamp had to be reverted by hand. The version stage called `gstack-version-bump write`, which always stores the npm-valid three-part form in the manifest (decision 11, which is why gstack's own `package.json` reads `1.91.298`).
+
+### Fixed
+- **`lib/restamp.ts`**: the version stage keeps the full version in the manifest when the policy lists it in `stamp_paths` or `stamp_files` and the base's copy holds four parts, and the stage detail says so (`wrote 0.60.483.0 (package.json keeps four parts: …)`). The form is read at the base, like the policy, so a branch an earlier restamp already cut to three parts goes back to four. Every other repo, gstack included, still gets the npm-valid form, and a four-part manifest the policy does not list still migrates to three parts as decision 11 says.
+- **`bin/gstack-version-bump write`**: new `--manifest-form npm|full` (default `npm`); `full` writes the version as given to the manifest and its npm lockfiles. It has no effect when the version path is itself a JSON manifest.
+
+### Tests
+- `test/restamp-four-part-manifest.test.ts`: a gbrain-shaped fixture (four-part `VERSION` and `package.json`, the manifest in `stamp_files`, bun, no npm lockfile) restamped from an unstamped branch and from a branch already carrying its stamp (both failed before the fix with `"0.60.483"`), from a branch an older restamp cut to three parts, and with the manifest listed through `stamp_paths`. gstack's shape (three-part manifest and lockfile) and an unlisted four-part manifest keep their current results.
+- `test/gstack-version-bump.test.ts`: `--manifest-form full` keeps four parts in the manifest and lockfile; an unknown form exits 2 before anything is written.
+
+## [1.91.307.0] - 2026-10-29
+
+**/ship now reviews in proportion to what the diff can break: light, standard or full depth.** A diff with 50 or fewer runtime lines gets one batched specialist plus the native adversarial pass, a larger runtime diff gets the class's specialists scoped to their hunks, and gate code or any classifier error gets the full review as before. The receipt records the depth and the queue refuses a receipt below what the head needs. Before this, a 12-line fix paid for the same reviewer fan-out as a change to the merge gate. The backtest that would have let light depth drop the adversarial pass was inconclusive (9 light-eligible PRs, 5 with attributable findings, below the 15 the plan required), so light keeps it.
+
+### Added
+- **Review depth** (`lib/review-depth.ts`, `lib/review-collect.ts`, `gstack-review-plan`): `REVIEW_DEPTH:` (depth, runtime lines, the rules hash, reasons), `GATE_SCAN:` bound to HEAD, one `DEPTH_ERROR:` per input that fails closed to full (with the input, the cause and the repair), and per-reviewer `DIFF_CMD:` pathspecs. Runtime lines leave out docs, tests and outputs registered with a command in the base branch's `.gstack/generated.json`, read at the base commit. `--depth-floor standard|full` only raises the depth. A fixed or open blocker, or a CRITICAL, raises light to standard for the rest of the invocation.
+- **Receipt fields**: `gstack_version` and `review_depth` (`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`), computed at the checked-out head. `write --head` must be HEAD. `review_loop_exception` (`write --review-loop-exception`) records an owner-accepted stop after a fix-check edit with the fix-check's head and findings, and the PR body repeats its line word for word (Fixes #3559).
+- **Queue checks** (`gstack-ship-queue ready`, `train build`): the depth the head needs is recomputed from git and a lower receipt is `QUEUE_RECEIPT_DEPTH_RAISED`, with the receipt's depth, the required depth, reasons and inputs on a `REVIEW_DEPTH:` line. A malformed owner exception, a body that dropped its line, or a fix-check head that is not the bound head or its ancestor is `QUEUE_RECEIPT_EXCEPTION_INVALID`. The queue reads the same committed range as the writer and trusts a recorded gate scan only when it names the full bound commit. A receipt with neither new field predates depth receipts and is accepted as legacy.
+
+### Changed
+- **Carry and depth**: a carry recomputes the depth at the new head, rebinds the gate scan, and refuses when the new head needs more (a test change that skips a test is gate class). It also refuses a `--head` other than the checked-out HEAD, a receipt with `gstack_version` but no `review_depth`, and an owner exception that no longer stands, since the queue would refuse each of them. Tracked edits in the checkout make the write-time gate scan unavailable, which records `full`.
+- **`gstack-diff-scope --head `**: the scope signals for the committed range only, with no working-tree or untracked files; an unknown sha is `SCOPE_ERROR=no_head` and a missing merge base `SCOPE_ERROR=no_merge_base`.
+- **`.gstack/ship-policy.json`**: `min_gstack_version` rises from 1.91.146.0 to 1.91.299.0.
+
+### Tests
+- `test/ship-receipt-depth.test.ts`: write records the depth bound to HEAD and refuses another `--head`. The queue refuses light on a standard head, a current receipt with no depth and an unbound gate scan, and accepts a legacy receipt. A plain test change carries and rebinds the scan, while a skipped test refuses the carry. The review-loop exception is refused without a fix-check, with a foreign head or without its body line, and the queue refuses a body that dropped the line.
+- `test/review-depth.test.ts`, `test/review-collect.test.ts`, `test/review-plan-cli.test.ts`: the depth matrix, the fixed light set, fail-closed errors and the registry read at the base commit. A `package.json` version line that also carries other keys is not stamp-only.
+- `test/diff-scope.test.ts`: `--head` reads the committed range and ignores working-tree edits, and refuses an unknown sha.
+
+## [1.91.306.0] - 2026-10-29
+
+**A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it.
+
+### Changed
+- **`gstack-ship-receipt carry`**: an output of an entry with a `command` in origin/'s `.gstack/generated.json` counts as `generated`, not product, when the `--pregate` result for HEAD lists it under the `regen` check's new `proven` paths and that check ran the same base registry. Without that evidence, for a file that matches an output glob but that the command never writes, and for an entry with no command or one only the branch registers, the output stays product.
+- **`gstack-pregate` `regen` check**: a fresh entry with touched outputs runs its command a second time in the scratch worktree with those outputs deleted, and records the ones it recreates byte-identically as `proven`. The verdict still comes from the first run, so a generator that reads its own output passes as before and proves nothing.
+- **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`.
+- **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules.
+
+### Tests
+- `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a pregate whose regen check proved it and refuses without one or with a pregate from another registry, and a hand-written file under an output glob that the generator never writes stays product.
+- `test/gstack-ship-receipt.test.ts`: the written receipt's key order includes `head_tree` and `product_patch_id`.
+
+## [1.91.305.0] - 2026-10-29
+
+**You can now render gstack with whole skills, skill sections, model overlays or preamble pieces left out, as a byte-exact tree with a lock file, so an eval can measure which instructions a model actually needs.** Garry asked for "95% of the outcome but with 50% of the token use", and the free ceiling measurement on train gate run 40705984271 found skill text is about 21% of billed eval cost (35 of 103 tests), so trimming it per model is worth measuring. Nothing changes for anyone today. Every model rung and unknown models load `full`, and the committed SKILL.md files are byte-identical to the previous release. A smaller loadout ships only after GSTA-2 measures it.
+
+### Added
+- **`bin/gstack-modules`**: `list` prints every module id (`skill:`, `section:`, `overlay:`, `preamble:`) with its source and pin; `validate` checks `modules/loadouts.json`; `render --host (--loadout | --modules full,-,+) --out-dir ` renders one loadout with the same `--model`, `--install-root` and `--plan-mode` setup uses, and writes `loadout.lock.json` (spec sha256, excluded ids, per-file sha256 and bytes).
+- **`modules/loadouts.json`** (with a JSON schema): rungs `frontier`, `strong`, `mid`, `light`, the model-id table, pinned modules and named loadouts. Every rung and `unknown` map to `full`.
+- **Safety pins**: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade` can never be excluded, directly or by excluding the skill that holds them (`MODULE_PINNED`).
+- **Renders refuse instead of guessing**: an unknown loadout or module, a malformed spec, a rendered file that still points at an excluded skill or section (`MODULE_EXCLUSION_UNSAFE`), or an `--out-dir` that an earlier render did not write (`RENDER_DEST_NOT_OWNED`) each exit 2 with a fix line and a `docs/troubleshooting.md` anchor, and never fall back to `full`. A failed render leaves the earlier one in place.
+- **`docs/loadouts.md`**: terms, commands, how to build an eval arm, and how a loadout changes (only with a cited GSTA-2 run).
+
+### Changed
+- `gen:skill-docs` accepts `--loadout` and `--modules` with an `--out-dir` outside the checkout; a selection with exclusions aimed at the committed tree is refused. Excluded sections render a one-line `Not loaded in the loadout` stub, and `{{IF_SECTION:}}` guards remove skeleton text that belongs to a section (office-hours' second-opinion offer and visual-design phase, plan-design-review's review-sections self-check). `gstack/llms.txt` lists only the skills a render includes.
+
+### For contributors
+- New `test/module-loadouts.test.ts` covers the manifest, selection, pins, the render filter, the CLI and the byte-identical `full` render. `.gstack/pregate.json` allows the two new `modules/` files as strays. `test/gen-skill-docs-prune-stale.test.ts`'s copied fixture now links `scripts/modules.ts`; no assertion, threshold or budget changed.
+
## [1.91.304.0] - 2026-10-29
**Running the Capy installer for a second workflow on the same machine no longer takes away the first one's admission.** /ship's docs step runs `scripts/install.sh --for document-release`, and that run replaced the `--for ship` install verdict with its narrower one, so the rest of /ship refused with "the install verdict certified for=document-release, which does not cover codex that ship needs" until someone reran `--for ship` (#3561, seen on gstack-evals#13).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f3ba07335..d2c84855a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -205,6 +205,7 @@ the new defaults.
| Validate periodic cases on a branch in CI | `gh workflow run evals-periodic.yml --ref ` (periodic lane only) | CI runners + API spend | Pushed branch, `gh` with workflow rights |
| Validate periodic cases plus the weekly gate census on a branch | `gh workflow run evals-periodic.yml --ref -f include_gate_census=true` | CI runners + API spend | Pushed branch, `gh` with workflow rights |
| Run the opt-in ML, gitleaks and Swift checks | `gh workflow run platform-qualification.yml --ref ` | Free CI runners | Pushed branch, `gh` with workflow rights |
+| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:/ --out-dir ` (`--loadout full` for the control) | Free, about a second per host | Bun 1.4.2 |
| Look at past local eval runs | `bun run eval:list` / `eval:compare` / `eval:summary` | Free | Local eval history |
| See a case's pass rate across recent weekly runs | `bun run eval:pass-rates --case ` | Free | `gh` with repo read access |
| Measure one red case alone against the ship bar (MEETS 9/10) | `bun run scripts/ship-measure.ts measure --case --round baseline` ([bar](docs/TESTING_INTERNALS.md#ship-measure)) | API spend for 10 trials (12 for behavior); asks above $2/trial, stops at $25/case | Same as paid coverage |
diff --git a/TODOS.md b/TODOS.md
index d2be7ab44..7d7aa202d 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -2,6 +2,11 @@
## NEXT PRIORITY
+### P1: review-depth receipt follow-ups (filed 2026-10-28, from the #3567 /ship plan audit)
+
+- **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1.
+- **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1.
+
### P3: `/codex --effort` follow-ups (filed 2026-10-11, from the #3146 autoplan review)
- **Show the effort that ran.** Step 0.5 prints `CODEX_EFFORT: (source: /codex --effort)` once, but the result footers do not repeat the effort, so it scrolls away. Add an `Effort:` line to the review, challenge and consult result footers. Blocked on skeleton bytes in `codex/SKILL.md` (cap 60,200). **Effort:** S. **Priority:** P3.
@@ -783,15 +788,15 @@ Each item was deferred with a reason during the `/claude-api prompt-audit` clean
format errors, so it stays on today's request), the default workflow judge
(landed, but one provider refusal on corpus item f03 under the new prompt,
matched by one under the old prompt) and the cookie workflow judge (GSTA-91:
- held on one new false pass on a07, zero new-request format errors, so it stays
- on today's request; today's request itself false-passes a07 in `old-b` and on
+ held on one new false pass on a07, zero new-request format errors; the hold was
+ released on GStack Minder's 2026-10-29 ruling and it now sends the schema; today's request itself false-passes a07 in `old-b` and on
both valid `old-a` samples, and a08 in `old-a`, so the prose step targets those). Reuse `test/fixtures/judge-calibration`
and `scripts/judge-calibration.ts`. Priority P3. Effort M.
- **Uncalibrated workflow-judge configurations** — build a corpus and calibrate
schema transport for the frontier configuration (review/SKILL.md workflow); it
stays on today's request. The cookie configuration
(setup-browser-cookies/SKILL.md workflow) has a corpus and a held comparison-1
- (GSTA-91), so it also stays on today's request. After any transport change,
+ (GSTA-91); it moved to the schema on GStack Minder's 2026-10-29 release ruling. After any transport change,
re-run the review and cookie workflow judges. Priority P3. Effort M.
- **Claude model auto-detection for the skill overlay** — when no
`claude_overlay_model` is set, detect the Claude Code model (`ANTHROPIC_MODEL`,
diff --git a/VERSION b/VERSION
index d38dd36b0..f64064728 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.91.304.0
+1.91.313.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index 0196bed74..a043d5f41 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.91.304.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.91.313.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
diff --git a/benchmark/SKILL.md b/benchmark/SKILL.md
index 56c76e733..ee09bbba4 100644
--- a/benchmark/SKILL.md
+++ b/benchmark/SKILL.md
@@ -412,7 +412,7 @@ TOP 10 SLOWEST RESOURCES
RECOMMENDATIONS:
- vendor.chunk.js: code-split — 320KB on initial load
- analytics.js: load async/defer — blocks rendering for 250ms
-- hero-image.webp: add width/height (CLS), lazy-load
+- hero-image.webp: add width/height (CLS)
```
Third-party = any host but the page's origin. Each recommendation names a resource here or in a Phase 5 regression, the metric it moves and one remedy (split, defer, compress, lazy-load, cache).
diff --git a/benchmark/SKILL.md.tmpl b/benchmark/SKILL.md.tmpl
index 12a805eec..0a7448a29 100644
--- a/benchmark/SKILL.md.tmpl
+++ b/benchmark/SKILL.md.tmpl
@@ -201,7 +201,7 @@ TOP 10 SLOWEST RESOURCES
RECOMMENDATIONS:
- vendor.chunk.js: code-split — 320KB on initial load
- analytics.js: load async/defer — blocks rendering for 250ms
-- hero-image.webp: add width/height (CLS), lazy-load
+- hero-image.webp: add width/height (CLS)
```
Third-party = any host but the page's origin. Each recommendation names a resource here or in a Phase 5 regression, the metric it moves and one remedy (split, defer, compress, lazy-load, cache).
diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session
index 4296bdec8..3df25b12e 100755
--- a/bin/gstack-capy-session
+++ b/bin/gstack-capy-session
@@ -23,7 +23,13 @@
# applies: dirty, diverged, pinned, Bun hold, a live workflow lease) and on
# success re-execs the NEW checkout's copy of this script with the same
# arguments (GSTACK_CAPY_SELF_UPGRADED=1 stops a second attempt) before the
-# installer runs. Lines: `SELF_UPGRADE: done -> ` | `skipped ()`
+# installer runs. A live lease under 120 minutes old (the lease prune window)
+# held by this session's own process chain also skips it (#3571):
+# gstack-upgrade never counts the caller's own session, but an install run from
+# inside a workflow (/ship's docs step) must not change gstack under it. The age
+# bound matters on Capy, where the leased harness pid is the machine's
+# long-lived capy-server and a live lease alone would never end the deferral;
+# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides). Lines: `SELF_UPGRADE: done -> ` | `skipped ()`
# | `none (current)` | `disabled ()`.
# --check never moves the checkout; behind origin it prints
@@ -107,6 +113,18 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
elif ! behind; then echo "SELF_UPGRADE: none (current)"
elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN"
export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)"
+ elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
+ && OWN=$("$ROOT/bin/gstack-lease" live --root "$ROOT" 2>/dev/null | awk '{ a = $2; sub(/m$/, "", a); if (a + 0 < 120) print }') \
+ && [ -n "$OWN" ] \
+ && ! "$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ >/dev/null 2>&1; then
+ HOLDERS=""
+ while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done </dev/null)
UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$?
diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope
index 2fa10b41d..3ec81cf09 100755
--- a/bin/gstack-diff-scope
+++ b/bin/gstack-diff-scope
@@ -2,6 +2,9 @@
# gstack-diff-scope — categorize what changed in the diff against a base branch
# Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ...
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
+# Or: gstack-diff-scope --head → the committed range ... only
+# (no working tree, no untracked files): what a receipt writer and the
+# merge queue compute for one commit, from any checkout (GSTA-147).
#
# Output contract (#2526 — all-false must be distinguishable from "we could
# not look" and from "nothing matched"):
@@ -31,6 +34,8 @@ _default_base() {
echo "main"
}
BASE="${1:-$(_default_base)}"
+HEAD_REF=""
+if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
FRONTEND=false
BACKEND=false
@@ -79,6 +84,21 @@ _collect() {
[ -n "$f" ] && FILES_LIST+=("$f")
done
}
+if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then
+ if ! git rev-parse --verify -q "${HEAD_REF}^{commit}" >/dev/null 2>&1; then
+ _print_flags
+ echo "SCOPE_ERROR=no_head"
+ echo "# --head '${HEAD_REF}' is not a commit in this repository"
+ exit 2
+ fi
+ if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then
+ _print_flags
+ echo "SCOPE_ERROR=no_merge_base"
+ echo "# '${BASE}' and '${HEAD_REF}' share no merge base"
+ exit 2
+ fi
+ _collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null)
+else
# Committed diff vs base (merge-base form; two-dot fallback when no merge base).
_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true)
# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo
@@ -87,6 +107,7 @@ _collect < <(git diff -z HEAD --name-only 2>/dev/null || true)
# Untracked files: a brand-new component/migration/test is exactly what a
# reviewer should see, and /ship commits it in Step 15 regardless.
_collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true)
+fi
if [ "${#FILES_LIST[@]}" -eq 0 ]; then
_print_flags
diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh
index 221e5c993..dda793216 100644
--- a/bin/gstack-doctor-components.sh
+++ b/bin/gstack-doctor-components.sh
@@ -286,6 +286,8 @@ gstack_result_anchor() {
QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;;
QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;;
QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;;
+ QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;;
+ QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;;
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
QUEUE_SCREEN_NOT_RUN) printf '%s' 'queue-screen-not-run' ;;
QUEUE_TRAIN_STACK_RED) printf '%s' 'queue-train-stack-red' ;;
@@ -306,6 +308,8 @@ gstack_result_anchor() {
REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;;
RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;;
PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;;
+ REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;;
+ RECEIPT_CARRY_TREE_MISMATCH) printf '%s' 'receipt-carry-tree-mismatch' ;;
RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;;
DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;;
RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;;
diff --git a/bin/gstack-modules b/bin/gstack-modules
new file mode 100755
index 000000000..fa3f98d8d
--- /dev/null
+++ b/bin/gstack-modules
@@ -0,0 +1,8 @@
+#!/usr/bin/env -S bun --no-env-file --config=/dev/null
+import { modulesMain } from '../scripts/modules-cli.ts';
+
+void modulesMain(process.argv.slice(2)).then(result => {
+ if (result.stdout) process.stdout.write(result.stdout);
+ if (result.stderr) process.stderr.write(result.stderr);
+ process.exitCode = result.code;
+});
diff --git a/bin/gstack-pregate b/bin/gstack-pregate
index 8613150ed..446e4be7a 100755
--- a/bin/gstack-pregate
+++ b/bin/gstack-pregate
@@ -40,7 +40,7 @@ import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
import { cancelActive } from '../lib/pregate/supervise';
import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate';
import { GATE_AHEAD_REL } from '../lib/restamp';
-import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard';
+import { evaluateSealed, publishedCommits, rangeCommits, renderSealed, sealedErrorReport } from '../lib/sealed-guard';
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
import { fromGateAhead } from '../lib/tree-receipt';
@@ -244,13 +244,13 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
const report = 'error' in listed
- ? { status: 'error' as const, error: listed.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: 'unknown' as const, visibilitySource: '', target: remoteUrl ?? remote }
+ ? sealedErrorReport(listed.error, remoteUrl ?? remote)
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote });
const blocks = report.status === 'refused' || report.status === 'error';
if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; }
process.stderr.write(renderSealed(report, 'gstack-pregate sealed'));
const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`;
- process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} policy=${policy}\n`);
+ process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`);
return blocks ? EXIT.fail : EXIT.ok;
}
diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush
index 252dc4389..80ebddeed 100755
--- a/bin/gstack-redact-prepush
+++ b/bin/gstack-redact-prepush
@@ -43,7 +43,7 @@ import * as path from "path";
import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine";
import { mkdirpSync } from "../lib/fs-utils";
import { resolveStateRoot } from "../lib/state-root";
-import { evaluateSealed, publishedCommits, renderSealed, type SealedReport } from "../lib/sealed-guard";
+import { evaluateSealed, publishedCommits, renderSealed, sealedErrorReport, type SealedReport } from "../lib/sealed-guard";
const ZERO = /^0+$/;
let emptyTree: string | undefined;
@@ -608,7 +608,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se
const targets: Array<{ commits: string[]; pushedSha: string }> = [];
for (const ref of refs) {
const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName });
- if ("error" in published) return { status: "error", error: published.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: "unknown", visibilitySource: "", target: remoteUrl ?? "the push target" };
+ if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target");
targets.push({ commits: published.commits, pushedSha: ref.localSha });
}
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan
index a5424c25a..06f3e3976 100755
--- a/bin/gstack-review-plan
+++ b/bin/gstack-review-plan
@@ -4,20 +4,25 @@
*
* gstack-review-plan --base [ [--pass ] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below]
* [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged ] [--gated a,b] [--force a,b|all]
- * [--reviewed-numbers ] [--no-tiers] [--json]
+ * [--reviewed-numbers ] [--depth-floor standard|full] [--no-tiers] [--json]
* gstack-review-plan --fix-check-prompt --fix-list --hunks --tests [--callers ]
*
- * Reads the diff against the merge base with ][ (committed, working tree and
- * untracked), runs gstack-diff-scope and, unless --gate-tagged is given,
- * gstack-gate-diff; prints REVIEW_CLASS:, REVIEW_PLAN:, one DISPATCH: line per
- * reviewer and one SKIP: line per reviewer left out. Logic: lib/review-depth.ts.
+ * Reads the diff against the merge base with ][ (committed, tracked uncommitted
+ * edits and untracked files, through lib/review-collect.ts collectLive), runs
+ * gstack-diff-scope and, unless --gate-tagged is given, gstack-gate-diff; prints
+ * REVIEW_CLASS:, REVIEW_DEPTH: (depth, runtime lines, the rules hash, reasons and
+ * result codes), GATE_SCAN:, REVIEW_PLAN:, one DEPTH_ERROR: per input that failed
+ * the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per
+ * reviewer and one SKIP: per reviewer left out. A plan that fails closed to full
+ * exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts.
*/
-import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
+import { spawnSync } from 'node:child_process';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { runScriptSync } from '../lib/script-invocation';
-import { planReview, reviewPlanLine, scanNumbers, type History } from '../lib/review-depth';
+import { collectLive } from '../lib/review-collect';
+import { diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, type Depth, type History } from '../lib/review-depth';
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth)
@@ -25,19 +30,23 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
--base ][ the base branch (merge base with HEAD is the diff base); required
--pass full review passes already run in this invocation (default 0)
--last none|full|fix-check the step that just finished (default none: plan pass 1)
- --fixed-blocking the finished step fixed a blocking finding
+ --fixed-blocking the finished step fixed a blocking finding (raises light to standard)
--fixed-below the finished step fixed findings below the floor
--blocking-open a blocking finding is still open in fix scope
--touched-gate a fix touched a gate-class path
- --critical-found a specialist reported a CRITICAL finding (Red Team on code class)
+ --critical-found a specialist reported a CRITICAL finding (Red Team on code class; raises light to standard)
--adversarial-ran Step 11's native adversarial pass already ran (a repeat skips it)
--gate-tagged gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable)
--gated a,b specialists the hit-rate stats tag GATE_CANDIDATE
--force a,b|all specialists forced by prompt flags (--all-specialists is all)
--reviewed-numbers the numeric-claim fingerprint the outside voice already reviewed
+ --depth-floor standard|full raise the depth to at least this (never lowers it); /ship passes its prompt flag on every run
--no-tiers audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does)
- --json the plan as JSON
+ --json the plan as JSON (depth, runtime_lines, rules, errors with input and repair, gate_scan, per-dispatch exclude)
--fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files
+
+Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). A full plan
+from an error exits 0; exit 1 means no plan could be built (git could not list the diff).
${exitTableHelp()}`;
const args = process.argv.slice(2);
@@ -65,34 +74,17 @@ function main(): number {
if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; }
const pass = Number(flag('--pass') ?? '0');
if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; }
+ const floor = flag('--depth-floor') as Depth | undefined;
+ if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; }
const root = git(['rev-parse', '--show-toplevel'])?.trim();
if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; }
process.chdir(root);
- const ref = git(['rev-parse', '--verify', '-q', `origin/${base}`]) !== null ? `origin/${base}` : base;
- const mergeBase = git(['merge-base', ref, 'HEAD'])?.trim();
- if (!mergeBase) { process.stderr.write(`gstack-review-plan: no merge base with ${ref} (fetch the base first)\n`); return EXIT.fail; }
- const nameStatusRaw = git(['diff', '--name-status', '--no-renames', mergeBase]);
- const untrackedRaw = git(['ls-files', '--others', '--exclude-standard']);
- const numstatRaw = git(['diff', '--numstat', mergeBase]);
- if (nameStatusRaw === null || untrackedRaw === null || numstatRaw === null) { process.stderr.write('gstack-review-plan: git could not list the diff; no plan is safer than a plan for an empty diff\n'); return EXIT.fail; }
- const scratch = (p: string) => p.startsWith('.gstack/tmp/');
- const nameStatus = nameStatusRaw.split('\n').filter(Boolean).map(l => l.split('\t')).filter(c => !scratch(c[1] ?? ''));
- const untracked = untrackedRaw.split('\n').filter(Boolean).filter(p => !scratch(p));
- const paths = [...new Set([...nameStatus.map(c => c[1]!).filter(Boolean), ...untracked])].sort();
- const deleted = nameStatus.filter(c => c[0] === 'D').map(c => c[1]!);
- const stampOnly = paths.filter(p => p.endsWith('package.json') && !untracked.includes(p)).filter(p => {
- const changed = (git(['diff', '-U0', '--no-color', mergeBase, '--', p]) ?? '').split('\n').filter(l => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l));
- return changed.length > 0 && changed.every(l => /^[+-]\s*"version"\s*:/.test(l));
- });
- const diffLines = numstatRaw.split('\n').reduce((n, l) => { const [a, d] = l.split('\t'); return n + (Number(a) || 0) + (Number(d) || 0); }, 0)
- + untracked.reduce((n, p) => { try { return n + fs.readFileSync(p, 'utf8').split('\n').length; } catch { return n; } }, 0);
- const added: Record = {};
- let current = '';
- for (const line of (git(['diff', '-U0', '--no-color', mergeBase]) ?? '').split('\n')) {
- if (line.startsWith('+++ b/')) { current = line.slice(6); continue; }
- if (line.startsWith('+') && !line.startsWith('+++') && current) (added[current] ??= []).push(line.slice(1));
+ const collected = collectLive(root, base);
+ if (collected.errors.length) {
+ for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`);
+ return EXIT.fail;
}
- for (const p of untracked) { try { added[p] = fs.readFileSync(p, 'utf8').split('\n'); } catch { /* unreadable: no claims */ } }
+ const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
const scopeRun = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base], { encoding: 'utf8', timeout: 60_000 });
const scope: Record = {};
@@ -109,14 +101,25 @@ function main(): number {
const force = flag('--force') === 'all' ? 'all' as const : list('--force');
const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 });
const auditEffort = has('--no-tiers') || (tiersSetting.stdout ?? '').trim() === 'off' ? 'host' : 'light';
+ const { added, diffLines, paths, deleted, stampOnly, perPath, registry } = collected;
const plan = planReview({
- paths, deleted, stampOnly, root, scope, gateTagged, diffLines, numbers: scanNumbers(added), force, gated: list('--gated'),
+ ...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor,
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below'), blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') },
});
- if (has('--json')) { process.stdout.write(JSON.stringify({ ...plan, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); return EXIT.ok; }
- const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`];
- for (const d of plan.dispatches) out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}`);
+ const gateScan = { tagged: gateTagged, head };
+ if (has('--json')) {
+ const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
+ process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n');
+ return EXIT.ok;
+ }
+ const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`];
+ for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`);
+ for (const d of plan.dispatches) {
+ const key = d.name.split(' ')[0];
+ out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`);
+ if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`);
+ }
for (const s of plan.skipped) out.push(`SKIP: ${s}`);
out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above stay full)`);
process.stdout.write(out.join('\n') + '\n');
diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt
index d7d3940a0..6d50425b1 100755
--- a/bin/gstack-ship-receipt
+++ b/bin/gstack-ship-receipt
@@ -36,16 +36,18 @@ import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
-import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
+import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
import { approvalForReceipt } from '../lib/approval-citations';
import {
CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS,
- bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry,
+ bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, planTreeCarry,
receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr,
} from '../lib/ship-receipt';
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
+import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
+import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt)
@@ -61,10 +63,12 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block
census --repo --since [--cohort ]
the P7 adoption number over merged PRs (deduplicated by run and thread)
carry --pr [--repo ] | --body [--base ][] [--head ] [--pregate ] [--dry-run] [--out ]
- carry the receipt to HEAD when only a base merge, tests or release metadata changed
- since its head (product patch-id unchanged): rewrites the receipt with a \`carry\` record,
- posts the body (gh pr edit) and prints GSTACK_RESULT: skill=ship status=complete;
- RECEIPT_CARRY_PRODUCT_CHANGED / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3);
+ carry the receipt to HEAD when only a base merge, tests, release metadata or registered
+ outputs the --pregate regen check proved (recreated them) changed since its head (product patch-id
+ unchanged), or after a rebase onto a tree the receipt covers with the recorded product
+ patch-id: rewrites the receipt with a \`carry\` record, posts the body (gh pr edit) and
+ prints GSTACK_RESULT: skill=ship status=complete; RECEIPT_CARRY_PRODUCT_CHANGED /
+ RECEIPT_CARRY_TREE_MISMATCH / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3);
a receipt that records a pregate needs --pregate current for HEAD (RECEIPT_CARRY_PREGATE_STALE);
needs git >= 2.39 (RECEIPT_GIT_TOO_OLD)
docs-caps [--base ][] /ship Step 14.5: one DOCS_CAP: / ok|over line per docs.caps entry of the
@@ -80,6 +84,11 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate ({passes, blocking_found, deferred[, noted, fixed, scope]}: gstack-review-scope summary --out)
--docs ({status: updated|current|blocked, scope[, exception: {accepted_by, date, via, reason}]}: Step 14.5's outcome;
blocked without an exception is DOCS_AUDIT_BLOCKED, and with --body the body must carry the exception line verbatim)
+ --review-loop-exception ({accepted_by, date, via, reason, fix_check: {head, findings[, files]}}: the owner accepted a
+ stop after a fix-check edit; needs --review-loop with fix_checks >= 1, fix_check.head an ancestor of the head, and with --body the
+ "Review-loop exception: …" line verbatim; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and
+ review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an
+ unreadable input records full).
--approval-run (repeatable; resolves plan-sha citations) (W2: a class declared in origin/'s
.gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING)
--out --body . Defaults: head=HEAD, base=origin/HEAD's branch, version=the
@@ -134,10 +143,30 @@ function receiptVersion(repoRoot: string, head: string, base: string): string |
return 'none';
}
+/**
+ * What a later carry across rewritten history compares: the head's tree and its
+ * product patch-id against the base. Both are evidence, never a gate: a head
+ * outside a checkout (or a base that does not resolve) records neither, and
+ * such a receipt then carries only along its own history.
+ */
+function reviewedProduct(repoRoot: string, base: string, head: string, pregateFile: string | undefined): { head_tree?: string; product_patch_id?: string } {
+ const tree = git(['rev-parse', '--verify', '--quiet', `${head}^{tree}`], repoRoot);
+ if (!tree) return {};
+ const baseRef = `origin/${base.replace(/^origin\//, '')}`;
+ const id = productPatchAgainstBase(repoRoot, productRules(repoRoot, base, verifiedOutputs(repoRoot, base, pregateFile)), baseRef, head);
+ return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree };
+}
+
function cmdWrite(rest: string[], json: boolean): number {
const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd();
- const head = flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot);
+ const checkedOut = git(['rev-parse', 'HEAD'], repoRoot);
+ const head = flag(rest, '--head') ?? checkedOut;
if (!head) { process.stderr.write('gstack-ship-receipt write: --head is required outside a git repository\n'); return EXIT.usage; }
+ const headSha = git(['rev-parse', '--verify', '--quiet', `${head}^{commit}`], repoRoot);
+ if (checkedOut && headSha !== checkedOut) {
+ process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${checkedOut.slice(0, 12)}; the receipt binds the reviewed tree, so check out ${head.slice(0, 12)} and write there; nothing written`) + '\n');
+ return EXIT.fail;
+ }
const receiptFile = flag(rest, '--receipt');
const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null;
let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
@@ -165,6 +194,19 @@ function cmdWrite(rest: string[], json: boolean): number {
return EXIT.refused;
}
}
+ let loopException: ReturnType | undefined;
+ if (flag(rest, '--review-loop-exception') !== undefined) {
+ const refuse = (why: string) => { process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'REVIEW_LOOP_EXCEPTION_INVALID', `${why}; nothing written`) + '\n'); return EXIT.fail; };
+ try { loopException = parseReviewLoopException(jsonArg(flag(rest, '--review-loop-exception'))); } catch (e: any) { return refuse(`--review-loop-exception: ${e.message}`); }
+ if (!loopException.ok) return refuse(loopException.message);
+ let loop: unknown; try { loop = jsonArg(flag(rest, '--review-loop')); } catch {}
+ if (!parseReviewLoop(loop)?.fix_checks) return refuse('--review-loop-exception needs --review-loop with fix_checks >= 1: the stop it accepts follows a fix-check');
+ const fc = loopException.exception.fix_check.head;
+ if (spawnSync('git', ['merge-base', '--is-ancestor', fc, head], { cwd: repoRoot }).status !== 0) return refuse(`fix_check.head ${fc.slice(0, 12)} is not ${head.slice(0, 12)} or an ancestor of it`);
+ const line = reviewLoopExceptionLine(loopException.exception);
+ if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`);
+ }
+ const reviewDepth = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head);
const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') });
if (approval.kind === 'invalid') {
for (const line of approval.lines) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CLASSES_INVALID', line) + '\n');
@@ -194,7 +236,8 @@ function cmdWrite(rest: string[], json: boolean): number {
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined,
- review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}),
+ review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...reviewedProduct(repoRoot, base, head, pregateFile),
+ gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined,
};
} catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; }
if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; }
@@ -367,13 +410,73 @@ function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: tr
return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined } };
}
+/**
+ * Generated outputs the carry may set aside as metadata: the paths the pregate
+ * passed for HEAD proved (its regen check deleted each touched output and the
+ * entry's command recreated it byte-identically), when that check ran
+ * origin/'s `.gstack/generated.json` at the commit this carry reads and the
+ * path is an output of an entry with a `command` there. A proven name with glob
+ * syntax is product too: the carry uses these paths as pathspec globs.
+ */
+function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | undefined): string[] {
+ if (!pregateFile) return [];
+ const regen = readPregateFile(pregateFile).file?.checks.find(c => c.id === 'regen');
+ if (!regen || regen.status !== 'pass' || !regen.proven?.length) return [];
+ const load = loadRegistryFromBase(repoRoot, base);
+ if (isRegistryUnavailable(load) || load.errors.length || regen.inputs?.registry !== load.source.label) return [];
+ const owned = load.registry.entries.filter(e => e.command).flatMap(e => e.outputs);
+ return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p));
+}
+
+interface ProductRules { extra: { metadata: string[]; outputs: readonly string[] }; metadata: string[]; templates: readonly string[] }
+function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules {
+ const load = loadPolicyFromBase(repoRoot, base);
+ const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy;
+ const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs };
+ return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...outputs], templates: policy?.stamp_patterns ?? [] };
+}
+
+/**
+ * The product pathspec between two commits: every path but tests and release
+ * metadata, where a metadata-listed path whose change from `from` to `to` is
+ * not a stamp stays product (every glob that matches it leaves the exclusion).
+ */
+function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[] } | undefined {
+ const changedRaw = git(['diff', '--name-only', from, to], repoRoot);
+ if (changedRaw === undefined) return undefined;
+ const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
+ const versionAt = (ref: string): string | null => {
+ const file = blobAt(ref, 'VERSION');
+ if (file !== null) return file.trim() || null;
+ try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; }
+ };
+ const oldVersion = versionAt(from);
+ const newVersion = versionAt(to);
+ const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, rules.extra, matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p) ? { before: blobAt(from, p), after: blobAt(to, p), oldVersion, newVersion, templates: rules.templates } : undefined) }));
+ const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
+ const demoted = changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path);
+ const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p)));
+ return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] };
+}
+
+/** A commit's product patch-id against its merge base with the base ('' for no product diff), as write records it and a rewritten-history carry recomputes it. */
+function productPatchAgainstBase(repoRoot: string, rules: ProductRules, baseRef: string, commit: string): string | { error: string } {
+ const mergeBase = git(['merge-base', baseRef, commit], repoRoot);
+ if (!mergeBase) return { error: `cannot resolve the merge base of ${baseRef} and ${commit.slice(0, 12)}` };
+ const spec = productSpec(repoRoot, rules, mergeBase, commit);
+ if (!spec) return { error: `git diff ${mergeBase.slice(0, 12)} ${commit.slice(0, 12)} failed` };
+ return patchId(repoRoot, mergeBase, commit, spec.product);
+}
+
/**
* The receipt follows HEAD when the reviewed product is unchanged
* (docs/unattended.md#the-terminal-line; harness lane E row 3). Four Captains
* reran a full /ship on identical product code after a base merge or a
* test-only fix; the carry compares the product diff against the base at the
* receipt's head and at HEAD by patch-id, classifies every changed path, and
- * records the range-diff as evidence in the receipt it rewrites.
+ * records the range-diff as evidence in the receipt it rewrites. After a rebase
+ * or force-push it carries only onto a tree the receipt covers whose product
+ * patch-id against the current base is the recorded one (planTreeCarry).
*/
function cmdCarry(rest: string[], json: boolean): number {
const bodyFile = flag(rest, '--body');
@@ -384,7 +487,7 @@ function cmdCarry(rest: string[], json: boolean): number {
if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; }
const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase();
if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; }
- const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => {
+ const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`);
return EXIT.refused;
@@ -425,66 +528,63 @@ function cmdCarry(rest: string[], json: boolean): number {
}
const oldGit = gitVersionBelow(repoRoot, CARRY_MIN_GIT);
if (oldGit) return refused('RECEIPT_GIT_TOO_OLD', `git ${oldGit} cannot run patch-id --verbatim; the carry needs git ${CARRY_MIN_GIT} or later`);
- const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
- if (ancestor.status !== 0) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed); merge the base in next time, and run /ship now`);
// The carry binds HEAD alone: pending edits are not in the commits it compares, and a parent that stops at CARRY: carried would report them shipped.
const status = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
if (status.status !== 0) return refused('RECEIPT_CARRY_REFUSED', 'git status failed; the carry needs a readable working tree');
const dirty = status.stdout.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^.* -> /, ''));
if (dirty.length) return refused('RECEIPT_CARRY_REFUSED', `the working tree has uncommitted changes (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ` +${dirty.length - 5} more` : ''}); commit them and carry at the new head, or run /ship`);
+ const fromKnown = git(['cat-file', '-e', `${from}^{commit}`], repoRoot) !== undefined;
+ const ancestor = fromKnown && spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }).status === 0;
+ const fromTree = fromKnown ? git(['rev-parse', `${from}^{tree}`], repoRoot) : undefined;
+ const covered = [...(receipt.head_tree ? [receipt.head_tree] : fromTree ? [fromTree] : []), ...(receipt.carry?.trees ?? [])];
+ if (!ancestor && !covered.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed), and the receipt records no tree to compare; merge the base in next time, and run /ship now`);
const base = flag(rest, '--base') ?? receipt.base ?? resolveBaseBranch(repoRoot);
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
- const fromBase = git(['merge-base', baseRef, from], repoRoot);
const headBase = git(['merge-base', baseRef, head], repoRoot);
- if (!fromBase || !headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
- const load = loadPolicyFromBase(repoRoot, base);
- const policy = isUnavailable(load) ? null : load.policy;
- const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])] };
- const changedRaw = git(['diff', '--name-only', from, head], repoRoot);
- if (changedRaw === undefined) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
- // Every metadata-listed path is read at both heads: only a stamp keeps it out of the product patch.
- const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
- const versionAt = (ref: string): string | null => {
- const file = blobAt(ref, 'VERSION');
- if (file !== null) return file.trim() || null;
- try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; }
- };
- const oldVersion = versionAt(from);
- const newVersion = versionAt(head);
- const allMetadataGlobs = [...CARRY_METADATA_GLOBS, ...extra.metadata];
- const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, extra, matchesAny(allMetadataGlobs, p) ? { before: blobAt(from, p), after: blobAt(head, p), oldVersion, newVersion, templates: policy?.stamp_patterns ?? [] } : undefined) }));
- // Pathspecs by class: tests and metadata are excluded from the product patch; the test patch is the tests alone.
- // A metadata-listed path whose change was not a stamp stays in the product patch: every glob that matches it is dropped from the exclusion.
- const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
- const demoted = changed.filter(c => c.class === 'product' && matchesAny(allMetadataGlobs, c.path)).map(c => c.path);
- const metadataGlobs = allMetadataGlobs.filter(g => !demoted.some(p => matchesAny([g], p)));
- const productSpec = ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)];
- const testSpec = [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)];
- const productFrom = patchId(repoRoot, fromBase, from, productSpec);
- const productHead = patchId(repoRoot, headBase, head, productSpec);
- const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : '');
- const testFrom = asId(patchId(repoRoot, fromBase, from, testSpec));
- const testHead = asId(patchId(repoRoot, headBase, head, testSpec));
- for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`);
- // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there,
- // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every
- // path the base changed, none of which can differ between the two base diffs.
- const changedProduct: string[] = [];
- if (productFrom !== productHead) {
- const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean));
- for (const p of [...touched].sort()) {
- if (changedProduct.length >= CARRY_NAMED_PATHS) break;
- const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, extra);
- if (cls !== 'product') continue;
- if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p);
+ if (!headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
+ const rules = productRules(repoRoot, base, verifiedOutputs(repoRoot, base, flag(rest, '--pregate')));
+ const headTree = git(['rev-parse', `${head}^{tree}`], repoRoot);
+ if (!headTree) return refused('RECEIPT_CARRY_REFUSED', `git could not read the tree of HEAD ${head.slice(0, 12)}`);
+ const headPatch = productPatchAgainstBase(repoRoot, rules, baseRef, head);
+ if (typeof headPatch !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute HEAD's product patch-id: ${headPatch.error}`);
+ let plan: ReturnType;
+ if (!ancestor) {
+ // Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded.
+ const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) : fromKnown ? productPatchAgainstBase(repoRoot, rules, baseRef, from) : { error: 'the receipt records no product patch-id and its head is not in this clone' };
+ if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the reviewed product patch-id is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`);
+ plan = planTreeCarry({ from, head, headTree, coveredTrees: covered, productPatchId: { recorded, head: headPatch } });
+ } else {
+ const fromBase = git(['merge-base', baseRef, from], repoRoot);
+ if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
+ const spec = productSpec(repoRoot, rules, from, head);
+ if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
+ const { changed } = spec;
+ const productFrom = patchId(repoRoot, fromBase, from, spec.product);
+ const productHead = patchId(repoRoot, headBase, head, spec.product);
+ const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : '');
+ const testFrom = asId(patchId(repoRoot, fromBase, from, spec.tests));
+ const testHead = asId(patchId(repoRoot, headBase, head, spec.tests));
+ for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`);
+ // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there,
+ // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every
+ // path the base changed, none of which can differ between the two base diffs.
+ const changedProduct: string[] = [];
+ if (productFrom !== productHead) {
+ const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean));
+ for (const p of [...touched].sort()) {
+ if (changedProduct.length >= CARRY_NAMED_PATHS) break;
+ const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
+ if (cls !== 'product') continue;
+ if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p);
+ }
+ if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
}
- if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
+ const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim();
+ // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
+ const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
+ const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
+ plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff });
}
- const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim();
- // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
- const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
- const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
- const plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff });
if (!plan.ok) return refused(plan.code, plan.message);
// Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD,
// so a carried receipt never claims the old tree's checks (docs/ship-policy.md#receipt-carry).
@@ -499,7 +599,19 @@ function cmdCarry(rest: string[], json: boolean): number {
} else if (receipt.pregate && !rest.includes('--dry-run')) {
return refused('RECEIPT_CARRY_PREGATE_STALE', `the receipt records a pregate for ${receipt.head.slice(0, 12)}; run gstack-pregate at HEAD ${head.slice(0, 12)} and pass --pregate `);
}
- const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry);
+ let depthField: Partial = {};
+ const isAncestor = (commit: string) => !ancestor || spawnSync('git', ['merge-base', '--is-ancestor', commit, head], { cwd: repoRoot, timeout: 30_000 }).status === 0;
+ const problems = exceptionProblems(body, receipt, isAncestor);
+ if (problems.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's owner exception does not stand: ${problems.join('; ')}; run /ship on ${head.slice(0, 12)}`);
+ if (receipt.review_depth || receipt.gstack_version) {
+ if (!receipt.review_depth) return refused('RECEIPT_CARRY_REFUSED', `the receipt records gstack_version but no review_depth, so the queue would refuse it; run /ship on ${head.slice(0, 12)}`);
+ const checkedOut = git(['rev-parse', 'HEAD'], repoRoot)?.toLowerCase();
+ if (checkedOut !== head) return refused('RECEIPT_CARRY_REFUSED', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${(checkedOut ?? 'none').slice(0, 12)}; the gate scan reads the checkout, so check out ${head.slice(0, 12)} and carry there`);
+ const now = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head);
+ if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`);
+ depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) } };
+ }
+ const carried = carryReceipt({ ...receipt, ...pregateField, ...depthField }, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch });
const errors = validateReceipt(carried);
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
const newBody = placeReceiptFirst(body, carried);
diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump
index ba6a51254..c9b0d71f1 100755
--- a/bin/gstack-version-bump
+++ b/bin/gstack-version-bump
@@ -25,8 +25,14 @@
// the caller must handle), exit 2 on bad args / unresolvable base /
// broken version source.
//
-// write --version [--version-path ]] [--regen-digest] [--changelog
]
+// write --version [--version-path ] [--regen-digest] [--changelog
] [--manifest-form npm|full]
// Validates the 4-digit pattern, writes VERSION + package.json.version.
+// --manifest-form full: the manifest and its npm lockfiles carry the
+// version as written instead of the npm-valid translation (default
+// npm). For repos whose declared convention is a four-part manifest
+// (gbrain); gstack-restamp passes it only when the policy lists the
+// manifest as a stamp file and the base's copy holds four parts. No
+// effect when the version path is itself a JSON manifest.
// --changelog
(opt-in): re-head the branch's `## [Unreleased]`
// entry in
as `## [] - ` at the top, with
// lib/changelog-check's reheadChangelog — the same function
@@ -455,6 +461,8 @@ function cmdWrite(args: string[], cwd: string): void {
if (!VERSION_RE.test(version!)) {
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH[.MICRO]. Aborting.`, 2);
}
+ const manifestForm = argVal(args, "--manifest-form") ?? "npm";
+ if (manifestForm !== "npm" && manifestForm !== "full") fail("write: --manifest-form needs npm|full", 2);
const src = versionSource(cwd, argVal(args, "--version-path"));
if (src.outcome === "broken") failBroken(src, "write");
if (src.outcome !== "valid") {
@@ -528,7 +536,8 @@ function cmdWrite(args: string[], cwd: string): void {
// 3-digit translation — npm rejects a fourth component, so mirroring the
// raw 4-digit form breaks `npm ci` in any repo npm actually manages.
// VERSION keeps the full 4-digit form; it stays the source of truth.
- const manifestV = npmVersion(version!);
+ // --manifest-form full keeps a repo's declared four-part manifest as is.
+ const manifestV = manifestForm === "full" ? version! : npmVersion(version!);
if (hasPkg) {
let pkgWritten = false;
try {
diff --git a/captain/templates/CREW.md b/captain/templates/CREW.md
index 64a01fc96..e6bdf2806 100644
--- a/captain/templates/CREW.md
+++ b/captain/templates/CREW.md
@@ -90,8 +90,9 @@ Homes are paths in the gstack checkout the lane checked at session start.
14. **Don't merge `BASE` into a READY PR.** Only on the controller's word after a real-code
train conflict (merge, never rebase, free suite, READY again) or as part of the stamp
- commit. Home: `docs/captain.md` (Merge trains), `docs/ship-policy.md` (Run a merge
- train).
+ commit. A merge whose product diff is unchanged carries the /ship receipt; a rebase
+ onto a new tree costs a full /ship. Home: `docs/captain.md` (Merge trains),
+ `docs/ship-policy.md` (Run a merge train, Carrying a receipt).
15. **CHANGELOG entry under `## [Unreleased]` before READY;** version files stay at
`BASE`'s version; the stamp touches only `STAMP_FILES`. Home: `docs/ship-policy.md`
diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md
index 4b0e01ca4..6c99766c2 100644
--- a/docs/PROJECT_STRUCTURE.md
+++ b/docs/PROJECT_STRUCTURE.md
@@ -92,6 +92,7 @@ gstack/
│ ├── test/ # Integration tests
│ └── dist/ # Compiled binary
├── agents-digest/ # Committed 2KB instruction-tier rules digest (gstack-AGENTS.md) for rules-reading hosts
+├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, pinned modules and named loadouts; read by scripts/modules.ts, rendered by bin/gstack-modules (docs/loadouts.md)
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; design-catalog.ts = the typed design anti-pattern catalog every design skill renders from; design-lint/ = the static and render tiers behind bin/gstack-design-lint, ported from hallmark's sloplint; design-detect-contract.ts = detector sentinel vocabulary; design-md.ts = open DESIGN.md reader/writer; dom-dump-script.ts + generated dom-dump.js = rendered-DOM dump for the detector; review-evidence.ts = review-start receipt binding, computed freshness and shared-code snapshot eligibility; frontend-scope.ts; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/)
│ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads
diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md
index 31fefa201..13cb03aa8 100644
--- a/docs/TESTING_INTERNALS.md
+++ b/docs/TESTING_INTERNALS.md
@@ -783,6 +783,14 @@ once, plus cleanup. Each still has 120 seconds of model work. Its 17 workflow
judges own their deadline and abort signal, with five seconds for terminal
recording inside a ten-second Bun grace; the other 11 retain their existing
120-second Bun timeout. Late responses cannot create records or cache passes.
+When a judge's abort signal cancels its request, `callJudge`
+(`test/helpers/llm-judge.ts`) prints one `llm-judge-aborted` JSON line to
+stderr: elapsed time, wrapper `attempts` and `retryWaitsMs` (429 waits),
+`httpRequests` and `httpStatuses` (so SDK retries show), `connectedMs`,
+`firstEventMs` and `lastEventMs`, the open `block` type, `thinkingChars` and
+`textChars`, `messageId`, `requestId` and the `reason` (at most 200
+characters). It holds counts and ids only, never content, and changes no
+request, score or verdict.
The ship documentation file reserves 4,920 seconds for four 600-second cases and
eight 300-second fault cases, run once, plus cleanup; in CI each case runs as its
diff --git a/docs/captain.md b/docs/captain.md
index 1bbc2a511..e955acfb7 100644
--- a/docs/captain.md
+++ b/docs/captain.md
@@ -234,6 +234,14 @@ a push included. The sequence that works unattended on Capy:
10. After the slot call, the stamp commit keeps the pin's receipt; re-READY with the same
receipt, no second /ship.
+When the base moves under a reviewed branch, merge it in; never rebase. A merge that
+leaves the product diff unchanged carries the receipt with `gstack-ship-receipt carry`
+(pregate at HEAD, no review rerun), and a merge whose resolution changed product code
+needs a fresh /ship. A rebase carries only onto a tree the receipt already covers with
+the same product diff; any other rebase is a full /ship
+([ship-policy.md](ship-policy.md#receipt-carry)). A controller never asks a lane to
+rebase a reviewed branch.
+
## Merge trains
gstack's queue tool drives trains when the repository's `.gstack/ship-policy.json` carries
diff --git a/docs/capy-install.md b/docs/capy-install.md
index f98f87d6e..7de24710e 100644
--- a/docs/capy-install.md
+++ b/docs/capy-install.md
@@ -155,7 +155,16 @@ and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after
a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start
self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy)
while a lease held by a live process outside its own chain exists, naming each
-holder as `pid (m )`. The team-mode SessionStart hook
+holder as `pid (m )`. The install-mode self-upgrade also
+defers while its own chain holds a live lease under 120 minutes old. An install
+run from inside a workflow (/ship's docs step running `install.sh --for
+document-release`, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this
+session holds a workflow lease on this installation: ... (UPGRADE_BUSY))`,
+leaves the checkout where the run started and stays ok with `behind=`. Each
+`gstack-skill-start` renews the lease. On Capy the leased pid is the machine's
+long-lived `capy-server`, so the deferral ends when the lease turns 120
+minutes old, not when the process exits; `bin/gstack-upgrade` run by hand
+moves the checkout sooner. The team-mode SessionStart hook
(`bin/gstack-session-update`) honors the same leases: it logs
`SKIP leased pid=/m` to `analytics/session-update.log` and tries again at
the next session start, without writing its hourly throttle stamp. A lease whose
diff --git a/docs/loadouts.md b/docs/loadouts.md
new file mode 100644
index 000000000..f065842d5
--- /dev/null
+++ b/docs/loadouts.md
@@ -0,0 +1,40 @@
+# Module loadouts
+
+gstack can render a smaller or larger set of its instructions for a given model. Each toggleable piece is a **module** with an id. A **loadout** is a named set of modules, and `modules/loadouts.json` maps each model **rung** to a loadout. Today every rung loads `full`, which is the same render gstack has always shipped. A loadout changes only when the shared skill eval (GSTA-2) measures a smaller one that matches `full` within its preregistered margin.
+
+Install-time model detection, which picks a loadout for the model you run, ships with the first loadout that is not `full`. Until then, loadouts are an eval tool: `bin/gstack-modules render` produces any loadout as a byte-exact tree with a lock file.
+
+## Terms
+
+| Term | Meaning |
+|---|---|
+| Module | One of four kinds. `skill:` is a whole skill. `section:/` is one entry of `/sections/manifest.json`. `overlay:` is one `model-overlays/.md`; excluding a base overlay also removes the base's text from every overlay that inherits it; the inheriting overlay's own text stays. `preamble:` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). |
+| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. |
+| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. |
+| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. |
+
+## Commands
+
+```bash
+bin/gstack-modules list [--json] [--pinned] # every module id, kind, source, pin, source bytes and sha256
+bin/gstack-modules validate # check modules/loadouts.json against the catalog
+bin/gstack-modules render --host claude --loadout full --out-dir ~/arms/control
+bin/gstack-modules render --host claude \
+ --modules 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design' \
+ --out-dir ~/arms/oh-lean
+```
+
+`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`.
+
+`bun run gen:skill-docs --loadout --out-dir ` and `--modules --out-dir ` apply the same filter to a generation run without the staging, dangling-reference check or lock file; a selection with exclusions and no `--out-dir` is refused with `MODULE_SPEC_INVALID`, because the committed tree is always the `full` render. A non-`full` render adds `` to each SKILL.md header.
+
+## Building an eval arm
+
+1. Render the control with `--loadout full` and each arm with `--modules full,-,…`. Every refusal names its code and fix ([troubleshooting](troubleshooting.md#module-loadouts-gstack-modules)).
+2. Preregister the lock files' `spec_sha256` and per-file sha256 values with the eval.
+3. Excluding a section removes its STOP pointer, its Section-index row and its file. The step becomes `> Not loaded in the loadout: skip . Do not offer it, and treat it as not run.` Skeleton text that belongs to the section, such as office-hours' Phase 3.5 offer, sits inside `{{IF_SECTION:}}…{{/IF_SECTION}}` in the template and goes with it.
+4. The autoplan runner (`gstack-autoplan`) reads review methodology from the source tree, not from a rendered loadout, so autoplan-runner arms always measure `full` methodology.
+
+## Changing a loadout
+
+Add or edit an entry under `loadouts` and cite the GSTA-2 run (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`.
diff --git a/docs/pregate.md b/docs/pregate.md
index a42b37831..1e8698852 100644
--- a/docs/pregate.md
+++ b/docs/pregate.md
@@ -146,7 +146,12 @@ Two stages with separate clocks, both printed:
~2.5 s for `regen` and under 200 ms for each of the others):
- `regen`: every registry entry whose inputs or outputs are touched is fresh
- (`gstack-regen check` in a scratch worktree).
+ (`gstack-regen check` in a scratch worktree). A fresh entry with touched
+ outputs then runs once more with those outputs deleted; the ones the command
+ recreates byte-identically are the check's `proven` paths, which
+ `gstack-ship-receipt carry` reads. The verdict comes from the first run: an
+ output the second run does not recreate, or a second run that fails, is
+ 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.
@@ -763,11 +768,13 @@ with a timing flake: never raise the bound.
```json
{ "sealed": {
- "paths": ["heldout/**"],
+ "paths": ["heldout/**", "**/sealed/**"],
"transcripts": ["runs/**/transcript*.jsonl"],
+ "allow_paths": ["**/sealed/*/MANIFEST.json", "**/protected/MANIFEST.json"],
"answer_markers": ["SEALED ANSWER:"],
"seed_patterns": ["shuffle_seed=\\d+"],
- "local_paths": true } }
+ "local_paths": true,
+ "case_insensitive": true } }
```
A repo with a `sealed` key gets a configured local check against accidental
@@ -787,8 +794,38 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
(`/home//`, `/Users//`, `C:\Users\\`) check each commit's added
lines. Findings print rule, `file[:line]` and commit, never the text.
+- **Globs.** `paths`, `transcripts` and `allow_paths` match the whole
+ repo-relative path from the repository root. `*` and `?` stay inside one
+ path segment; `**/` spans zero or more directories; a `**` not followed by
+ `/` matches across directories (`heldout/**` covers every depth under
+ `heldout/`); `[` and `{` are literal. So `sealed/**` misses `tasks/x/sealed/a.json`:
+ to guard a `sealed/` directory wherever it sits, write `**/sealed/**`.
+- **Case.** Matching is case-sensitive. `case_insensitive: true` folds case
+ for `paths` and `transcripts` only, so `**/heldout/**` also catches
+ `HELDOUT/` and `HeldOut/`. Separator and spelling variants (`hold-out/`,
+ `held_out/`, `heldout-v2/`) are not folded: list each as its own glob.
+ The effective value is the base branch's OR the pushed one (it only tightens).
+- **Exceptions.** `allow_paths` exempts publishable files that sit next to
+ sealed data (a `MANIFEST.json` of hashes and counts) from `sealed.path` and
+ `sealed.transcript`, applied after the deny globs and before waivers. They
+ are read from the destination base branch only: an exception a push adds
+ takes effect once it lands on the base branch, so a branch can never widen
+ its own. They never fold case. An entry whose file name contains `**`, or
+ is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`,
+ `sealed/**.json`), is refused; a partial wildcard such as `MANIFEST*.json`
+ is accepted and matches every file it spells, so name the file. An exempt file is checked by the
+ text rules only, so configure `answer_markers` when you exempt files next to
+ sealed data. Each exempt path is listed on every push (`exempt
+ commit (sealed.allow_paths )`) and counted as
+ `exempt=` on the `SEALED:` line.
+- **Version skew.** A gstack older than the release whose CHANGELOG adds
+ `sealed.allow_paths` refuses `allow_paths` and `case_insensitive` as
+ unknown keys, so every push and `gstack-pregate run` in the repository
+ fail on that machine. Raise `min_gstack_version` in
+ `.gstack/ship-policy.json` to that release before landing either key.
- **Policy.** The union of the destination base branch's `sealed` and the
- pushed commit's: a push can tighten it, never relax it. A malformed or
+ pushed commit's: a push can tighten it, never relax it (`allow_paths` come
+ from the base branch alone, and a pushed deny glob cannot override one). A malformed or
unreadable policy refuses. Waivers come only from the base branch's
`.gstack/sealed-allow.tsv` (`rulepathshareason`) and are
listed on every push.
diff --git a/docs/review-policy.md b/docs/review-policy.md
index 149abc89b..bafc25869 100644
--- a/docs/review-policy.md
+++ b/docs/review-policy.md
@@ -128,26 +128,75 @@ applies no edits is its exit too.
## Review depth by diff class
-`gstack-review-plan --base ` classifies the diff and prints the reviewers
-the next step dispatches (`REVIEW_CLASS:`, `REVIEW_PLAN:`, one `DISPATCH:` per
-reviewer with its effort, one `SKIP:` per reviewer left out, `--json`). A mixed
-diff takes its most restrictive class; a `gstack-diff-scope` error fails closed
-to code, and code with the gate scan unavailable fails closed to gate.
+`gstack-review-plan --base ` classifies the diff, picks a **depth** and prints
+the reviewers the next step dispatches: `REVIEW_CLASS:`, `REVIEW_DEPTH:
+runtime_lines= rules= reasons=<…> codes=<…>`, `GATE_SCAN: tagged=|unavailable
+head=`, one `DEPTH_ERROR:` per input that failed closed, one `DISPATCH:` per reviewer
+with its effort and a shell-quoted `DIFF_CMD:` (plus `DIFF_EXCLUDE: none` when it
+reads everything), one `SKIP:` per reviewer left out, and `--json` with the same fields.
+A plan that fails closed to full exits 0; a diff git cannot list exits 1.
-| Class | What it is | Pass 1 dispatches |
+**Classes** are unchanged. A mixed diff takes its most restrictive class: **docs**
+(markdown no agent or gate reads), **tests** (test files and fixtures), **code**
+(everything else, including every `SKILL.md` and template, markdown in a skill
+directory, `*/sections/**`, `scripts/resolvers/**`, rule docs, checklists and bins) and
+**gate** (code plus auth or migration scope, a hunk the gate scan tags, a deleted code
+or test path, CI workflows, credential, state-root and the paths below).
+
+**Runtime lines** are the changed lines of code-class paths, minus outputs the base
+commit's `.gstack/generated.json` registers. Docs, tests and release metadata are never
+runtime; a `.tmpl` edit plus its regenerated `SKILL.md` counts the template once.
+
+| Depth | When | Pass 1 dispatches |
|---|---|---|
-| **docs** | markdown no agent or gate reads: README, CHANGELOG, TODOS, how-to and explanation docs | the parent's core checklist only |
-| **tests** | test files and fixtures only | core plus the testing specialist |
-| **code** | everything else, including every `SKILL.md` and template, any markdown in a skill directory, `*/sections/**`, `scripts/resolvers/**`, `docs/*-policy.md`, `docs/captain.md`, `docs/unattended.md`, `docs/pregate.md`, checklists, specialists and bins | specialists by scope (as before), the native adversarial pass, the outside voice; the Red Team past 200 lines or after a CRITICAL |
-| **gate** | code plus auth or migration scope, a hunk the gate scan tags (an RH rule: a relaxed threshold, a skipped test), a deleted code file, CI workflows, credential or state-root paths | code's set, plus the Red Team always, at full effort, and no hit-rate gating |
+| **light**, runtime 0 | docs and tests only, or only registered outputs | the core checklist only; the outside voice when the diff publishes numbers |
+| **light**, runtime 1–50 | `SMALL_DIFF_LINES` or fewer runtime lines | core, one **batched** specialist (testing, maintainability and security checklists, the specialists scope selects and the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers |
+| **standard** | more than 50 runtime lines | the code set with every threshold (specialists at 50, simplification at 100, Red Team at 200) read on runtime lines; specialists leave docs and registered outputs out of their diff, and every specialist but Testing leaves tests out too; adversarial and outside voice read everything |
+| **full** | gate class, or any classifier error | the gate set, unchanged: every reviewer on the whole diff, the Red Team always, no hit-rate gating |
-**Numbers keep the outside voice.** On a docs or tests diff the outside voice is
-skipped unless the diff publishes numeric claims: `gstack-review-plan` scans the
-added lines of every path for percentages, money, multipliers, durations, token
-or credit counts and score, rate or speed claims (`NUMBERS: yes`), and a
-claim always dispatches it. The claims' fingerprint (`fingerprint=`) is passed
-back as `--reviewed-numbers`; when a later fix changes a claim, only the
-outside voice reruns, on the numbers.
+A fixed blocking finding, a CRITICAL or an open blocker raises a light diff to
+standard for the next step (`step=standard`): its specialists read at least 50 lines
+and the Red Team runs, so a raise never dispatches less than light did. A fix-check
+is the same at every depth.
+
+**Fail closed.** Each of these makes the depth full and is listed in `errors[]` with
+the input it could not read, a sanitized cause and the repair: a `gstack-diff-scope`
+error, a gate scan that is unavailable, a base registry that cannot be read or is
+invalid (`REGEN_REGISTRY_INVALID` or `REGEN_CONTAINMENT` in `codes=`), a numstat git
+could not produce, and a binary code-class path (it is never counted as 0 lines).
+
+**The registry rule.** Only the base commit's registry counts (the merge base's
+`origin/` commit for live /ship and the current base ref's commit for the queue: `origin/` in `ready`, `/` in `train build`), and only
+entries with a `command`, which pregate's regen check proves for that tree. An entry
+added on the branch never excludes anything; `command: null` entries, vendored code
+and lockfiles stay runtime. To adopt it, commit an entry like this on the base branch:
+
+```json
+{
+ "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1",
+ "entries": [
+ { "id": "skill-docs", "command": "bun run gen:skill-docs --host all", "outputs": ["*/SKILL.md", "*/sections/*.md"], "inputs": ["*/SKILL.md.tmpl", "*/sections/*.tmpl"] }
+ ]
+}
+```
+
+**Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`,
+`lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`,
+`lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`,
+`ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or
+`scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets
+the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns,
+not file bytes, so a comment edit keeps it.
+
+**`--depth-floor standard|full`** (a /ship prompt flag, passed to every plan run)
+raises the depth and never lowers it.
+
+**Numbers keep the outside voice.** At light depth the outside voice runs only when
+the diff publishes numeric claims: `gstack-review-plan` scans the added lines of
+prose and data files for percentages, money, multipliers, durations, token or credit
+counts and score, rate or speed claims (`NUMBERS: yes`). The claims' fingerprint
+(`fingerprint=`) is passed back as `--reviewed-numbers`; when a later fix changes a
+claim, only the outside voice reruns, on the numbers. It always reads the whole diff.
**The adversarial and outside voices run once per invocation** (Step 11, on the
final tree), as the last full-pass plan lists them; after they ran, decision 2
@@ -166,6 +215,15 @@ default. `gstack-config set review_effort_tiers off` (or `--no-tiers`) runs
the audits and watchers at the host default too
([model-policy.md](model-policy.md#reasoning-effort)).
+### Behavior changes
+
+- A tests-only diff (runtime 0) no longer dispatches the Testing specialist: the
+ core checklist and the gate scan review it.
+- Light no longer runs a separate Red Team: the batched specialist carries its
+ prompt. At runtime 0 light runs no adversarial pass either.
+- Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy
+ diff with few runtime lines dispatches fewer specialists.
+
## What the receipt records
`gstack-review-scope summary --since ` reads this invocation's
@@ -190,6 +248,13 @@ specialist stats they record). The summary line appends `fix_checks=`,
`dispatches=` and `est_input_tokens=` only when they were recorded, so an old
loop prints the old line.
+Beside the counters, `write` records `review_depth` (the depth, runtime lines,
+rules hash, base and registry commits, and the gate scan bound to the head) and
+the queue refuses a receipt below the depth its head needs
+([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner
+accepted after a fix-check edit goes in `review_loop_exception`, never in prose
+under `REVIEW_LOOP:`.
+
The receipt's summary line prints `passes= blocking_found= deferred=`,
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
@@ -268,9 +333,10 @@ trailers, appended }`.
## Where this is enforced
-- `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=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on), 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: 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-depth.ts`, `bin/gstack-review-plan`: the diff class, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.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=`: 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-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`); refusals are `REVIEW_SCOPE_INPUT` and `REVIEW_SCOPE_RANGE` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
- `lib/ship-policy.ts`: the `review_scope` key and its `ship: review_scope …` consequence line.
- `review/checklist.md`: `prereg-with-results` under Gate Integrity and `test-keyboard-count-loop` under Pass 2; `bin/gstack-coauthor-trailers` for the contributor-credit trailers of a port.
diff --git a/docs/ship-policy.md b/docs/ship-policy.md
index e2cd9f208..c08973c92 100644
--- a/docs/ship-policy.md
+++ b/docs/ship-policy.md
@@ -221,8 +221,11 @@ It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
is not the PR's pushed head, CI at that exact head is not green, or the pregate
result is not a current pass, and (`QUEUE_RECEIPT_MISSING`,
`QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that
-head: /ship runs before every READY ([details](#ship-before-ready)). Send the
-block as printed.
+head: /ship runs before every READY ([details](#ship-before-ready)). It also
+refuses `QUEUE_RECEIPT_DEPTH_RAISED` when the receipt's review depth is below
+what the head needs and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner
+exception the receipt records does not stand ([the ship receipt](#ship-receipt)).
+Send the block as printed.
**2. Lane behind another PR: gate ahead.** Gate the tree the queue will have
once the predecessor lands, while it is still open:
@@ -370,12 +373,17 @@ slot. The queue enforces this at every step from the PR body alone:
- `ready` refuses with `QUEUE_RECEIPT_MISSING` when the body has no valid
receipt and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the
head being READY'd (or its `pr` is another PR); the informational
- `SHIP_RECEIPT:` line still prints. There is no flag around it.
+ `SHIP_RECEIPT:` line still prints. It refuses `QUEUE_RECEIPT_DEPTH_RAISED`
+ (with a `REVIEW_DEPTH:` line) when the receipt records a review depth below
+ what the head needs, and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner
+ exception it records does not stand ([the ship receipt](#ship-receipt)).
+ There is no flag around it.
- `train build` refuses a pin whose PR has no current receipt with the same
codes, naming the PR, before anything is written, pushed or dispatched,
`--dry-run` included.
-- `verify --train` refuses the slot with the same codes before printing
- `MERGE_CONDITION`.
+- `verify --train` refuses the slot with `QUEUE_RECEIPT_MISSING` or
+ `QUEUE_RECEIPT_STALE` before printing `MERGE_CONDITION`; it does not
+ recompute the review depth.
**The stamp keeps the pin's receipt.** The slot's stamp commit changes only
stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs
@@ -478,6 +486,9 @@ its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin),
`QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship
receipt for the pin; see [/ship before READY](#ship-before-ready)),
+`QUEUE_RECEIPT_DEPTH_RAISED` and `QUEUE_RECEIPT_EXCEPTION_INVALID` (the
+receipt's review depth is below what the pin needs, or an owner exception it
+records does not stand; see [the ship receipt](#ship-receipt)),
`QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed
`QUEUE_TRAIN_CONFLICT: train stopped at # (conflicts: ) after
merging #,#; nothing dispatched, no branch pushed; fix: …` line names
@@ -777,7 +788,9 @@ written by the pr-body section of `/ship` after the body is composed.
The ```` ```gstack-ship-receipt ```` block is the handoff record: identifiers
first (`pr`, `head`, `version`, `base`), then the evidence (`gate`, `ci`,
-`pregate`, `issues`, `approval`, `docs`, `review_loop`, `carry`), validated
+`head_tree`, `product_patch_id`, `gstack_version`, `review_depth`, `pregate`,
+`issues`, `approval`, `docs`, `review_loop`, `review_loop_exception`, `carry`),
+validated
against the `ship-receipt` schema (`gstack-artifact schema ship-receipt`) and
read back by `gstack-ship-receipt read`, `result`, `carry` and the queue.
@@ -812,6 +825,46 @@ unreadable base policy is `POLICY_SOURCE_UNAVAILABLE`); one
(`absent/ over` for a path the tree lacks), exit 1 on any `over`, whose
repair is trimming the file, never raising the cap in the same PR.
+**`review_depth` and `gstack_version`.** `write` records the gstack that
+wrote the receipt and the review depth at the checked-out head
+([review-policy.md#review-depth](review-policy.md#review-depth)):
+`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`,
+computed from git objects for the range against `origin/`
+(`gstack-diff-scope --head ` for the scope signals), with the gate
+scan run on that head. `--head` must be the checked-out HEAD
+(`RECEIPT_INVALID` otherwise), so the record and `head_tree` describe the
+tree the review read; tracked edits in the checkout make the scan unavailable,
+which records `full`. `gstack-ship-queue ready` and `train build` recompute
+the depth the head needs from the same range and scope and print `REVIEW_DEPTH: receipt= required=
+runtime_lines= class= reasons=<…> inputs=base@,registry@,gate_scan@`;
+a recorded gate scan counts only when its `head` is the full bound commit,
+otherwise the scan is unavailable and the head needs `full`. A receipt below the required
+depth, or one whose writer records `gstack_version` but no `review_depth`, is
+[`QUEUE_RECEIPT_DEPTH_RAISED`](troubleshooting.md#queue-receipt-depth-raised):
+the receipt is left unchanged and the fix is /ship on that head. A receipt with
+neither field predates depth receipts and is accepted as legacy. A carry
+recomputes the depth at the new head, rebinds the gate scan, and refuses when
+the new head needs a deeper review than the receipt records (a test change that
+skips a test raises the diff to gate class), when `--head` is not the
+checked-out HEAD, when the receipt records `gstack_version` without
+`review_depth`, or when an owner exception no longer stands, each as
+[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused).
+
+**`review_loop_exception`.** The owner's acceptance of a stop after a
+fix-check edit under frozen scope (#3559), from `write --review-loop-exception
+`: `{ accepted_by, date, via, reason, fix_check: { head, findings[, files] } }`.
+It needs `--review-loop` with `fix_checks` of 1 or more, a `fix_check.head`
+that is the receipt head or its ancestor, and, with `--body`, the line
+`Review-loop exception: accepted by via [: (fix-check at , findings)`
+word for word; otherwise
+[`REVIEW_LOOP_EXCEPTION_INVALID`](troubleshooting.md#review-loop-exception-invalid)
+and nothing is written. The queue checks both owner exceptions the same way
+before READY and train build: the record must parse, the body must still
+carry its line, and the fix-check head must be the bound head or its ancestor
+(after a carry across rewritten history the old fix-check commit is off the
+branch, so that check is skipped), or the receipt is
+[`QUEUE_RECEIPT_EXCEPTION_INVALID`](troubleshooting.md#queue-receipt-exception-invalid).
+
**Prose against the receipt.** `gstack-ship-receipt read --pr --check-prose`
(or `--body `) compares every counter the body quotes with the receipt's
integers under `review_loop`, `gate` and `ci`: `passes=2`, `passes: 2` and
@@ -891,6 +944,16 @@ line, and the codes a refusal ends with are
## Carrying a receipt across a base merge or a test-only fix
+**Merge main into a reviewed branch; never rebase it.** What happens to the
+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 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`) |
+
Since the release that added `review_scope`. A `/ship` receipt names the head it
reviewed, so a conflict-only merge of the base or a test-only fix used to
invalidate it and cost a full re-run on identical product code.
@@ -921,11 +984,22 @@ newer tentative one the stamp moved (a `*_KEY_VERSION` written as the next
minor, #3512; a version older than the old one or of another width is a hand
edit). A logic change in a
stamped source file is product. Lockfiles are product unless the policy names
-them. A base merge contributes no product
-change of its own. A receipt carried twice keeps the reviewed head as
+them. An output of an entry with a `command` in origin/'s
+`.gstack/generated.json` is `generated`, not product, only when the
+`--pregate` result passed for HEAD lists it under the `regen` check's
+`proven` paths (the check deleted the touched output in its scratch worktree
+and the command recreated it byte-identically) and that check ran the same
+registry, origin/ at the commit the carry reads. A file that matches an
+output glob but that the command never writes is not proven. Without that
+evidence, or for an entry with no command or one only the branch registers,
+the output is product (U1 on gstack-evals paid a
+full rerun because a merge changed its generated index's diff). A base merge
+contributes no product change of its own. A receipt carried twice keeps the reviewed head as
`carry.from` and adds up the merges and paths. The carried receipt records
`carry: { from, reason, product_patch_id, changed_paths, merge_commits,
-range_diff, covers, reruns }` with the `git range-diff` evidence, prints
+range_diff, covers, reruns, ancestry, trees }` (`ancestry` is `linear`
+along the receipt's own history, `rewritten` once any hop was a rebase;
+`trees` lists the earlier covered trees) with the `git range-diff` evidence, prints
`CARRY: carried from to ()` and the `GSTACK_RESULT:
skill=ship status=complete` line, and its summary line ends with
`carried_from=`. `CARRY: current` means the receipt already names HEAD;
@@ -933,10 +1007,25 @@ 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 receipt whose head is not in HEAD's
-history (a rebase or force-push) or a working tree with uncommitted changes is
-[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused): run `/ship`
-on the new head. The queue's `ready`, `train build` and `verify --train` read the
+(exit 3; the refusal names the paths), and 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)
+and `product_patch_id` (its product patch against the base), and every carry
+rebinds both to the new head and keeps the earlier trees in `carry.trees`.
+When the receipt's head is no longer in HEAD's history, the carry follows only
+when HEAD's tree is one of those trees and HEAD's product patch-id against the
+current base equals the recorded one; it records `carry.ancestry: rewritten`
+and `range_diff: tree-equal`, and needs neither the old commit nor its
+history, so a fresh clone carries too. The tree alone is not enough: a stacked
+branch rebased onto main after its base closed unmerged keeps its tree while
+its diff grows by the base's code, and that is `RECEIPT_CARRY_PRODUCT_CHANGED`.
+Any other tree is
+[`RECEIPT_CARRY_TREE_MISMATCH`](troubleshooting.md#receipt-carry-tree-mismatch),
+which names HEAD's tree and the covered trees. A receipt from before
+`head_tree` existed, whose head is not in this clone, is
+`RECEIPT_CARRY_REFUSED`: run `/ship` on the new head. A refusal never
+rewrites the body. The queue's `ready`, `train build` and `verify --train` read the
carried receipt like any other.
**Delta /ship: eligibility, then checks, then publication.** `/ship` Step 1 runs
@@ -995,7 +1084,7 @@ or the order moved, the result is `QUEUE_STALE` (exit 3) with the exact
Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command).
-1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
+1. **version**: `gstack-version-bump write --version ` 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 ``, 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/` 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 `` (`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 `, 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 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 (: 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 (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. 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.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 20285d89f..82e666d05 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -1566,11 +1566,19 @@ process has exited never blocks, and neither does a stale one: each lease
records its process's start identity, so a pid reused after a reboot or a
machine wake (GBRA-105's lease named pid 379, which had become dbus-daemon)
is reclaimed instead of blocking. The upgrader's own session (the pid chain it
-runs under) never counts.
+runs under) never counts for `bin/gstack-upgrade` run by hand. A Capy install
+in install mode (`bin/gstack-capy-session`) counts it while that lease is under
+120 minutes old: an install run from inside a workflow that holds the lease
+(/ship's docs step, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this
+session holds a workflow lease on this installation: pid ] (m
+), so the upgrade waits until the lease is 120 minutes old; fix:
+/bin/gstack-upgrade once the run ends (UPGRADE_BUSY))` and leaves the
+checkout where the run started. Each `gstack-skill-start` renews the lease.
**Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows
every lease with its age and `alive|dead|stale`; a holder is named as
-`pid (m )`), then re-run `bin/gstack-upgrade`.
+`pid (m )`), then re-run `bin/gstack-upgrade`. For the
+session's own run, run the printed `bin/gstack-upgrade` after the run ends.
When you know they are idle, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade`
proceeds. At a Capy session start the same refusal appears as
`SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`,
@@ -2393,6 +2401,14 @@ and an E.164 `+14155551234` still report, so the finding means the id stands
without a run word (`the id is 40269992077`, `40269992077 failed`) or follows
a word that is not one (`see 40323989742`).
+Separately, the scanner skips a bare number that is exactly a power of two
+from 2^30 through 2^49, one less than such a power, or one of eleven 32-bit
+hash and PRNG constants (Knuth's multiplicative hash, the golden-ratio
+constant, the glibc and Numerical Recipes LCGs, FNV-1a, MurmurHash2 and 3, the
+MT19937 seeder and the largest 32-bit prime; `isNumericConstant` in
+`lib/redact-patterns.ts`). Only the exact digits with no separator are exempt:
+the same digits with a `+`, spaces, dashes, dots or parentheses still report.
+
**Fix.** Write the id next to a run word (`run 40269992077`) or as an
`/actions/runs/` link. For anything else, rerun with `--confirm `
using the `TOKEN:` line the refusal printed; MEDIUM findings do not block
@@ -2433,6 +2449,15 @@ An address only another remote knows still reports. If the hook also printed
`existing-email suppression was limited for this push`, it could not read the
history in 5 seconds, so known authors may be listed too.
+A filename with an `@` in the package@version style is not an address and
+never reports: the part after the `@` is a version or tag (one dotless label
+or a dotted version, never a domain name) followed by file extensions no
+top-level domain uses, or a dotted all-digit version followed by
+an extension that doubles as a top-level domain (`looksLikeAtFilename` in
+`lib/redact-patterns.ts`). A file named after a real address, an address at an
+all-digit domain and an address whose top-level domain doubles as a file
+extension still report.
+
**Fix.** Remove the address, or allow it for this repo:
`git config --add gstack.redact.allowEmail `. If it is your own
address, check `git config user.email`. The allowlist only covers `pii.email`;
@@ -2905,10 +2930,12 @@ for an older PR, write it once by hand with the same command.
### `RECEIPT_INVALID`
**Meaning.** The block exists but does not validate against the `ship-receipt`
-schema (`gstack-artifact schema ship-receipt`).
+schema (`gstack-artifact schema ship-receipt`). `gstack-ship-receipt write`
+also refuses with this code, and writes nothing, when `--head` is not the
+checked-out HEAD: the receipt binds the tree the review read.
**Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the
-block.
+block. For the `--head` refusal, check out that commit and write there.
### `EXECUTION_UNKNOWN`
@@ -3155,7 +3182,10 @@ cannot be downgraded; every other check id in `warn` is honoured only from the
base branch's copy or the user-owned file. For the user-owned file, edit it
directly at the path the message names. A user file inside the repository, or
reached through a symlink into it, is ignored rather than read (`--explain`
-names it).
+names it). An unknown `sealed` key that a newer gstack defines (such as
+`allow_paths` or `case_insensitive`) means this machine's gstack is older than
+the policy: upgrade gstack here, and raise `min_gstack_version` in the
+repository's `.gstack/ship-policy.json` before landing such a key.
### `PREGATE_SECRETS`
@@ -3661,7 +3691,11 @@ finding: rule, `file[:line]`, commit. They never print the matched text.
**Fix.** Remove the output from every published commit before the branch
reaches the public repository (a later commit that deletes it still publishes
-it). A reviewed false positive gets one line in the base branch's
+it). A publishable file kept next to sealed data (a `MANIFEST.json` of hashes
+and counts) can be exempted from the path rules with a `sealed.allow_paths`
+glob in the base branch's `.gstack/pregate.json`; the text rules still read it
+([docs/pregate.md#sealed](pregate.md#sealed)). A reviewed false positive gets
+one line in the base branch's
`.gstack/sealed-allow.tsv`: `rulepathshareason`. A private
destination sets `"visibility": "private"` under `sealed` in the base branch's
`.gstack/pregate.json`. Capy's pull-request tool may not run local hooks, so
@@ -4439,6 +4473,39 @@ written for the pinned head is the binding one and `verify --train` accepts it.
`gstack-ship-queue ready --pr --screen|--no-screen`. A lane never stamps
to fix this: the stamp comes only when the slot is called.
+
+### `QUEUE_RECEIPT_DEPTH_RAISED`
+
+**Meaning.** `ready` or `train build` recomputed the review depth the head
+needs from git (the range against the base ref, `origin/` in `ready`
+and `/` in `train build`, the base commit's
+generated-output registry, and the receipt's gate scan when it names this
+head) and the receipt's `review_depth.depth` is below it, or the receipt comes
+from a gstack that records `gstack_version` but carries no `review_depth`. The
+`REVIEW_DEPTH:` line prints the receipt's depth, the required depth, runtime
+lines, the class, the reasons and the inputs (`base@`, `registry@`,
+`gate_scan@` or `missing`). A gate scan recorded for another head counts as
+missing, which makes the head need `full`. The receipt is unchanged.
+
+**Fix.** Run /ship on the head the READY names; it reviews at the depth
+`gstack-review-plan` plans for that head and writes a receipt that records it.
+Never edit `review_depth` by hand ([review-policy.md](review-policy.md#review-depth)).
+
+
+### `QUEUE_RECEIPT_EXCEPTION_INVALID`
+
+**Meaning.** The receipt records an owner exception (`docs.exception` or
+`review_loop_exception`) that the queue cannot accept: the record does not
+parse, a review-loop exception names no fix-check (`review_loop.fix_checks`
+is 0 or missing), its `fix_check.head` is neither the bound head nor an
+ancestor of it (not checked after a carry across rewritten history), or the PR
+body no longer carries the exception's line word for word
+(`Documentation exception: …` or `Review-loop exception: …`).
+
+**Fix.** Restore the line in the PR body exactly as `write` printed it, or run
+/ship again and record the owner's acceptance with `--docs` or
+`--review-loop-exception` ([ship-policy.md](ship-policy.md#ship-receipt)).
+
### `QUEUE_SCREEN_STALE_REF`
@@ -4699,29 +4766,70 @@ rebased; /ship's own rule is to merge the base in, never rebase a reviewed head.
### `RECEIPT_CARRY_REFUSED`
**Meaning.** `gstack-ship-receipt carry` could not carry the PR's /ship receipt
-to HEAD: the PR body has no valid receipt, the receipt's head is not an ancestor
-of HEAD (a rebase or force-push dropped it), the working tree has uncommitted
-or untracked changes (a carry certifies a commit, never a tree that differs from
-it), or git could not compare the two product diffs or read the working tree's
-status. The receipt in the body is unchanged and still names the old head, so
-`gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`.
+to HEAD: the PR body has no valid receipt, the working tree has uncommitted or
+untracked changes (a carry certifies a commit, never a tree that differs from
+it), the receipt's head left HEAD's history and the receipt records no tree to
+compare (a receipt written before `head_tree`, whose head is not in this
+clone), or git could not compare the two product diffs or read the working
+tree's status. It also refuses when an owner exception the receipt records
+no longer stands, and, for a receipt from a gstack that records review
+depth, when HEAD needs a deeper review than the receipt records, `--head` is
+not the checked-out HEAD, or the receipt records `gstack_version` without
+`review_depth` ([ship-policy.md](ship-policy.md#ship-receipt)). The receipt in
+the body is unchanged and still names the old head, so `gstack-ship-queue
+ready` refuses `QUEUE_RECEIPT_STALE`.
-**Fix.** Keep the reviewed head in HEAD's history (merge the base in instead of
-rebasing), commit or stash every working-tree change, then rerun
+**Fix.** Commit or stash every working-tree change, then rerun
`gstack-ship-receipt carry --pr ` at the new head. When the history is
-already rewritten, run /ship on the new head.
+already rewritten and the receipt predates `head_tree`, run /ship on the new
+head; next time merge the base in instead of rebasing. For a depth or
+exception refusal, run /ship on the new head (or check out `--head` and carry
+there).
+
+
+### `REVIEW_LOOP_EXCEPTION_INVALID`
+
+**Meaning.** `gstack-ship-receipt write --review-loop-exception` refused the
+record and wrote nothing: it is not `{ accepted_by, date, via, reason,
+fix_check: { head, findings[, files] } }`, `--review-loop` is missing or counts
+no fix-check, `fix_check.head` is neither the receipt head nor its ancestor,
+or the `--body` file lacks the `Review-loop exception: …` line.
+
+**Fix.** Record the owner's exact words and the fix-check the stop followed,
+pass the `gstack-review-scope summary` file as `--review-loop`, put the printed
+line in the PR body, and write again
+([ship-policy.md](ship-policy.md#ship-receipt)).
+
+
+### `RECEIPT_CARRY_TREE_MISMATCH`
+
+**Meaning.** The branch was rebased or force-pushed after /ship, so the
+receipt's head is no longer in HEAD's history, and HEAD's tree is not a tree
+the receipt covers (its `head_tree` or a tree in `carry.trees`). No review saw
+this code. The refusal names HEAD's tree and the covered trees; the body is
+unchanged.
+
+**Fix.** Run /ship on the new head. A rebase carries only onto a reviewed tree
+whose product diff against the new base is unchanged; merging the base in
+instead keeps every later carry available
+([ship-policy.md](ship-policy.md#receipt-carry)).
### `RECEIPT_CARRY_PRODUCT_CHANGED`
**Meaning.** Between the receipt's head and HEAD the product diff against the
-base changed: at least one path that is neither a test, release metadata nor
-part of the base merge has a different patch than the reviewed head had. The
-reviewed verdict does not cover HEAD, so the receipt is not carried. The
-refusal names the changed product paths.
+base changed: at least one path that is neither a test, release metadata, a
+registered output the pregate's regen check proved (listed under `proven`,
+checked against the same base registry), nor part of the base merge has
+a different patch than the reviewed head had. After a rebase it means HEAD has
+the reviewed tree but the base moved under it, so the diff a reviewer would see
+is not the reviewed one. The reviewed verdict does not cover HEAD, so the
+receipt is not carried. The refusal names the changed product paths.
**Fix.** Run /ship on the new head. The receipt carries only across a
-conflict-only base merge, a test-only change or release metadata, which the
+conflict-only base merge, a test-only change, release metadata, a registered
+output the pregate's regen check proved, or a rebase onto a covered tree with
+the recorded product diff, 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)).
@@ -5146,3 +5254,47 @@ QM's admin API refused or did not answer. gstack lists packs before it creates o
QM registration does not work yet. QM's skill-pack import clones the whole repository and reads every file before it picks the skills, and by default it refuses a pack over 32 MiB or 5,000 files. The gstack repository is larger (about 73 MiB in 4,300 files), so gstack checks the size first and sends nothing. When QM itself refuses (a QM with other limits), the pack it created stays in the org without skills: `gstack-harness unregister qm --remote` archives it. Meanwhile, give a QM agent gstack by pasting the output of `gstack-harness register generic --print` into its instructions.
+
+## Module loadouts (gstack-modules)
+
+These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modules` (guide: [loadouts.md](loadouts.md)). Each one exits 2 before anything is written, and none falls back to the `full` loadout: an eval arm that silently rendered `full` would measure the control.
+
+
+### `LOADOUTS_INVALID: modules/loadouts.json …`
+
+**Meaning.** `modules/loadouts.json` is unreadable, or names a module, base loadout, rung or default that does not exist, excludes a pinned module, or maps one model id to two rungs. **Fix.** Correct the named entries, then run `bin/gstack-modules validate` until it prints `LOADOUTS: ok`; `git checkout -- modules/loadouts.json` restores the committed file.
+
+
+### `LOADOUT_UNKNOWN: "" is not a loadout (valid: …)`
+
+**Meaning.** `--loadout` or the first token of `--modules` names no loadout in `modules/loadouts.json`. **Fix.** Use a name from the `valid:` list (`bin/gstack-modules list` prints them); `full` is always present.
+
+
+### `MODULE_SPEC_INVALID: …`
+
+**Meaning.** A `--modules` token is not `-` or `+`, both `--loadout` and `--modules` were given, `gen:skill-docs` got a selection with exclusions but no `--out-dir`, or a render flag is missing or malformed. **Fix.** Write the spec as `,-,+` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists.
+
+
+### `MODULE_UNKNOWN: "" is not a module`
+
+**Meaning.** The id names no skill, section, overlay or preamble piece. **Fix.** Copy the id from `bin/gstack-modules list`.
+
+
+### `MODULE_PINNED: is pinned and can never be excluded`
+
+**Meaning.** The spec removes a module the safety contract needs: the start command (`preamble:bash`), plan-mode rules, the question format, completion status, the unattended profile, /ship's review gates, the router or `/gstack-upgrade`. **Fix.** Drop `-` from the spec; `bin/gstack-modules list --pinned` prints every pinned id.
+
+
+### `MODULE_EXCLUSION_UNSAFE: : referenced by :`
+
+**Meaning.** The rendered tree still points at a module the loadout left out, so an agent following it would read a file that is not there. **Fix.** Exclude the referencing module as well, or wrap the referencing skeleton text in `{{IF_SECTION:}}…{{/IF_SECTION}}` in its template (the text renders unchanged when the section loads). Nothing was published; rerun after the change.
+
+
+### `RENDER_FAILED: gen-skill-docs failed: …`
+
+**Meaning.** Generation itself failed for this loadout (an unresolved placeholder, an oversized skill, a missing section). **Fix.** Fix the named template or module and rerun. The staging directory was removed and `--out-dir` is unchanged.
+
+
+### `RENDER_DEST_NOT_OWNED: exists and holds no loadout.lock.json`
+
+**Meaning.** `--out-dir` is a non-empty directory that an earlier `gstack-modules render` did not write, so replacing it could delete someone's files. **Fix.** Pass a new or empty directory, or the directory of an earlier render.
diff --git a/docs/unattended.md b/docs/unattended.md
index e67b637e9..f4ed5bfdd 100644
--- a/docs/unattended.md
+++ b/docs/unattended.md
@@ -225,9 +225,10 @@ and reruns carry with `--pregate .gstack/tmp/pregate.json`. `CARRY: carried from
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)`
-or `(RECEIPT_CARRY_REFUSED)` with the refused result line means the product
-diff changed or the reviewed head left HEAD's history, so /ship runs again;
+current` means nothing moved. `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;
`(RECEIPT_GIT_TOO_OLD)` means the machine's git predates 2.39 and cannot run
`patch-id --verbatim` ([ship-policy.md](ship-policy.md#receipt-carry)).
diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts
index dc8e6f27b..ab11fbbca 100644
--- a/lib/headless-artifacts.ts
+++ b/lib/headless-artifacts.ts
@@ -71,6 +71,8 @@ export interface PregateCheck {
stage?: 'preflight' | 'tests'; inputs?: Record; fix?: string; code?: string; lines?: string[];
// Lane D (GSTA-112): a non-pass names its cause; an environment cause carries its kind and evidence.
cause?: 'diff' | 'environment'; environment?: { kind: string; evidence: string; proof?: 'launch' | 'output' };
+ /** `regen`: the touched outputs the generator recreated byte-identically from a deleted copy (what a receipt carry may set aside as generated). */
+ proven?: string[];
}
/** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */
export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> }
@@ -101,7 +103,14 @@ export interface ShipReceipt {
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> };
/** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; dispatches?: Record; est_input_tokens?: number };
- carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] };
+ carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' };
+ /** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */
+ head_tree?: string; product_patch_id?: string;
+ /** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */
+ gstack_version?: string;
+ review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[] };
+ /** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */
+ review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } };
}
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused'];
@@ -206,7 +215,7 @@ export const ARTIFACT_SCHEMAS = {
type: 'object', required: ['schema_version', 'tree', 'checks'],
properties: {
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
- checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } } } } },
+ checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } }, proven: strList } } },
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: str,
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str,
substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } },
@@ -222,7 +231,10 @@ export const ARTIFACT_SCHEMAS = {
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @ from pregate.json, current for this head' }, requires_remote: strList,
approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' },
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } },
- carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList } },
+ carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } },
+ head_tree: str, product_patch_id: str, gstack_version: str,
+ review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList } },
+ review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } },
issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } },
},
},
diff --git a/lib/pregate/checks/regen.ts b/lib/pregate/checks/regen.ts
index dd63dd003..4aef9c75e 100644
--- a/lib/pregate/checks/regen.ts
+++ b/lib/pregate/checks/regen.ts
@@ -23,7 +23,7 @@ function run(ctx: PregateContext): CheckResult {
fix: 'rerun with --allow-repo-commands (what /ship passes)', lines: commands.map(c => `COMMAND: ${c.id}=${JSON.stringify(c.command)} outputs=${c.outputs.join(',')}`),
};
}
- const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), env: ctx.env, timeoutMs: ctx.timeoutMs });
+ const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), prove: ctx.touched.map(t => t.path), env: ctx.env, timeoutMs: ctx.timeoutMs });
const lines = results.map(renderEntry);
const stale = results.filter(r => r.status === 'stale');
const failed = results.filter(r => r.status === 'failed');
@@ -32,7 +32,8 @@ function run(ctx: PregateContext): CheckResult {
if (depsMissing.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_DEPS_MISSING', detail: `dependencies missing; ${depsMissing[0]!.fix} (${depsMissing.length} entr${depsMissing.length === 1 ? 'y' : 'ies'} unverified: ${depsMissing.map(d => d.id).join(', ')})`, fix: depsMissing[0]!.fix };
if (failed.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_COMMAND_FAILED', detail: `${failed.length} regen command(s) failed: ${failed.map(f => f.id).join(', ')}`, fix: failed.map(f => f.fix).join('; ') };
if (stale.length) return { id: 'regen', stage: 'preflight', status: 'fail', inputs, lines, code: 'REGEN_STALE', detail: `${stale.length} stale: ${stale.flatMap(s => s.changed.concat(s.outside)).join(' ')}`, fix: stale.map(s => s.fix).join('; ') };
- return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` };
+ const proven = results.flatMap(r => r.proven ?? []).sort();
+ return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, ...(proven.length ? { proven } : {}), detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` };
}
export const regenCheck: CheckDef = { id: 'regen', stage: 'preflight', tier: 1, downgradable: true, needsRepoCommands: true, run };
diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts
index 8e20540fa..bbd409ee7 100644
--- a/lib/pregate/config.ts
+++ b/lib/pregate/config.ts
@@ -321,7 +321,7 @@ export function explainConfig(): string {
'local: / lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
- 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)',
+ 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)',
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree= tests= failed=) (docs/pregate.md#remote-gate)',
diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts
index 9c820900c..330db6130 100644
--- a/lib/redact-patterns.ts
+++ b/lib/redact-patterns.ts
@@ -201,6 +201,35 @@ function looksLikeCompactTimestamp(span: string): boolean {
);
}
+/**
+ * Bare integers that programs print as constants: 2^30 through 2^49 and each
+ * one minus one (`4294967296`, `2147483647`), and the 32-bit multipliers,
+ * offsets and primes of common hashes and PRNGs (Knuth's multiplicative hash,
+ * the golden-ratio constant, the glibc and Numerical Recipes LCGs, FNV-1a,
+ * MurmurHash2 and 3, the MT19937 seeder, the largest 32-bit prime). pii.phone.e164
+ * reads each as a bare 10-15 digit number. Only the exact separator-free value
+ * is exempt: the same digits with a `+`, spaces, dashes or dots still report,
+ * so an E.164 number, which carries its `+`, is never exempt.
+ */
+const NUMERIC_CONSTANTS: ReadonlySet = new Set([
+ ...Array.from({ length: 20 }, (_, i) => 2n ** BigInt(30 + i)).flatMap((p) => [String(p), String(p - 1n)]),
+ "2654435761",
+ "2654435769",
+ "1103515245",
+ "1013904223",
+ "2166136261",
+ "1540483477",
+ "3432918353",
+ "2246822507",
+ "3266489909",
+ "1812433253",
+ "4294967291",
+]);
+
+export function isNumericConstant(span: string): boolean {
+ return NUMERIC_CONSTANTS.has(span);
+}
+
/** Context window for pairing a normalized parcel ID with its punctuated form. */
const PARCEL_CONTEXT_CHARS = 400;
@@ -446,6 +475,31 @@ export function isDotenvFilename(match: RegExpExecArray): boolean {
return spanStart > 0 && input[spanStart - 1] === ".";
}
+/** A trailing run of file extensions that are not top-level domains (checked
+ * against the IANA root zone list, 2026-10-28). Extensions that are also TLDs
+ * (`.md`, `.py`, `.sh`, `.rs`, `.so`, `.zip`, `.mov`, `.map`) are left out on
+ * purpose. */
+const NON_TLD_EXTENSIONS = /(?:\.(?:json|jsonc|jsonl|ndjson|ya?ml|toml|lock|txt|csv|tsv|log|ini|cfg|conf|xml|html?|[mc]?js|jsx|tsx?|tgz|gz|tar|bz2|xz|zst|whl|jar|gem|nupkg|deb|rpm|wasm|svg|png|jpe?g|pdf|patch|diff))+$/i;
+
+/**
+ * True when a pii.email span is a filename with an `@` in it rather than an
+ * address: `terminal-bench@2.0.json`, `lodash@4.17.21.tgz`,
+ * `eslint-config@next.yaml`. The package@version convention puts a version or
+ * tag after the `@`, so either sign decides: the "domain" ends in file
+ * extensions no TLD uses and what precedes them cannot be a domain (one dotless
+ * label or a dotted version), or it is a dotted version (two or more all-digit
+ * labels) followed by an extension that doubles as a TLD (`a@1.2.md`). A file
+ * named after an address (`jane@acme.com.json`), `user@163.com`,
+ * `alice@1.2.com` and an address at a TLD that doubles as an extension
+ * (`dev@acme.sh`, `ops@acme.md`) still report.
+ */
+export function looksLikeAtFilename(span: string): boolean {
+ const domain = span.slice(span.lastIndexOf("@") + 1);
+ const stem = domain.replace(NON_TLD_EXTENSIONS, "");
+ if (stem !== domain) return !/\.[A-Za-z]{2,}$/.test(stem);
+ return /^\d+(?:\.\d+)+\.(?:md|py|sh|rs|so|zip|mov|map)$/i.test(domain);
+}
+
/** Extensions that mark a `.local.` span as a file. Closed on
* purpose: `.md` is also Moldova's TLD, but no internal suffix sits in front
* of a public one, so after `.local` it can only be an extension. */
@@ -955,6 +1009,7 @@ export const PATTERNS: RedactPattern[] = [
regex: /\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\b/,
autoRedactable: true,
redactToken: "",
+ validate: (span) => !looksLikeAtFilename(span),
// Engine layers the email allowlist (example.com, noreply@, user's own,
// repo-public authors) on top of this — see redact-engine.ts.
},
@@ -969,12 +1024,14 @@ export const PATTERNS: RedactPattern[] = [
// A digit-only UUID's hyphen groups read as national phone formatting, and
// so do a county tax-map parcel ID (see looksLikeParcelId), vector
// coordinates (looksLikeDecimalCoordinates) and seed/nonce/timestamp JSON
- // values (isNumericMetadataValue), and GitHub Actions run and job ids in
- // their id positions (isCiRunIdentifier).
+ // values (isNumericMetadataValue), GitHub Actions run and job ids in
+ // their id positions (isCiRunIdentifier), and bare powers of two and
+ // hash/PRNG constants (isNumericConstant).
validate: (span, match) =>
!insideUuid(match) &&
span.replace(/\D/g, "").length >= 10 &&
!looksLikeCompactTimestamp(span) &&
+ !isNumericConstant(span) &&
!looksLikeParcelId(span, match) &&
!looksLikeDecimalCoordinates(span) &&
!isNumericMetadataValue(span, match) &&
diff --git a/lib/regen.ts b/lib/regen.ts
index 2a8538647..4760e5573 100644
--- a/lib/regen.ts
+++ b/lib/regen.ts
@@ -164,6 +164,8 @@ export interface EntryResult {
exit?: number | null; stderr?: string; ms: number;
/** The exact command an author runs to refresh the entry. */
fix: string;
+ /** `check` with `prove`, on a fresh entry: the deleted outputs the proof run recreated byte-identically (an output it never writes, or a proof run that failed, proves nothing). */
+ proven?: string[];
}
/** Working tree vs the seeded index in a scratch worktree: modified tracked files plus new untracked ones. */
@@ -214,6 +216,8 @@ export interface RunRegenOptions {
only?: string[];
/** Restrict to entries whose inputs or outputs intersect these paths. */
touched?: string[];
+ /** `check`: outputs to prove on a fresh entry: a second run with each one deleted first, the ones it recreates byte-identically come back as `proven`. The verdict comes from the first run alone. */
+ prove?: readonly string[];
env?: NodeJS.ProcessEnv; timeoutMs?: number;
}
@@ -248,10 +252,22 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s
if (run.exit !== 0) { results.push({ id: e.id, command: e.command, status: 'failed', changed, outside, exit: run.exit, stderr: run.stderr, ms, fix: fixFor(e) }); continue; }
if (o.mode === 'check') {
const differs = changed.length > 0 || outside.length > 0;
- if (differs && install) results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` });
- else results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) });
// Reset the scratch so the next entry's diff is its own.
- git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']);
+ const reset = () => { git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']); };
+ reset();
+ if (differs && install) { results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` }); continue; }
+ // The proof is a second run apart from the verdict: each touched output deleted first, the ones recreated
+ // byte-identically are proven, and a failure (a generator that reads its own output) proves nothing.
+ const probe = differs ? [] : (o.prove ?? []).filter(p => matchesAny(e.outputs, p) && fs.existsSync(path.join(cwd, p)));
+ let proven: string[] | undefined;
+ if (probe.length) {
+ for (const p of probe) fs.rmSync(path.join(cwd, p), { force: true });
+ const proof = runCommand(cwd, e.command, o.env ?? process.env, timeout);
+ const redo = changedPaths(cwd);
+ proven = proof.exit === 0 ? probe.filter(p => !redo.includes(p)) : [];
+ reset();
+ }
+ results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e), ...(proven ? { proven } : {}) });
} else results.push({ id: e.id, command: e.command, status: changed.length ? 'written' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) });
}
} finally { scratch?.remove(); }
diff --git a/lib/restamp.ts b/lib/restamp.ts
index e0d473061..1a4d18e23 100644
--- a/lib/restamp.ts
+++ b/lib/restamp.ts
@@ -223,6 +223,19 @@ export function versionFiles(root: string, versionRel: string): string[] {
const dir = path.dirname(pkgRel) === '.' ? '' : path.dirname(pkgRel) + '/';
return [versionRel, pkgRel, ...NPM_LOCKFILES.map(l => dir + l)];
}
+/**
+ * The manifest's path when the repo's convention is a four-part manifest: the policy lists it as a
+ * stamp file and the base's copy holds a four-part version (gbrain). Read at the base, like the
+ * policy, so a branch an older restamp cut to three parts heals. Otherwise null, and the writer
+ * stores the npm-valid three-part form (decision 11; gstack's own manifest).
+ */
+function manifestKeepsFourParts(plan: Plan, root: string): string | null {
+ const pkgRel = versionFiles(root, plan.versionRel)[1]!;
+ if (pkgRel === plan.versionRel || !matchesAny([...plan.policy.stamp_paths, ...plan.policy.stamp_files], pkgRel)) return null;
+ const atBase = git(root, ['show', `${plan.baseSha}:${pkgRel}`]);
+ const version = atBase.ok ? extractVersion(atBase.out, pkgRel) : '';
+ return version && versionWidth(version) === 4 ? pkgRel : null;
+}
function allowedOutput(plan: Plan, root: string, rel: string): boolean {
const p = plan.policy;
if (versionFiles(root, plan.versionRel).includes(rel)) return true;
@@ -455,9 +468,10 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
const v = stage('version');
if (command) { v.status = 'skipped'; v.detail = 'restamp_command owns the version'; }
else {
- const w = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
+ const full = manifestKeepsFourParts(plan, staging);
+ const w = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion, ...(full ? ['--manifest-form', 'full'] : [])], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); }
- v.status = 'done'; v.detail = `wrote ${plan.newVersion}`;
+ v.status = 'done'; v.detail = `wrote ${plan.newVersion}` + (full ? ` (${full} keeps four parts: a policy stamp file the base holds in four)` : '');
}
injected('version');
diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts
index 8c7e752ea..ee4004adb 100644
--- a/lib/result-codes-release.ts
+++ b/lib/result-codes-release.ts
@@ -148,6 +148,16 @@ export const RELEASE_RESULT_CODES = {
summary: 'the PR body’s /ship receipt is for another head than the one being READY’d or merged (a push after /ship, or a receipt copied from another PR)',
fix: 'rerun /ship on the current head so the receipt names it, then gstack-ship-queue ready again; a slot’s stamp commit keeps the receipt written for its pinned head',
},
+ QUEUE_RECEIPT_DEPTH_RAISED: {
+ anchor: 'queue-receipt-depth-raised',
+ summary: 'the PR body’s /ship receipt records a review depth below the one its head needs (the queue recomputed it from git), or a current receipt records no review_depth at all',
+ fix: 'run /ship on the head the READY names; the REVIEW_DEPTH line shows the receipt’s depth, the required depth, its reasons and inputs, and the receipt is left unchanged',
+ },
+ QUEUE_RECEIPT_EXCEPTION_INVALID: {
+ anchor: 'queue-receipt-exception-invalid',
+ summary: 'the PR body’s /ship receipt records an owner exception (docs or review loop) that is malformed, lacks the fix-check it follows, or whose line the body no longer carries verbatim',
+ fix: 'restore the exception line in the PR body word for word, or run /ship again and record the owner’s acceptance with --docs or --review-loop-exception',
+ },
QUEUE_SCREEN_STALE_REF: {
anchor: 'queue-screen-stale-ref',
summary: 'GitHub’s refs/pull//merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success',
@@ -246,18 +256,28 @@ export const RELEASE_RESULT_CODES = {
},
RECEIPT_CARRY_REFUSED: {
anchor: 'receipt-carry-refused',
- summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the receipt head is not an ancestor of HEAD (rebase or force-push), or the product diff could not be compared',
- fix: 'merge the base in instead of rebasing, keep the reviewed head in HEAD\'s history, then rerun carry; otherwise run /ship on the new head',
+ summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the working tree has uncommitted changes, the receipt head is not an ancestor of HEAD and the receipt records no tree to compare, or the product diff could not be compared',
+ fix: 'commit or stash working-tree changes and rerun carry; when history was rewritten and the receipt predates head_tree, run /ship on the new head (merge the base in instead of rebasing next time)',
},
PROSE_LINT_FOUND: {
anchor: 'prose-lint-found',
summary: 'the prose /ship is about to publish (a CHANGELOG entry or a PR body) carries an AI tell a regular expression can decide',
fix: 'rewrite each PROSE: line in the draft the way its hint says, then rerun gstack-prose-lint on the same file',
},
+ REVIEW_LOOP_EXCEPTION_INVALID: {
+ anchor: 'review-loop-exception-invalid',
+ summary: 'write --review-loop-exception got an exception that is malformed, follows no fix-check (review_loop.fix_checks is 0 or missing), names a fix-check head that is not the receipt head or its ancestor, or is missing from the PR body',
+ fix: 'pass { accepted_by, date, via, reason, fix_check: { head, findings } } with the owner’s words, the --review-loop summary that counts the fix-check, and the printed "Review-loop exception: …" line in the body',
+ },
+ RECEIPT_CARRY_TREE_MISMATCH: {
+ anchor: 'receipt-carry-tree-mismatch',
+ summary: 'the branch was rebased or force-pushed since its /ship receipt, and HEAD\'s tree is not a tree the receipt covers, so no review saw this code',
+ fix: 'run /ship on the new head; next time merge the base in instead of rebasing a reviewed branch (a rebase carries only onto a reviewed tree with the reviewed product diff)',
+ },
RECEIPT_CARRY_PRODUCT_CHANGED: {
anchor: 'receipt-carry-product-changed',
- summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix or release metadata), so the reviewed verdict does not cover HEAD',
- fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata or the base merge and the product patch-id is unchanged',
+ summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD',
+ fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged',
},
DOCS_AUDIT_BLOCKED: {
anchor: 'docs-audit-blocked',
diff --git a/lib/result-codes.ts b/lib/result-codes.ts
index d4e1cf5b4..46ca94bd7 100644
--- a/lib/result-codes.ts
+++ b/lib/result-codes.ts
@@ -597,7 +597,7 @@ export const RESULT_CODES = {
},
UPGRADE_BUSY: {
anchor: 'upgrade-busy',
- summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), so moving the checkout would change gstack under it',
+ summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, so moving the checkout would change gstack under it',
fix: 'wait for those sessions to finish (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know they are idle',
},
UPGRADE_FETCH_FAILED: {
diff --git a/lib/review-collect.ts b/lib/review-collect.ts
new file mode 100644
index 000000000..3846df0f3
--- /dev/null
+++ b/lib/review-collect.ts
@@ -0,0 +1,137 @@
+/**
+ * review-collect — the git reads behind a review depth (docs/review-policy.md#review-depth),
+ * kept out of lib/review-depth.ts so the policy stays pure. collectCommit reads git
+ * objects only (a range between two commits, from any checkout, dirty or not);
+ * collectLive reads the working tree as /ship reviews it (committed, tracked
+ * uncommitted edits and untracked files). Both return a ClassInput with per-path
+ * line counts, the base commit's generated-output registry and collector errors;
+ * the caller adds the gate-scan result.
+ */
+import { spawnSync } from 'node:child_process';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { GENERATED_PATH, parseRegistry } from './regen';
+import { RESULT_CODES } from './result-codes';
+import type { ClassInput, DepthError, PathLines, RegistryInfo } from './review-depth';
+
+export interface Collected extends ClassInput {
+ perPath: PathLines[];
+ registry: RegistryInfo;
+ errors: DepthError[];
+ deleted: string[];
+ stampOnly: string[];
+ /** Added lines per path, for scanNumbers. */
+ added: Record;
+ diffLines: number;
+ mergeBase: string | null;
+ untracked: string[];
+}
+
+function git(root: string, args: string[]): { ok: boolean; out: string; err: string } {
+ const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
+ return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
+}
+const scratch = (p: string) => p.startsWith('.gstack/tmp/');
+const oneLine = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').trim().slice(0, 200);
+const unavailable = (input: string, detail: string): RegistryInfo => ({ sha: null, outputs: [], error: { code: 'registry_unavailable', detail: oneLine(detail), input, repair: 'fetch the base branch (git fetch origin ) so its commit and registry resolve' } });
+
+/** The registry as committed at `sha`: outputs of entries with a command (a `command: null` entry never excludes); a missing file is an empty registry. */
+export function loadRegistryAt(root: string, sha: string): RegistryInfo {
+ const commit = git(root, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]);
+ if (!commit.ok) return unavailable(`${sha}:${GENERATED_PATH}`, `${sha} is not a commit in this repository`);
+ const resolved = commit.out.trim();
+ const input = `${resolved.slice(0, 12)}:${GENERATED_PATH}`;
+ if (!git(root, ['cat-file', '-e', `${resolved}:${GENERATED_PATH}`]).ok) return { sha: resolved, outputs: [] };
+ const show = git(root, ['show', `${resolved}:${GENERATED_PATH}`]);
+ if (!show.ok) return unavailable(input, show.err || 'git show failed');
+ const { registry, errors } = parseRegistry(show.out);
+ const e = errors[0];
+ if (e) return { sha: resolved, outputs: [], error: { code: 'registry_invalid', detail: oneLine(`${e.path || GENERATED_PATH}: ${e.message}`), input, repair: RESULT_CODES[e.code].fix, result: e.code } };
+ return { sha: resolved, outputs: registry.entries.filter(x => x.command !== null).flatMap(x => x.outputs) };
+}
+
+/** Numstat, deletions and the -U0 patch for `range` (`[mergeBase, head]` or `[mergeBase]` for the working tree). */
+function readDiff(root: string, range: string[], errors: DepthError[]): Pick {
+ const numstat = git(root, ['diff', '--numstat', '-z', '--no-renames', ...range]);
+ const status = git(root, ['diff', '--name-status', '-z', '--no-renames', '--diff-filter=D', ...range]);
+ const patch = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range]);
+ const empty = { perPath: [], deleted: [], stampOnly: [], added: {} };
+ for (const [r, what] of [[numstat, '--numstat'], [status, '--name-status'], [patch, '-U0']] as const) {
+ if (!r.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(r.err || 'git diff failed'), input: `git diff ${what} ${range.join(' ')}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); return empty; }
+ }
+ const perPath: PathLines[] = [];
+ for (const rec of numstat.out.split('\0').filter(Boolean)) {
+ const [a, d, ...rest] = rec.split('\t');
+ const p = rest.join('\t');
+ if (!scratch(p)) perPath.push({ path: p, lines: (Number(a) || 0) + (Number(d) || 0), binary: a === '-' && d === '-' });
+ }
+ const statusParts = status.out.split('\0');
+ const deleted = statusParts.flatMap((s, k) => (s === 'D' && statusParts[k + 1] ? [statusParts[k + 1]!] : [])).filter(p => !scratch(p));
+ const added: Record = {};
+ const changed: Record = {};
+ let cur = '';
+ let minus = '';
+ let inHunk = false;
+ for (const line of patch.out.split('\n')) {
+ if (line.startsWith('diff --git ')) { inHunk = false; cur = ''; minus = ''; continue; }
+ if (!inHunk) {
+ if (line.startsWith('--- ')) minus = line === '--- /dev/null' ? '' : line.slice(6).replace(/\t$/, '');
+ else if (line.startsWith('+++ ')) cur = line === '+++ /dev/null' ? minus : line.slice(6).replace(/\t$/, '');
+ else if (line.startsWith('@@')) inHunk = true;
+ continue;
+ }
+ if (!cur || scratch(cur) || line.startsWith('@@')) continue;
+ if (line[0] === '+') (added[cur] ??= []).push(line.slice(1));
+ if (line[0] === '+' || line[0] === '-') (changed[cur] ??= []).push(line);
+ }
+ const stampOnly = perPath.map(e => e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l)));
+ return { perPath, deleted, stampOnly, added };
+}
+
+function result(diff: ReturnType, extra: Pick): Collected {
+ const paths = diff.perPath.map(e => e.path).sort();
+ return { ...diff, ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) };
+}
+
+/** The range `baseSha`...`headSha` from git objects alone; the registry is the base commit's, never the head's. */
+export function collectCommit(root: string, baseSha: string, headSha: string): Collected {
+ const errors: DepthError[] = [];
+ const registry = loadRegistryAt(root, baseSha);
+ const mb = git(root, ['merge-base', baseSha, headSha]);
+ if (!mb.ok) {
+ errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' });
+ return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [] });
+ }
+ const mergeBase = mb.out.trim();
+ const diff = readDiff(root, [mergeBase, headSha], errors);
+ const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]);
+ const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort();
+ return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [] });
+}
+
+/** The working tree against the merge base with origin/ (or ), untracked files counted per path; the registry is the base ref's commit. */
+export function collectLive(root: string, base: string): Collected {
+ const errors: DepthError[] = [];
+ const ref = git(root, ['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]).ok ? `origin/${base}` : base;
+ const registry = loadRegistryAt(root, ref);
+ const mb = git(root, ['merge-base', ref, 'HEAD']);
+ if (!mb.ok) {
+ errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` });
+ return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [] });
+ }
+ const mergeBase = mb.out.trim();
+ const diff = readDiff(root, [mergeBase], errors);
+ const others = git(root, ['ls-files', '--others', '--exclude-standard', '-z']);
+ if (!others.ok) errors.push({ code: 'numstat_failed', detail: oneLine(others.err || 'git ls-files failed'), input: 'git ls-files --others --exclude-standard', repair: 'run the command by hand and fix what git reports' });
+ const untracked = others.out.split('\0').filter(p => p && !scratch(p)).sort();
+ for (const p of untracked) {
+ let buf: Buffer;
+ try { buf = fs.readFileSync(path.join(root, p)); } catch { diff.perPath.push({ path: p, lines: 0, binary: true }); continue; }
+ const binary = buf.subarray(0, 8000).includes(0);
+ const text = binary ? '' : buf.toString('utf8');
+ const lines = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
+ diff.perPath.push({ path: p, lines, binary });
+ if (!binary) diff.added[p] = text.split('\n');
+ }
+ return result(diff, { root, registry, errors, mergeBase, untracked });
+}
diff --git a/lib/review-depth.ts b/lib/review-depth.ts
index f7331dbba..0cae65d45 100644
--- a/lib/review-depth.ts
+++ b/lib/review-depth.ts
@@ -1,17 +1,22 @@
/**
* review-depth — the one policy owner for how much review a /ship run dispatches
- * (docs/review-policy.md#review-depth). A pure function from the changed paths,
- * gstack-diff-scope flags, the diff size, the gate scan and the pass history to a
- * diff class, the next step of the loop and the reviewers that step dispatches,
- * each with its reasoning-effort tier. bin/gstack-review-plan prints it; the
+ * (docs/review-policy.md#review-depth). A pure function from the changed paths and
+ * their line counts, gstack-diff-scope flags, the base registry's generated outputs,
+ * the gate scan and the pass history to a diff class, a review depth (light,
+ * standard, full), the next step of the loop and the reviewers that step
+ * dispatches, each with its effort tier and the pathspecs it leaves out. Git reads
+ * live in lib/review-collect.ts; bin/gstack-review-plan prints the plan; the
* review-army resolver and the skills read the same definitions.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
+import type { ResultCodeName } from './result-codes';
+import { matchesAny } from './ship-policy';
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
export type PathClass = 'docs' | 'tests' | 'code';
+export type Depth = 'light' | 'standard' | 'full';
export type Effort = 'light' | 'full' | 'host';
export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check';
export type Step = 'full' | 'fix-check' | 'done' | 'stop';
@@ -22,7 +27,12 @@ export const FULL_PASS_CAP = 2;
export const SMALL_DIFF_LINES = 50;
export const RED_TEAM_LINES = 200;
export const LARGE_DIFF_LINES = 100;
+/** The checklists light's batched specialist always reads, whatever the scope. */
+export const LIGHT_FIXED: readonly Specialist[] = ['testing', 'maintainability', 'security'];
+/** Why light with runtime above 0 keeps the native adversarial pass as its own dispatch. */
+export const LIGHT_ADVERSARIAL_REASON = 'backtest inconclusive (GSTA-147 D5)';
const CLASS_RANK: Record = { docs: 0, tests: 1, code: 2, gate: 3 };
+const DEPTH_RANK: Record = { light: 0, standard: 1, full: 2 };
const AGENT_PROSE = [/(^|\/)SKILL\.md(\.tmpl)?$/, /(^|\/)sections\//, /^scripts\/resolvers\//, /^docs\/[^/]*-policy\.md$/, /^docs\/(captain|unattended|pregate)\.md$/, /(^|\/)DRIVER\.md(\.tmpl)?$/, /(^|\/)templates\//, /(^|\/)(AGENTS|CLAUDE|ETHOS)\.md$/, /(^|\/)specialists\//, /(^|\/)checklist\.md$/, /^\.gstack\//];
/** gstack-diff-scope's TEST_PATH_RE (the test-bootstrap rule), plus fixture directories. */
@@ -30,19 +40,41 @@ const TEST_PATH = [/(^|\/)(tests?|spec|__tests__)\/|(^|\/)tests?\.py$|(^|\/)test
const DOC_PATH = /\.(md|mdx|markdown|rst|txt|adoc)$/i;
/** Release metadata a version stamp writes: never a reason for review depth on its own. */
const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//];
-const GATE_PATH = [/(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/];
+/** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */
+const GATE_PATH = [
+ /(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/,
+ /^lib\/review-(depth|collect|scope)\.ts$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/,
+];
-/** One changed path's class. Markdown an agent or a gate reads (skills, sections, resolvers, policy and rule docs, templates, any markdown in a skill directory under `root`) is code: it is gstack's product. */
-export function classifyPath(p: string, root?: string): PathClass {
+/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
+export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
+ SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED,
+ patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
+})).digest('hex').slice(0, 16);
+
+/**
+ * One changed path's class. Markdown an agent or a gate reads (skills, sections, resolvers, policy and rule docs,
+ * templates, any markdown in a skill directory) is code: it is gstack's product. `root` is the repository root
+ * (skill directories probed on disk) or the top-level skill directory names read from a commit.
+ */
+export function classifyPath(p: string, root?: string | readonly string[]): PathClass {
if (RELEASE_METADATA.some(r => r.test(p))) return 'docs';
if (AGENT_PROSE.some(r => r.test(p))) return 'code';
if (TEST_PATH.some(r => r.test(p))) return 'tests';
if (!DOC_PATH.test(p)) return 'code';
const top = p.includes('/') ? p.split('/')[0]! : '';
- if (root && top && ['SKILL.md', 'SKILL.md.tmpl'].some(f => fs.existsSync(path.join(root, top, f)))) return 'code';
- return 'docs';
+ if (!top || !root) return 'docs';
+ if (typeof root === 'string') return ['SKILL.md', 'SKILL.md.tmpl'].some(f => fs.existsSync(path.join(root, top, f))) ? 'code' : 'docs';
+ return root.includes(top) ? 'code' : 'docs';
}
+export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path';
+/** Why a depth failed closed to full: the input it could not read, a sanitized cause and the repair; `result` is the result code behind it when one exists. */
+export interface DepthError { code: DepthErrorCode; detail: string; input: string; repair: string; result?: ResultCodeName }
+export interface PathLines { path: string; lines: number; binary: boolean }
+/** The base commit's `.gstack/generated.json`: outputs of entries with a command only; `error` when it could not be read or validated. */
+export interface RegistryInfo { sha: string | null; outputs: readonly string[]; error?: DepthError }
+
export interface ClassInput {
paths: readonly string[];
/** gstack-diff-scope output (SCOPE_* keys). */
@@ -53,23 +85,38 @@ export interface ClassInput {
deleted?: readonly string[];
/** Paths whose whole change is a version stamp (package.json's version key): classified as docs. */
stampOnly?: readonly string[];
- /** The repository root: markdown inside a skill directory there is code. */
- root?: string;
+ /** The repository root, or the skill directory names at the reviewed commit: markdown inside a skill directory is code. */
+ root?: string | readonly string[];
+ /** Changed lines per path; without it a code diff counts every line as runtime. */
+ perPath?: readonly PathLines[];
+ registry?: RegistryInfo;
+ /** Collector failures (a diff git could not list): each fails the depth closed. */
+ errors?: readonly DepthError[];
}
-export interface ClassResult { class: DiffClass; reasons: string[] }
+export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null }
-/** The diff's class: the most restrictive of its paths, raised to gate by any gate signal; errors fail closed. */
+const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200);
+const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p);
+const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root));
+
+/** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */
export function classifyDiff(i: ClassInput): ClassResult {
const reasons: string[] = [];
+ const errors: DepthError[] = [...(i.errors ?? [])];
let cls: DiffClass = 'docs';
const raise = (c: DiffClass, why: string) => { if (CLASS_RANK[c] > CLASS_RANK[cls]) cls = c; reasons.push(why); };
if (!i.paths.length) raise('docs', 'no changed paths');
- for (const p of i.paths) { const c = (i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); }
+ for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); }
const s = i.scope ?? {};
- if (s.SCOPE_ERROR) raise('code', `scope error: ${s.SCOPE_ERROR}`);
+ if (s.SCOPE_ERROR) {
+ raise('code', `scope error: ${s.SCOPE_ERROR}`);
+ errors.push({ code: 'scope_error', detail: sanitize(s.SCOPE_ERROR), input: 'gstack-diff-scope ', repair: 'run gstack-diff-scope by hand and fix what it reports' });
+ }
if (CLASS_RANK[cls] >= CLASS_RANK.tests) {
- if (i.gateTagged === null) raise('gate', 'gate scan unavailable');
- else if (i.gateTagged > 0) raise('gate', `gate scan tagged ${i.gateTagged} hunk(s)`);
+ if (i.gateTagged === null) {
+ raise('gate', 'gate scan unavailable');
+ errors.push({ code: 'gate_scan_unavailable', detail: 'gstack-gate-diff reported no GATE_SUMMARY', input: 'gstack-gate-diff ', repair: 'run gstack-gate-diff and read its GATE_ERROR line, or pass the recorded scan as --gate-tagged ' });
+ } else if (i.gateTagged > 0) raise('gate', `gate scan tagged ${i.gateTagged} hunk(s)`);
const deletedGuarded = (i.deleted ?? []).filter(p => classifyPath(p, i.root) !== 'docs');
if (deletedGuarded.length) raise('gate', `deletes ${deletedGuarded.length} code or test path(s)`);
}
@@ -77,8 +124,38 @@ export function classifyDiff(i: ClassInput): ClassResult {
if (s.SCOPE_AUTH === 'true') raise('gate', 'auth paths');
if (s.SCOPE_MIGRATIONS === 'true') raise('gate', 'migrations');
for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; }
+ if (i.registry?.error) errors.push(i.registry.error);
}
- return { class: cls, reasons };
+ if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null };
+ let runtimeLines = 0;
+ for (const e of i.perPath) {
+ if (pathClass(i, e.path) !== 'code' || isOutput(i, e.path)) continue;
+ if (e.binary) errors.push({ code: 'binary_code_path', detail: 'git reports a binary change, so its lines cannot be counted', input: e.path, repair: "register it as an output of an entry with a command in the base branch's .gstack/generated.json, or accept the full review" });
+ else runtimeLines += e.lines;
+ }
+ return { class: cls, reasons, errors, runtimeLines };
+}
+
+/** Any classifier error or gate class is full; runtime above SMALL_DIFF_LINES is standard; otherwise light. A floor only raises. */
+export function depthFor(o: { cls: DiffClass; runtimeLines: number; errors: readonly DepthError[]; floor?: Depth }): Depth {
+ const d: Depth = o.errors.length || o.cls === 'gate' ? 'full' : o.runtimeLines > SMALL_DIFF_LINES ? 'standard' : 'light';
+ return o.floor && DEPTH_RANK[o.floor] > DEPTH_RANK[d] ? o.floor : d;
+}
+
+export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string }
+
+function decideDepth(c: ClassResult, runtimeLines: number, floor?: Depth): DepthResult {
+ const unfloored = depthFor({ cls: c.class, runtimeLines, errors: c.errors });
+ const reasons = [...c.errors.map(e => `error:${e.code}`), ...(c.class === 'gate' ? ['gate-class'] : []), `runtime=${runtimeLines}${runtimeLines > SMALL_DIFF_LINES ? '>' : '<='}${SMALL_DIFF_LINES}`];
+ if (floor && DEPTH_RANK[floor] > DEPTH_RANK[unfloored]) reasons.push(`floor:${floor}`);
+ const codes = [...new Set(c.errors.flatMap(e => (e.result ? [e.result] : [])))];
+ return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES };
+}
+
+/** The depth of a collected range (lib/review-collect.ts) plus a recorded gate-scan result: what the queue recomputes at a head. */
+export function depthOfCommitInput(input: ClassInput & { floor?: Depth }): DepthResult {
+ const c = classifyDiff(input);
+ return decideDepth(c, c.runtimeLines ?? 0, input.floor);
}
// ---------------------------------------------------------------------------
@@ -153,34 +230,48 @@ export interface PlanInput extends ClassInput {
force?: readonly string[] | 'all';
/** Specialists the hit-rate stats tag GATE_CANDIDATE. */
gated?: readonly string[];
- /** A specialist produced a CRITICAL finding (Red Team activation on code class). */
+ /** A specialist produced a CRITICAL finding (Red Team activation on code class; raises light to standard). */
criticalFound?: boolean;
/** Step 11's native adversarial pass already ran in this invocation. */
adversarialRan?: boolean;
/** The numeric-claim fingerprint the outside voice last reviewed; a change reruns it. */
reviewedNumbers?: string;
+ /** --depth-floor: raises the depth, never lowers it. */
+ floor?: Depth;
}
-export interface Dispatch { role: Role; name: string; effort: Effort }
-export interface ReviewPlan { class: DiffClass; reasons: string[]; next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[] }
+/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what light's batched specialist reads. */
+export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string }
+export interface ReviewPlan extends DepthResult {
+ /** The depth this step runs at: light raised to standard by a fixed blocker, a CRITICAL or an open blocker. */
+ stepDepth: Depth;
+ depthReasons: string[];
+ next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[];
+}
+
+const scopeWants: Partial) => boolean>> = {
+ performance: s => s.SCOPE_BACKEND === 'true' || s.SCOPE_FRONTEND === 'true',
+ 'data-migration': s => s.SCOPE_MIGRATIONS === 'true',
+ 'api-contract': s => s.SCOPE_API === 'true',
+ design: s => s.SCOPE_FRONTEND === 'true',
+ 'state-machines': s => s.SCOPE_STATE_MACHINE === 'true' || s.SCOPE_STATE_MACHINE === 'unknown' || s.SCOPE_STATE_MACHINE === undefined,
+};
+const forcedBy = (i: PlanInput) => (n: string) => i.force === 'all' || (Array.isArray(i.force) && i.force.includes(n));
/** The specialists a full pass selects for a class: class sets the eligible set, hit-rate gating applies inside it, gate class ignores gating. */
export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] = []): Specialist[] {
const s = i.scope ?? {};
- const forced = (n: string) => i.force === 'all' || (Array.isArray(i.force) && i.force.includes(n));
+ const forced = forcedBy(i);
if (cls === 'docs') { skipped.push('specialists (docs class)'); return SPECIALISTS.filter(forced); }
if (cls === 'tests') { skipped.push('specialists except testing (tests class)'); return SPECIALISTS.filter(n => n === 'testing' || forced(n)); }
const want = (n: Specialist): boolean => {
if (forced(n)) return true;
- if (n === 'state-machines') return s.SCOPE_STATE_MACHINE === 'true' || s.SCOPE_STATE_MACHINE === 'unknown' || s.SCOPE_STATE_MACHINE === undefined;
+ if (n === 'state-machines') return scopeWants[n]!(s);
if (i.diffLines < SMALL_DIFF_LINES) return false;
switch (n) {
case 'testing': case 'maintainability': return true;
case 'security': return s.SCOPE_AUTH === 'true' || (s.SCOPE_BACKEND === 'true' && i.diffLines > LARGE_DIFF_LINES);
- case 'performance': return s.SCOPE_BACKEND === 'true' || s.SCOPE_FRONTEND === 'true';
- case 'data-migration': return s.SCOPE_MIGRATIONS === 'true';
- case 'api-contract': return s.SCOPE_API === 'true';
- case 'design': return s.SCOPE_FRONTEND === 'true';
case 'simplification': return i.diffLines > LARGE_DIFF_LINES;
+ default: return scopeWants[n]!(s);
}
};
return SPECIALISTS.filter(n => {
@@ -190,34 +281,76 @@ export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] =
});
}
-/** The reviewers the next step dispatches, with each role's effort tier. */
+/** A path as one git exclude pathspec; a glob character or a leading colon makes it literal. */
+const excludeSpec = (p: string) => `:(exclude${/[*?[\]\\]/.test(p) || p.startsWith(':') ? ',literal' : ''})${p}`;
+const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
+
+/** The diff command a dispatched reviewer runs; `` is the literal sha the parent writes in. */
+export function diffCommand(d: Dispatch): string {
+ return d.exclude.length ? `git diff -- . ${d.exclude.map(shellQuote).join(' ')}` : 'git diff ';
+}
+
+/** The reviewers the next step dispatches at its depth, with each role's effort tier and excluded pathspecs. */
export function planReview(i: PlanInput): ReviewPlan {
- const { class: cls, reasons } = classifyDiff(i);
+ const c = classifyDiff(i);
+ const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor);
+ const { class: cls, runtimeLines } = depth;
const next = nextStep(i.history);
+ const h = i.history;
+ const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound);
+ const stepDepth: Depth = raise ? 'standard' : depth.depth;
+ const depthReasons = [...depth.reasons, ...(raise ? [`history:${h.fixedBlocking ? 'fixed-blocking' : h.blockingOpen ? 'blocking-open' : 'critical-found'}`] : [])];
const skipped: string[] = [];
const dispatches: Dispatch[] = [];
+ const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial = {}) => dispatches.push({ role, name, effort, exclude, ...extra });
+ const docsAndOutputs = i.paths.filter(p => pathClass(i, p) === 'docs' || isOutput(i, p)).map(excludeSpec);
+ const tests = i.paths.filter(p => pathClass(i, p) === 'tests' && !isOutput(i, p)).map(excludeSpec);
const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint;
- if (next.step === 'full') {
- for (const n of specialistsFor(cls, i, skipped)) dispatches.push({ role: 'specialist', name: n, effort: 'full' });
- const redTeam = cls === 'gate' || (cls === 'code' && (i.diffLines > RED_TEAM_LINES || !!i.criticalFound));
- if (redTeam) dispatches.push({ role: 'red-team', name: 'red-team', effort: 'full' });
- else skipped.push(`red-team (${cls} class${cls === 'code' ? `, ${i.diffLines} lines, no critical` : ''})`);
- if (i.history.fullPasses === 0 || (!i.adversarialRan && (cls === 'code' || cls === 'gate'))) {
- if (cls === 'docs' || cls === 'tests') skipped.push(`adversarial-native (${cls} class)`);
- else dispatches.push({ role: 'adversarial-native', name: 'adversarial', effort: 'full' });
- if (cls === 'code' || cls === 'gate' || i.numbers.publishes) dispatches.push({ role: 'outside-voice', name: 'outside-voice', effort: 'host' });
- else skipped.push(`outside-voice (${cls} class, no numeric claims)`);
+ const firstVoices = h.fullPasses === 0 || !i.adversarialRan;
+ if (next.step === 'full' && stepDepth === 'light') {
+ const forced = forcedBy(i);
+ if (runtimeLines > 0) {
+ const scoped = SPECIALISTS.filter(n => !LIGHT_FIXED.includes(n) && (forced(n) || (scopeWants[n]?.(i.scope ?? {}) && !(i.gated ?? []).includes(n))));
+ add('specialist', 'batched', 'full', docsAndOutputs, { checklists: [...LIGHT_FIXED, ...scoped, 'red-team'] });
+ skipped.push('red-team (light depth: the batched specialist carries the Red Team prompt)');
+ if (firstVoices) add('adversarial-native', 'adversarial', 'full', [], { reason: LIGHT_ADVERSARIAL_REASON });
+ } else {
+ for (const n of SPECIALISTS.filter(forced)) add('specialist', n, 'full');
+ skipped.push('specialists, red-team and adversarial-native (light depth, runtime 0)');
+ }
+ if (h.fullPasses === 0 && i.numbers.publishes) add('outside-voice', 'outside-voice', 'host');
+ else if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host');
+ else skipped.push(`outside-voice (${cls} class, no numeric claims)`);
+ } else if (next.step === 'full') {
+ const full = stepDepth === 'full';
+ const dcls: DiffClass = full ? 'gate' : cls;
+ const raised = !full && runtimeLines <= SMALL_DIFF_LINES;
+ const lines = full ? i.diffLines : Math.max(runtimeLines, raised ? SMALL_DIFF_LINES : 0);
+ for (const n of specialistsFor(dcls, { ...i, diffLines: lines }, skipped)) add('specialist', n, 'full', full ? [] : n === 'testing' ? docsAndOutputs : [...docsAndOutputs, ...tests]);
+ const redTeam = dcls === 'gate' || (dcls === 'code' && (lines > RED_TEAM_LINES || !!i.criticalFound || raised));
+ if (redTeam) add('red-team', 'red-team', 'full');
+ else skipped.push(`red-team (${dcls} class${dcls === 'code' ? `, ${lines} runtime lines, no critical` : ''})`);
+ const codeLike = dcls === 'code' || dcls === 'gate';
+ if (h.fullPasses === 0 || (!i.adversarialRan && codeLike)) {
+ if (codeLike) add('adversarial-native', 'adversarial', 'full');
+ else skipped.push(`adversarial-native (${dcls} class)`);
+ if (codeLike || i.numbers.publishes) add('outside-voice', 'outside-voice', 'host');
+ else skipped.push(`outside-voice (${dcls} class, no numeric claims)`);
} else {
skipped.push('adversarial-native and outside-voice (run once per invocation)');
- if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' });
+ if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host');
}
} else if (next.step === 'fix-check') {
- dispatches.push({ role: 'fix-check', name: 'fix-check', effort: 'full' });
- if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' });
+ add('fix-check', 'fix-check', 'full');
+ if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host');
}
- return { class: cls, reasons, next, numbers: i.numbers, dispatches, skipped };
+ return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped };
}
export function reviewPlanLine(p: ReviewPlan, pass: number): string {
return `REVIEW_PLAN: class=${p.class} pass=${pass} next=${p.next.step} dispatches=${p.dispatches.length} numbers=${p.numbers.publishes ? `yes(${p.numbers.claims})` : 'no'} fingerprint=${p.numbers.fingerprint}`;
}
+
+export function reviewDepthLine(p: ReviewPlan): string {
+ return `REVIEW_DEPTH: ${p.depth} runtime_lines=${p.runtimeLines} rules=${p.rules} reasons=${p.depthReasons.join(',')} codes=${p.codes.join(',') || 'none'}${p.stepDepth !== p.depth ? ` step=${p.stepDepth}` : ''}`;
+}
diff --git a/lib/sealed-guard.ts b/lib/sealed-guard.ts
index e10c08a6a..6dd1572fa 100644
--- a/lib/sealed-guard.ts
+++ b/lib/sealed-guard.ts
@@ -9,6 +9,8 @@
* answer_markers regexes for a quoted sealed answer in added text
* seed_patterns regexes for a sampling seed in added text
* local_paths true: a local home path (/home//, /Users//) in added text
+ * allow_paths globs exempt from the path rules (read from the base branch only; never folds case)
+ * case_insensitive true: `paths` and `transcripts` match regardless of case
* visibility "public" | "private": the destination override (reviewed on the base branch)
*
* The publication boundary is every commit the push publishes: the local sha
@@ -18,8 +20,9 @@
* copies, independent of content (binary, empty, add-then-delete); text rules
* see each commit's added lines. The effective policy is the union of the
* destination base branch's `sealed` and the pushed one (a push can tighten,
- * never relax); waivers come only from the base branch's
- * `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
+ * never relax); `allow_paths` exceptions come only from the base branch's
+ * policy and remove path findings before waivers, which come only from the
+ * base branch's `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
* closed). Visibility: the base override, else a cache under the state root
* (a `public` answer for 24 h, a `private` one for 1 h before it may
* downgrade a refusal), else `gh repo view` (5 s); unknown counts as public.
@@ -28,19 +31,20 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
-import { containedRelative, globToRegExp, matchesAny } from './ship-policy';
+import { containedRelative, globToRegExp } from './ship-policy';
import { resolveStateRoot } from './state-root';
export const SEALED_CONFIG_PATH = '.gstack/pregate.json';
export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv';
export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility';
export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path';
-export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; visibility?: 'public' | 'private' }
+export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; visibility?: 'public' | 'private' }
export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string }
+export type ExemptFinding = SealedFinding & { glob: string };
export interface AllowEntry { rule: string; path: string; sha: string; reason: string }
export type Visibility = 'public' | 'private' | 'unknown';
-const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'visibility'];
+const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility'];
const MAX_ENTRIES = 64;
const MAX_REGEX = 256;
const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/;
@@ -50,9 +54,9 @@ const PRIVATE_TTL_MS = 3600_000;
/** `: ` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */
export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> {
const errors: Array<{ path: string; message: string }> = [];
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: 'must be an object { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }' }];
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: `must be an object { ${KEYS.join(', ')} }` }];
const o = raw as Record;
- for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')}` });
+ for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (if the key comes from a newer gstack, upgrade gstack on this machine)` });
const list = (key: string, check: (s: string) => string | null) => {
if (!(key in o)) return;
const v = o[key];
@@ -62,8 +66,9 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin
};
const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } };
const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } };
- list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex);
- if ('local_paths' in o && typeof o.local_paths !== 'boolean') errors.push({ path: 'sealed.local_paths', message: 'must be true or false' });
+ const exception = (g: string) => { const name = g.split('/').pop()!; return glob(g) ?? (name.includes('**') || /^[*?]+(\.|$)/.test(name) ? 'has a wildcard file name, which would exempt whole directories or file types; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); };
+ list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception);
+ for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, message: 'must be true or false' });
if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' });
return errors;
}
@@ -88,15 +93,15 @@ export function sealedPolicyAt(repoRoot: string, treeish: string, label: string)
const errors = sealedErrors(raw.sealed);
if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label };
const s = raw.sealed;
- return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, visibility: s.visibility }, label };
+ return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, visibility: s.visibility }, label };
}
-/** Base ∪ pushed: lists concatenate, `local_paths` ORs; visibility stays private only when the base says so and the push does not say public. */
+/** Base ∪ pushed: deny lists concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; visibility stays private only when the base says so and the push does not say public. */
export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null {
if (!base && !pushed) return null;
const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])];
const visibility = pushed?.visibility === 'public' || base?.visibility === 'public' ? 'public' : base?.visibility === 'private' ? 'private' : undefined;
- return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), visibility };
+ return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), visibility };
}
/** Reviewed waivers: `rulepathshareason` lines in the base branch's allow file. */
@@ -126,12 +131,20 @@ export function rangeCommits(repoRoot: string, range: string): { commits: string
const HEX = /^[0-9a-f]{40,64}$/;
+/** The first glob matching a repo-relative path, compiled once per list (`matchesAny` normalization); null when none does. */
+function globMatcher(globs: string[], ignoreCase: boolean): (file: string) => string | null {
+ const compiled = globs.map(g => { const re = globToRegExp(g); return { g, re: ignoreCase ? new RegExp(re.source, 'i') : re }; });
+ return file => { const norm = file.replace(/\\/g, '/').replace(/^\.\//, ''); return compiled.find(c => c.re.test(norm))?.g ?? null; };
+}
+
/** Path rules over every added, renamed or copied path, per commit (merges: paths new against every parent). */
function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] {
if (!p.paths.length && !p.transcripts.length) return [];
const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '--name-status', '-z'], commits.join('\n') + '\n');
if (!r.ok) throw new Error(`git diff-tree failed: ${r.err.slice(0, 200)}`);
const out: SealedFinding[] = [];
+ const transcript = globMatcher(p.transcripts, p.case_insensitive);
+ const sealedPath = globMatcher(p.paths, p.case_insensitive);
const tokens = r.out.split('\0');
let commit = '';
for (let i = 0; i < tokens.length; i++) {
@@ -141,8 +154,8 @@ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea
const renamed = /^[RC]\d*$/.test(t);
const file = renamed ? tokens[i += 2]! : tokens[++i]!;
if (!renamed && !t.includes('A')) continue;
- if (matchesAny(p.transcripts, file)) out.push({ rule: 'sealed.transcript', file, commit });
- else if (matchesAny(p.paths, file)) out.push({ rule: 'sealed.path', file, commit });
+ if (transcript(file)) out.push({ rule: 'sealed.transcript', file, commit });
+ else if (sealedPath(file)) out.push({ rule: 'sealed.path', file, commit });
}
return out;
}
@@ -216,7 +229,7 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
export interface SealedReport {
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
- policyLabels: string[]; commits: number; findings: SealedFinding[]; waived: Array;
+ policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array;
visibility: Visibility; visibilitySource: string; target: string; error?: string;
}
@@ -225,9 +238,14 @@ export interface EvaluateOptions {
env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh'];
}
+/** A report for a failure before any policy is read (an unlisted range, an unreadable push); renders as a fail-closed refusal. */
+export function sealedErrorReport(error: string, target: string): SealedReport {
+ return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], visibility: 'unknown', visibilitySource: '', target };
+}
+
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
export function evaluateSealed(o: EvaluateOptions): SealedReport {
- const blank = { policyLabels: [] as string[], commits: 0, findings: [] as SealedFinding[], waived: [] as Array, visibility: 'unknown' as Visibility, visibilitySource: '', target: o.target };
+ const blank = { ...sealedErrorReport('', o.target), error: undefined };
const base = o.baseRef ? sealedPolicyAt(o.repoRoot, o.baseRef, o.baseLabel ?? o.baseRef) : { policy: null, label: '(no destination base branch)' };
if ('error' in base) return { ...blank, status: 'error', error: `the sealed policy on ${base.label} is unreadable: ${base.error}` };
let policy: SealedPolicy | null = base.policy;
@@ -243,6 +261,13 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
let found: SealedFinding[];
try { found = scanCommits(o.repoRoot, commits, policy); }
catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, status: 'error', error: `could not read the published commits: ${e.message}` }; }
+ const exception = globMatcher(policy.allow_paths, false);
+ const exempt: ExemptFinding[] = [];
+ found = found.filter(f => {
+ const glob = f.rule === 'sealed.path' || f.rule === 'sealed.transcript' ? exception(f.file) : null;
+ if (glob) exempt.push({ ...f, glob });
+ return !glob;
+ });
const allow = allowEntriesAt(o.repoRoot, o.baseRef);
const waived: SealedReport['waived'] = [];
const findings = found.filter(f => {
@@ -252,7 +277,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
});
const vis = policy.visibility ? { visibility: policy.visibility as Visibility, source: `${VISIBILITY_OVERRIDE_KEY} override` } : resolveVisibility(o.remoteUrl, { env: o.env, gh: o.gh });
const status = findings.length === 0 ? 'clear' : vis.visibility === 'private' ? 'warned' : 'refused';
- return { status, policyLabels: labels, commits: commits.length, findings, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
+ return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
}
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`;
@@ -260,6 +285,7 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:$
/** stderr text for a report: findings one per line (` commit `), never the matched text. */
export function renderSealed(r: SealedReport, context: string): string {
const lines: string[] = [];
+ for (const e of r.exempt) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`);
for (const w of r.waived) lines.push(`${context}: waived ${w.rule} ${where(w)} commit ${w.commit.slice(0, 12)} (${SEALED_ALLOW_PATH}: ${w.reason})`);
const vis = `visibility: ${r.visibility}${r.visibility === 'unknown' ? ', counted public' : ''}; ${r.visibilitySource}`;
const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}`);
@@ -268,7 +294,7 @@ export function renderSealed(r: SealedReport, context: string): string {
lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'refused') {
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body);
- lines.push(`Remove it from every published commit before this branch reaches a public repository, or waive one reviewed finding with a \`rulepathshareason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
+ lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rulepathshareason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'warned') {
lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body);
}
diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts
index e44c46a5b..5485f9b20 100644
--- a/lib/ship-queue-train.ts
+++ b/lib/ship-queue-train.ts
@@ -22,7 +22,7 @@ import { baseOverlapLine } from './pr-wait';
import { versionAtRef, versionFiles } from './restamp';
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
import { loadBasePolicy, type QueueContext } from './ship-queue';
-import { bindReceipt } from './ship-receipt';
+import { bindShippedReceipt } from './ship-receipt-depth';
import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate';
import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen';
import { stackTestLine, stackTests } from './ship-queue-stack-tests';
@@ -351,8 +351,8 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dry
if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) {
return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs `, 'none');
}
- const shipped = bindReceipt(info.body, p.pr, [info.headRefOid]);
- if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none');
+ const shipped = bindShippedReceipt(ctx.repoRoot, `refs/remotes/${env.remote}/${ctx.base}`, info.body, p.pr, [info.headRefOid]);
+ if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none');
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
let screen = ready.screen;
diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts
index 166a9a24b..e4588ea13 100644
--- a/lib/ship-queue.ts
+++ b/lib/ship-queue.ts
@@ -31,7 +31,8 @@ import type { PregateFile } from './headless-artifacts';
import { CHECKS, pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
import type { ResultCodeName } from './result-codes';
-import { bindReceipt, extractReceipt } from './ship-receipt';
+import { extractReceipt } from './ship-receipt';
+import { bindShippedReceipt } from './ship-receipt-depth';
import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy';
import {
QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic,
@@ -254,7 +255,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from the state root's pregate/by-tree/${treeNow.tree.slice(0, 12)}.json, recorded by another checkout)`;
} else if (gap) pregateLine += `; the state root's mirror for this tree cannot stand in: ${gap}`;
}
- const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
+ const receipt = bindShippedReceipt(ctx.repoRoot, `origin/${ctx.base}`, info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
const attempt = currentAttempt(ctx.repoRoot, o.pr);
const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head);
const lines = [
@@ -268,6 +269,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
pregateLine,
gateLine(policy, attempt),
receipt.line,
+ ...(receipt.depthLine ? [receipt.depthLine] : []),
];
const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null;
if (gate) lines.push(gate.line);
diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts
new file mode 100644
index 000000000..8cff3b954
--- /dev/null
+++ b/lib/ship-receipt-depth.ts
@@ -0,0 +1,164 @@
+/**
+ * ship-receipt-depth — what the /ship receipt says about how deeply the
+ * review read the head, and what the queue checks against it
+ * (docs/review-policy.md#review-depth, docs/ship-policy.md#ship-receipt).
+ *
+ * `review_depth`: the depth the reviewed head needs, recomputed by
+ * `gstack-ship-receipt write` (receiptDepthAtHead: the committed range from
+ * git objects, gstack-diff-scope on that range and the gate scan of the clean
+ * checkout at that head), with the runtime lines, the rules hash, the base and
+ * registry commits and the gate scan bound to the head.
+ * `review_loop_exception`: the owner's acceptance of a stop after a
+ * fix-check edit under frozen scope (#3559), shaped like the docs exception.
+ * The queue recomputes the depth the head needs from git objects
+ * (lib/review-collect.ts) and refuses a receipt below it. Kept beside
+ * lib/ship-receipt.ts so that module stays under the size ratchet.
+ */
+import { spawnSync } from 'node:child_process';
+import * as path from 'node:path';
+import type { ShipReceipt } from './headless-artifacts';
+import { collectCommit } from './review-collect';
+import { classifyPath, depthOfCommitInput, type Depth } from './review-depth';
+import { runScriptSync } from './script-invocation';
+import { bindReceipt, type ReceiptBindingCode } from './ship-receipt';
+import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
+
+export type ReceiptDepth = NonNullable;
+export type ReviewLoopException = NonNullable