diff --git a/CHANGELOG.md b/CHANGELOG.md
index 574a8987b..9073884cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,160 @@
# Changelog
+## [1.93.36.0] - 2026-11-03
+
+- **/autoplan R65 defaults: no review for bug fixes, one lean review for features.** `gstack-autoplan next --kind fix` prints `AUTOPLAN: not needed (bug fix; R65)` and `status=skipped run=-` and writes nothing; the PR still needs the bug's proof. `--kind feature`, now the default for an unattended Capy run, runs the eng phase alone with one native reviewer whose brief merges the CEO and Eng lenses (Design and DX only when scope detects them), reads the draft code (a diff against `--base`, hashed into the snapshot, or an honest `DRAFT: none`), always asks "Any outside terms, licences or rights involved?", and has no outside voice, one round, one gate revision and no carry check. The reviewer's result must name the draft hash on its `DRAFT:` line. A failed git command reads `DRAFT: none (draft capture failed: …)`, untracked files named like secrets stay out of the draft, the `DRAFT:` line names the checkout it diffed, and a draft over 100,000 bytes is still inlined, cut there and marked. `run` stays the full run unless `--kind feature` asks for the lean one, `run --kind fix` skips like `next`, and `--kind fix --json` prints one skipped object. `--full` brings back every phase and voice; off Capy and in interactive sessions nothing changes. The gate cost line and the owner brief say a lean `rec` reopens nothing.
+
+## [1.93.35.0] - 2026-11-03
+
+**pregate no longer kills a quiet test suite that is busy working.**
+
+### Fixed
+- **`lib/pregate/supervise.ts`**: the stall timeout now needs both no log output and an idle process group. Each second, supervise reads the group's CPU time (`/proc` on Linux, `ps -S` on macOS and BSDs). If it grew by at least a tenth of the wall time, the group counts as alive. Before, a parallel `check.sh` that printed nothing for 9 minutes while working ended `PREGATE_STALLED` at the 300 s default (Founderstar FOUN-63). A hang that waits on nothing still stalls. A hang that keeps using CPU now runs to `--timeout`, and either way the check is incomplete, never a pass. Windows reads no CPU time and keeps log growth alone. The `PREGATE_STALLED` detail, the guard line and the docs say what was measured. Both new tests fail on main. There a silent CPU-bound command ends `stalled` and a busy lane test ends `PREGATE_STALLED`.
+
+## [1.93.34.0] - 2026-11-03
+
+**The docsync fault-callback replays and the runShadow deadline test stop failing on loaded remote pregate VMs.**
+
+### Fixed
+- **Docsync fault-callback replays** (`test/helpers/docsync-fault-callbacks.ts` and its three wrappers): each replay in the generated child now has a 30 s limit instead of bun's 5 s default, and each wrapper's limit is that per-replay limit times its replay count plus one. A replay spawns 30 to 40 real actor, hook and git processes. That takes about 1.3 s on CI and up to 4.9 s on an idle 4-core machine, and it ran past 5 s on GSTA-234's remote pregate for #3892. With 8 busy loops on 4 cores, main fails all 12 legacy-control replays at 5 s, and this branch passes all 12 (172 s for the whole wrapper). `test/docsync-report-interface.test.ts` gets the same limits, because its child cases ran 5.5 s and 11.4 s on #3869's remote pregate.
+- **`test/autoplan-shadow.test.ts` "one deadline covers the call and its retry"**: the first call now has 1 s of room inside the deadline instead of 300 ms. The time limit allows 2.4 s past the deadline before it would match an unshared retry, up from 0.9 s; the remote failure ran 1.06 s past. The test still requires the retry to be cut (p1 malformed) and the call to end before an unshared retry could finish.
+
+## [1.93.33.0] - 2026-11-03
+
+- On a checkout whose `origin/HEAD` points at the checked-out branch itself (a PR-branch clone), every base-relative step resolved the base to HEAD and reported zero changes, so review depth, `ready`, pregate and the docs audit passed on an empty diff (GST-774). Base resolution now skips a remote HEAD that names the checked-out branch, its upstream or HEAD's own commit (so a local branch under another name and a detached checkout of the PR branch are caught too), then uses the PR's base ref when `gh` finds one, then the remote's live default branch from `git ls-remote --symref`, then `main` or `master`. A repository whose default branch is `develop` or `trunk` keeps it. When the resolved base equals HEAD or contains it while the branch has commits its real base lacks, `gstack-diff-scope`, `gstack-gate-diff`, `gstack-review-plan`, `gstack-pregate`, `gstack-docs-candidate`, `/ship` policy base resolution and the receipt's depth binding refuse with `BASE_CONTAINS_HEAD` instead of reporting no changes. A pull request's head is refused whenever the base contains it; on the base branch itself, on a fresh branch with only uncommitted work, and on a detached HEAD given an explicit base, the step runs as before. `gstack-ship-queue land` resuming a merge-only PR that GitHub already merged with a true merge commit still lands, its depth check included. See docs/troubleshooting.md#base-contains-head.
+
+**The /ship receipt now carries the targeted reader's record.** When the review plan asked for the targeted reader on money, gate, auth or secret code and the reader ran, `gstack-ship-queue ready` still printed `REVIEW_TARGETED: … receipt=legacy verdict=missing` (gstack-evals#95, #3682, #3612), because nothing passed the record from Step 11 to the receipt. Enforcement stays report-only (GST-464).
+
+**A stale base no longer stands in for the PR's base (GST-462).** `gstack-review-plan --base main` read a local `main` that was behind `origin/main`, so commits the base already had counted as the branch's own change. #3597 was labeled gate code at full depth until it passed `--base origin/main`, and gbrain hit the same problem with a stale local `master` (GBRA-218). On harness-evals#152, a stale base made /ship review a 17,000-file diff of main's history instead of the PR (HARN-15).
+
+### Fixed
+- **Step 11** saves the targeted record to `.gstack/tmp/targeted.json` (`gstack-review-dispatch verify --targeted-out`), and the Step 19 and handoff `gstack-ship-receipt write` commands pass it as `--review-targeted` whenever that file exists. Every Step 9 entry creates `.gstack/tmp`, adds it to `.git/info/exclude` and deletes the file. So a pass without the reader leaves the receipt legacy, an earlier pass's record can't carry over, and the record never counts as an uncommitted edit.
+- **`gstack-ship-preflight`** fetches the base into `origin/` with an explicit refspec, so a single-branch clone also diffs against the current base tip. When a PR is open at HEAD and the diff lists far more files than the PR changes (more than twice as many, plus 50), or fewer, it stops before any reader runs. Fewer files means origin's base is ahead of the PR's, as in a fork, and the gate scan would miss commits the PR lands. The stop names the PR's base, or the fork case when the two names match.
+- **`gstack-gate-diff` and `gstack-diff-scope`** compare against origin's tracking branch whenever it shares history with HEAD, the same base `gstack-review-plan`'s path list reads. A local `main` behind it counted commits the base already had; one ahead of or diverged from it left out commits the branch lands. gate-diff says which (`local main is ahead of origin/main; base is origin/main`). This applies whether you name the base or let the tool detect the default. A local branch stays the base only when origin's is missing or unrelated. Tracking branches are read by their full names (`refs/remotes/origin/main`), so while that branch exists, a tag or a fetched branch called `origin/main` can't stand in, whether you pass `main`, `origin/main` or nothing. A pinned sha is never redirected. `gstack-review-plan` reads both, so its gate scan and scope now match the merge base it already took from `origin/`.
+
+### Tests
+- `test/review-targeted.test.ts`: the generated sections' own commands carry the record from Step 11's verify to `gstack-ship-receipt write`, and ready's line reads `verdict=ok`. A run without the reader passes no flag, and Step 9 clears the file, even when `.git/info` is missing or the exclude file has no final newline.
+- `test/ship-preflight.test.ts`: a single-branch clone's preflight refreshes `origin/main`, and a 60-file diff against a 3-file or a 61-file PR stops at the pushed head. The stop quotes an unsafe base name and names the fork case. Negative controls: another head, a matching size, or a zero count goes on.
+- `test/review-plan-cli.test.ts`, `test/gate-diff-cli.test.ts`, `test/diff-scope.test.ts`: a local `main` behind, ahead of or diverged from `origin/main` yields to it, so an unpushed `test.skip` on local `main` is scanned. One next to an unrelated `origin/main` stays the base. A tag or fetched branch named `origin/main` never stands in, and a pinned sha and `HEAD` are never redirected. The review plan of a docs-only branch that merged the newer `origin/main` stays docs; before the fix, the gate scan counted origin's skipped test.
+- `test/review-packets-removed.test.ts`: the two ship section pins move under replay GST-464, a brief-identity check. Every reviewer brief `lib/review-brief.ts` cuts from those sections must stay byte-identical to the no-loss-tested bodies; a changed brief fails it.
+
+**/ship can leave a results PR's receipt files out of the review, and it stops instead of handing a reader a diff it cannot read.** A results PR in harness-evals or gbrain's eval lanes can carry thousands of receipt files. One HARN-1 results PR spent its outside-voice budget reading receipts and the pass died anyway (GST-507, GST-508). The repository owner can now list those folders in `.gstack/ship-policy.json`, and every reviewer reads the code without them.
+
+### Added
+- **`review_exclude_paths` in `.gstack/ship-policy.json`** (`["receipts/**"]`, with `min_gstack_version` at least the release that adds it). It is read from the base branch only, so a branch cannot exclude its own files; a branch that adds the key gets a normal review and `REVIEW_EXCLUDE: none (head sets review_exclude_paths; …)`. Patterns are git glob pathspecs, root-anchored, and git itself lists the matches.
+- **Only content is excluded**: data files, docs, `.log` files and files whose bytes are binary and whose extension is not source or script (a branch's `.gitattributes` cannot make a text file count as binary, and a NUL byte cannot hide a `.js` file). Untracked files are never part of the selection. Code, tests, executables, symlinks, submodule commits, gate paths, agent prose, config, `.gstack/` and `.github/` files, gate-tagged files and files with a credential line stay in every reader's diff, each with a reason code. A data file a code or test file names by its full path stays too, and when the gate scan fails nothing is excluded. A basename, a directory reference or a data manifest that lists receipts does not keep anything.
+- **`gstack-review-plan` prints `REVIEW_EXCLUDE:`** with the counts, the excluded files by kind, the kept files and a digest. Every reader's `DIFF_CMD:`, the fixture command the security specialist and the Red Team use, and the outside voice's diff leave the files out; kept files come back through `DIFF_KEEP:`. Every brief says what was left out. The kept-file command is bounded at 24,000 argument bytes, and a pattern over that bound is not applied and the plan says why.
+- **Excluded lines no longer trigger the numeric-claim scan or the targeted money, gate and auth classes**, and they count as docs for the review depth.
+- **The receipt records `review_exclude`.** `gstack-review-dispatch verify` prints `REVIEW_EXCLUDE_RECORD: digest=` from the briefs the readers got, and `gstack-ship-receipt write --review-exclude ` must match the digest HEAD has under the base policy (`RECEIPT_INVALID field=review_exclude`, nothing written). Carry and the queue compare the digest, not the base commit, so a base merge that leaves the policy alone still carries.
+- **A review ceiling.** `gstack-review-dispatch pass`, `stage` and `render` read each reader's diff once before writing anything. Over 2,000,000 bytes (about 500K tokens) is `REVIEW_DIFF_TOO_LARGE` (exit 3) with a fix for where the policy stands and pasteable JSON; a `git diff` that fails or runs past 120 seconds is `REVIEW_DIFF_UNREADABLE` (exit 1) with the reason, the timeout and a redacted stderr log.
+
+### Changed
+- A repository without the key plans, briefs and diffs byte for byte as before. The one change it can see is the ceiling. A reader's diff over 2,000,000 bytes now stops the pass instead of being read in part.
+
+### Tests
+- `test/review-exclude.test.ts`: what is excluded and kept (binary files under `config/`, a file named only by a data manifest or by its basename, a file a test names by full path), every brief's diff commands run against git, the numeric scan and targeted classes, the base policy read (no file, a missing blob, a bad commit, an invalid key), the argument-byte bound, both ceiling lines, and the receipt write, carry and queue checks.
+
+**The review planner no longer fails closed on repositories with big data trees just because its consumer search ran past 60 seconds (GST-544).** `gstack-review-plan` looks for code that names each changed data file. It used to run one `git grep` over the whole tree with every data file name, and each call had a hard 60-second limit. In garrytan/harness-evals, with 8,000+ result files, that search took about 150 seconds, so the plan fell back to a full review every time.
+
+### Changed
+- **The consumer scan searches only files that can match** (`lib/review-collect.ts`). A cheap first pass lists the files that contain a changed file's extension, such as `.json`; the full search then reads only those files, a batch of paths per `git grep` call, with docs files left out. The result is the same as before.
+- **One deadline per scan:** 120 seconds by default, a minute under the 180 seconds /ship gives the whole planner, set in milliseconds with `GSTACK_CONSUMER_SCAN_TIMEOUT_MS` (docs/review-policy.md#review-depth). Past it, or past 256 MB of matches across all the calls, the depth still fails closed with `consumer_scan_failed`. The detail now says whether git timed out, exited with an error or could not start, and names the deadline. Other git reads keep their 60-second limit.
+
+### Tests
+- `test/review-collect.test.ts`: the narrowed search prints the same lines as one whole-tree `git grep` at the head, the merge base and the working tree; the same holds when every file is its own call; consumed sets on a mixed fixture (a chain through a data manifest, an untracked consumer, docs mentions ignored); a tiny deadline fails closed and says it timed out; the default deadline stays a minute under the planner timeout in `gstack-review-dispatch` and `gstack-ship-preflight`. The new behavior tests failed before the fix.
+
+**/review now names the kind of PR it is reviewing, so a CI or script change gets checks that fit it instead of a page of "not applicable".** The review checklist was written for application code. A PR that only changed CI or shell scripts came back with every category marked not applicable, which looked thorough but checked nothing, and nothing asked whether a new script even fit the repo (#356: a Windows `.bat` build script added to a bash + Makefile repo with Linux-only CI).
+
+### Changed
+- **`review/checklist.md` starts with a PR type taken from the changed paths:** docs-only, CI/infra, scripts/tooling, application or mixed. Code under `bin/`, `scripts/` or `tools/` that is not shell, batch or PowerShell, package manifests and lockfiles, prompt templates (`*.tmpl`) and skill Markdown count as application, and so does any path the reviewer can't place. Application and mixed diffs run every category exactly as before; nothing is skipped.
+- **On a CI/infra, scripts/tooling or docs-only diff,** Gate Integrity, Shell Injection, Completeness Gaps, Distribution & CI/CD Pipeline and the new Repo Conventions & Platform Fit category always run. Every other category with nothing to check is reported once, in one line (`Not applicable (PR type: ): `), and never counts as passed.
+- **A new Repo Conventions & Platform Fit category** checks that new files use the languages, tools and naming the repo already uses for the same job, scripts run on the platforms the CI runners and docs target, and new tooling doesn't repeat an existing tool. A finding has to name its concrete consequence.
+- **Distribution & CI/CD Pipeline gains script checks:** strict mode (`set -euo pipefail` or the repo's equivalent) in scripts CI or a build relies on, quoted variables, commands that work on the CI runners' OS, third-party actions pinned to a release tag or commit SHA, and tools installed at a pinned version. These checks apply to test-only CI changes too.
+
+### Tests
+- `test/review-checklist-pr-type.test.ts` builds a classifier from the patterns the checklist itself lists and runs real path sets through it, among them a `.tmpl` change, `bin/` TypeScript, an extensionless `bin/` executable, `package.json` + `bun.lock`, a workflow-only change, a `.bat` add, docs, and mixed changes. It also checks the five types, the always-run list, the one-line not-applicable report, the new category and the line budget. All 19 tests fail on the previous checklist.
+
+**/review and /ship now triage comments from every review bot you configured, not only Greptile.** Teams on CodeRabbit, Macroscope or another bot got no triage at all: Step 2.5 and Step 10 read only comments whose author was exactly `greptile-apps[bot]` (#3122). The bot list is the one gstack-pr-wait already uses, so `gstack-config set pr_wait_gating_bots coderabbit` makes CodeRabbit the bot gstack waits on and triages, and `none` in both lists turns triage off.
+
+### Changed
+- **`gstack-review-bots fetch`** reads every root line comment, conversation comment and the newest review body of each bot in `pr_wait_gating_bots` and `pr_wait_advisory_bots`, across all pages, through `gh api` (no `--slurp`, so the gh 2.45 that Ubuntu 24.04 packages works too). Only Bot accounts count, with gstack-pr-wait's login rule (a bot owns the logins that start with its name), so a person named `greptile-ci` is never triaged. It prints `REVIEW_BOTS: skip` (no PR, no `gh`, no GitHub remote, or both lists `none`), `REVIEW_BOTS: error` (a lookup or any page failed, so never a false zero) or a private directory with per-bot counts.
+- **False-positive suppressions match only the same bot.** New triage outcomes go to `review-bot-history.md` with a sixth `bot` column through `gstack-review-bots record --dir --id `. The helper reads the bot and file path from the fetched comment, so a contributor-named file never reaches a shell command, and a path with `*` is refused so it can't become a wildcard suppression. Rows in the old `greptile-history.md` still count, as Greptile's. Nothing is migrated or deleted.
+- **The PR body section is `## Review bot comments`** with a count per bot, and retro shows one signal row per bot (`REVIEW_BOT_HISTORY` beside `GREPTILE_HISTORY`).
+- **Greptile's early PR (Step 6.5) runs only when `greptile` is in one of the two lists**, and detection now needs a Greptile Bot account instead of any login matching `/greptile/i`. Tier 2 replies and re-ranking stay Greptile-only; other bots get the plain evidence reply when you pick it.
+- **/ship's driver routes Step 10** to the bot triage section whenever a PR exists, instead of running it only when Greptile is detected.
+- **docs/skills.md** has a "Choosing your bots" section.
+
+### Tests
+- `test/review-bots.test.ts` (fake `gh` runner): page-two comments, a CodeRabbit review body, one item from a root comment plus replies, human lookalikes and deleted accounts left out, overlapping bot names, an error on any page giving no directory, lookup failures giving `error`, an empty PR giving zero counts, cross-bot suppression isolation, old greptile-history rows suppressing Greptile only, and `record` refusing hostile paths, colliding ids and unknown categories.
+- `test/greptile-early.test.ts`: early detection is off when `greptile` is in neither list, and a human account named like Greptile is no signal.
+
+## [1.93.32.0] - 2026-11-03
+
+- **`bin/dev-setup` works in a fresh clone again.** It installed dependencies, then setup refused with "project-local Codex destination overlaps the source tree" and exited 1, because setup's overlap guard read gstack's own `.agents/skills` as a foreign project's skills directory pointing into the checkout. Setup now allows that one layout, where the project is the gstack checkout itself and its Codex skills directory is that checkout's own `.agents/skills`. Every other overlap is still refused. That includes a user project whose `.agents/skills` leads into a gstack checkout, a checkout whose `.agents/skills` points at its own root, and `--host codex` run from a dev checkout, which the Codex preflight still stops before any write. test/dev-setup-fresh-clone.test.ts covers the fresh clone and all three refusals (GST-768).
+
+## [1.93.31.0] - 2026-11-03
+
+**A CSO hardening test now fails at the Git step that broke instead of passing without testing anything.** `fixtureGit` in `test/cso-git-hardening.test.ts` threw only on a non-zero exit status. When Git was killed by its 30-second timeout or could not start, `spawnSync` returned a `null` status, and the helper treated that as success. If that happened to the `git replace` step, the test that checks a security snapshot ignores replaced history would pass without a replacement in place. The helper now throws on a spawn error or any status other than 0, and the message names the Git arguments, the status and the signal. The stricter helper showed that the file's warm-up step had been calling `/usr/bin/git` on Windows, where it doesn't exist, on every run. It was passing only because the error was swallowed. Every test in the file already skips on Windows, so the warm-up now skips there too.
+
+### Tests
+- `test/cso-git-hardening.test.ts`: `fixtureGit` throws on `result.error` or `status !== 0` (#3476), and the `beforeAll` warm-up returns early on Windows (Windows Free Tests run 41236157469 failed in it with `ENOENT … uv_spawn '/usr/bin/git'` once the error stopped being swallowed).
+
+## [1.93.30.0] - 2026-11-03
+
+**`browse` starts again when proxy credentials come from `BROWSE_PROXY_USER` and `BROWSE_PROXY_PASS`.** With those two variables set beside `--proxy`, `BROWSE_PROXY_URL` or a standard proxy variable such as `HTTPS_PROXY`, the daemon exited at startup with `proxy creds set in both env (BROWSE_PROXY_USER) and URL — pick one source`, and the CLI reported only `Server failed to start within 15s`. The CLI puts those credentials into the proxy URL it hands the daemon, and the daemon also inherited the two variables, so it saw the same credentials twice and refused them. The daemon now gets the variables blanked whenever the CLI passes it a resolved proxy URL, so it reads the credentials once, from the URL. Setting credentials both in the URL and in the variables is still refused, with the same message, before any daemon starts.
+
+### Fixed
+- **`browse/src/cli.ts`**: the daemon environment for a resolved proxy sets `BROWSE_PROXY_USER` and `BROWSE_PROXY_PASS` to empty. This covers a normal start, an auto-restart, a headed `connect` and its supervisor respawn, and the Windows launcher (#3457).
+
+### Tests
+- `browse/test/restart-env.test.ts`: for `--proxy`, `BROWSE_PROXY_URL` and `HTTPS_PROXY` with `BROWSE_PROXY_USER`/`PASS`, the restart and headed daemon environments parse the way `server.ts` parses them and yield the credentials once. All three failed before the fix with the "pick one source" error.
+- `browse/test/proxy-env-daemon.test.ts`: an end-to-end start with `HTTP_PROXY` and `BROWSE_PROXY_USER`/`PASS` against a local proxy that demands Basic auth loads the page through the proxy. It timed out at the 15 s start limit before the fix.
+- `browse/test/cli-supervisor.test.ts`: the expected headed respawn environment includes the two blanked variables.
+
+## [1.93.29.0] - 2026-11-03
+
+**A `gbrain serve --http` on this machine's loopback address now counts as a local brain, so transcripts get imported again.** gbrain recommends this setup for local agents on a Postgres brain (`gbrain bootstrap harness`). gstack treated every http MCP entry as a brain on another machine (Path 4). So `/sync-gbrain` staged transcripts for a brain admin who doesn't exist and never ran `gbrain import` (#3539). Ported from Willy Morin's #3552.
+
+### Fixed
+- **One locality rule** (`lib/gbrain-mcp-locality.ts`). An http, url or sse entry is local when its URL is http(s) on `localhost`, `127.0.0.0/8` or `::1`, and this machine's gbrain config names a Postgres engine (a `database_url`, not PGLite) with no `remote_mcp` marker. A loopback URL next to a local PGLite stays remote, so Path 4's split-engine setup through a tunnel is unchanged. A URL with userinfo or `${` is never local. Every other entry is classified as before.
+- **`gstack-memory-ingest`** imports through gbrain in that case. Pages an earlier run left `staged` / `remote-http` count as new and are imported on the next local run. Pages older than the 90-day window need one `gstack-memory-ingest --bulk --all-history` run.
+- **`gstack-gbrain-detect`** reports `local-stdio` for it in all three tiers, and `gbrain-local-status` and the `gstack-skill-start` banner use the same rule. A remote `(SSE)` line in `claude mcp list` no longer reads as local. With several gbrain servers, the exact `gbrain:` entry wins, as in the other tiers.
+
+### Docs
+- `USING_GBRAIN_WITH_GSTACK.md`: a loopback serve over this machine's Postgres brain is not Path 4; a loopback tunnel next to a local PGLite still is.
+
+### Tests
+- `test/gbrain-mcp-locality.test.ts` covers the rule, the list-line parser, and a check that the shipped jq copy never says local where the TypeScript rule says remote. The detect, local-status and memory-ingest tests add loopback and stuck-page cases. On main's code (the tests-first commit f3712618), 13 of the new cases fail and `test/gbrain-mcp-locality.test.ts` cannot load, because the module it tests is new.
+
+## [1.93.28.0] - 2026-11-03
+
+**`test/cso-snapshot-state.test.ts` no longer hangs when a paused contender is resumed too early.**
+
+### Fixed
+- The five paused-contender tests resume a child only once the process table shows it stopped (`/proc//stat` on Linux, `ps` on macOS). Each child marks its pause and then stops itself in two steps, and a SIGCONT that arrived between the two did nothing, so the child stayed stopped and the test timed out (Free Tests 41117003351). With a 200 ms delay injected between the two steps, main failed 3 and then 5 of the 5 tests across two runs, and this change passed 5 of 5 both times. A child that exits, or never stops within 2 s, now fails with a named error instead of hanging. Timing bounds are unchanged.
+
+## [1.93.27.0] - 2026-11-02
+
+**A test can keep an old value on purpose without turning off `literals` (#3490).** When a minimum version goes up, the test that checks the old version is refused has to keep the old value, and pregate failed it with `PREGATE_LITERALS`. The only way out was `warn: ["literals"]`, which turns the check off for every constant. Now that one line can carry `// pregate-literal: NAME= `.
+
+### Added
+- **`pregate-literal:` markers.** A marker on the flagged line, or on the comment line directly above it, waives that line when it names the changed constant and its old value and gives a reason. Every other line in the file is still checked, and text inside a string is not a marker. The policy has no waiver key, so neither a base nor a pushed `pregate.json` can widen it.
+- **Waivers are listed.** `literals` prints each waived line as `waived: :: ()` and adds `marker added on this branch` when the merge-base file lacks the marker, so a reviewer sees every new waiver.
+
+### Changed
+- **docs/pregate.md** and **docs/troubleshooting.md** (`PREGATE_LITERALS`) describe the marker.
+
+## [1.93.26.0] - 2026-11-02
+
+**Editing the GBrain setup guide no longer runs every paid eval case on a PR screen.** The PR eval selector didn't recognize `USING_GBRAIN_WITH_GSTACK.md`, so it treated the file as an unknown dependency and fell back to the full suite. Two screens, #3727 and #3802, paid about $67 for it. No skill prompt or paid case reads the guide (only the README, `docs/gbrain-quick-tour.md` and free tests do), so it now selects nothing. A root doc that a paid prompt quotes, such as `ETHOS.md`, still selects its cases. The train gate's full census still runs every case, so coverage is unchanged (GST-1027).
+
+## [1.93.25.0] - 2026-11-02
+
+**/setup-browser-cookies now warns that the fallback browser still trusts the compromised eMudhra intermediates (#3338).** BROWSER.md carried this warning since v1.91.239.0, but the cookie import skill did not, so an agent importing signed-in cookies into the bundled Chromium never told the user.
+
+### Changed
+- **`setup-browser-cookies/SKILL.md.tmpl`**: before the import, the agent tells the user once that gstack's bundled Chromium still accepts certificates from those intermediates, says not to use the imported sessions on a network they do not trust until the Playwright bump, and points to `BROWSER.md`, **TLS trust: the bundled Chromium's root store**.
+
## [1.93.24.0] - 2026-11-02
**The /ship workflow judge loses the contradiction it cited in every recorded red sample.**
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f394d2e3f..a2af62793 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1038,4 +1038,4 @@ When you're happy with your skill edits:
/ship
```
-This runs tests, reviews the diff, triages Greptile comments (with 2-tier escalation), manages TODOS.md, bumps the version, and opens a PR. See `ship/SKILL.md` for the full workflow.
+This runs tests, reviews the diff, triages the comments of every review bot you configured (Greptile's with 2-tier escalation), manages TODOS.md, bumps the version, and opens a PR. See `ship/SKILL.md` for the full workflow.
diff --git a/README.md b/README.md
index 47fb9c848..b4b40adc0 100644
--- a/README.md
+++ b/README.md
@@ -244,7 +244,7 @@ On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file
| Doc | What it covers |
|-----|---------------|
-| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) |
+| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes review-bot triage for Greptile, CodeRabbit and other bots) |
| [The recommended harness](docs/recommended-harness.md) | gstack + Capy from zero to the first merged train: install, the Captain and its Drive, trains, the daily flaky pass, the twice-weekly release, the factory scorecard, what is Capy-specific, known Capy limits |
| [gstack + Capy: the software factory](capy/README.md) | What we built with Capy Captains and gstack, how the pieces fit, the measured before/after, tips, and how Garry uses it |
| [Why gstack](docs/why-gstack.md) | The story, the productivity numbers and their caveats, Karpathy's failure modes, parallel sprints |
diff --git a/TODOS.md b/TODOS.md
index 9cb7eb0e1..4f97a9c57 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -2,6 +2,10 @@
## NEXT PRIORITY
+### P3: screen verdict reuse lookback (filed 2026-11-02, GST-1027)
+
+- **Screen reuse sees only the newest 50 evals.yml dispatches.** `listGateRuns` (`scripts/recover-receipts.ts`) lists dispatched screens as one unfiltered page (`event=workflow_dispatch`, `per_page=50`), shared with every PR's screens and every train. At week-B traffic (220 dispatches from 2026-10-25 to 11-02, about 27 a day) that is under two days, and nothing says when a PR's earlier screen fell off the page: `gate-reuse find` prints "no earlier executed gate on this PR bound a verdict" and the full screen runs. No miss yet: the longest gap among the week-B re-screens was 44 dispatches (#3607). Page the listing until runs are older than the PR's oldest candidate head (or filter by `created>=`), and print a truncation line when the last page is full. A missed run only costs a screen, never a wrong verdict. **Effort:** S. **Priority:** P3.
+
### P3: gstack-clean follow-ups (filed 2026-10-31, GSTA-226 E2, #2627)
- **Codex templates delete their own scratch.** The `/codex` review, challenge and consult blocks leave `codex-err-*`, `codex-resp-*`, `codex-out-*` and `codex-prompt-*` mktemp files under `TMP_ROOT` (which falls back to `.gstack/tmp`) and the `codex-prompt.*` and `codex-focus.*` free-text files in `.gstack/tmp`; `gstack-clean` removes them after 7 days. Delete each file at the end of its block instead. Skill prose for every user, so it needs its own review. **Effort:** S. **Priority:** P3.
@@ -52,8 +56,6 @@ The 158 older open issues were re-checked against main 5cb5e1c (v1.91.83.0). Of
- #1048: "smallest clear diff" in `plan-eng-review/SKILL.md.tmpl` and `plan-ceo-review/SKILL.md.tmpl` has no schema-design carve-out, so it biases reviews toward JSON blobs. Add one tradeoff bullet, with a paid before/after case. **Effort:** S. **Priority:** P3.
- #1791: every plan-eng-review run writes a fresh `tasks-eng-review-.jsonl` with no branch in the name and no reconciliation (`scripts/resolvers/tasks-section.ts`). Put the branch in the name and supersede earlier sets. **Effort:** M. **Priority:** P3.
- #1957: /autoplan Phase 0 prints no matched-terms line for UI scope and keeps UI and DX terms in two places (`bin/gstack-autoplan-snapshot.ts`, `lib/autoplan-prompts.ts`). One shared terms module, and a `borderline` flag in the printed line. **Effort:** M. **Priority:** P3.
-- **Review**
- - #356: `/review` has no repo-convention check, so a `.bat` added to a bash-and-Make repo passes. Add it to `review/specialists/maintainability.md`. **Effort:** S. **Priority:** P3.
- **Office hours and design**
- #340: the approved sketch stays in a purgeable `mktemp` dir and no downstream design skill reads it (`scripts/resolvers/design.ts`). Copy it next to the design doc and have plan-design-review and design-review read it. **Effort:** S. **Priority:** P3.
- **Questions**
@@ -93,10 +95,10 @@ Each item was decided in the Wave 9 plan and parked with its reason. Credit the
### P3: feature themes from the Wave 9 triage (filed 2026-10-11)
-The 73 open feature requests (75 with #3061 and #3122, which the themes below also name), grouped by theme with counted demand (issues in the theme). Each issue stays open under the `parked` label with a link to its theme here (the rule is in docs/triage.md); the three largest cross-cutting themes (new hosts, provider pluralism, install footprint) also have tracking issues. Pick a theme up as a whole and credit every linked reporter.
+The 73 open feature requests (74 with #3061, which the themes below also name), grouped by theme with counted demand (issues in the theme). Each issue stays open under the `parked` label with a link to its theme here (the rule is in docs/triage.md); the three largest cross-cutting themes (new hosts, provider pluralism, install footprint) also have tracking issues. Pick a theme up as a whole and credit every linked reporter.
- **New hosts (7 issues, plus #3061 above and 9 PRs parked on Oct 8)** — Kimi CLI (#1072), Gemini CLI (#1247), Pi Agent (#1288), Claude Desktop Cowork (#1345), Letta Code (#2117), team mode for non-Claude hosts (#890), `/retro global` repo attribution for Antigravity sessions (#1977: they are counted now, but the working directory sits in an undocumented blob). Kimi Code and OMP discovery (#2917) shipped with the scanner registry in `bin/gstack-global-discover.ts`. Waits for the host-config work; see "Parked contributor ideas (Oct 8 triage)" for the parked host PRs. **Priority:** P3.
-- **Provider pluralism (7 issues, plus #3122)** — Gemini CLI as an outside-voice backend (#619), more image providers in `design` beyond OpenAI and Gemini (Gemini shipped for #990), Codex-native `image_gen` in the `design` binary's own generate, evolve and prototype calls (#2174; design-shotgun itself already uses `$imagegen` on Codex since #2175), GPT-6 Sol/Luna tiers in Codex call routing (#2943), a `/grok` review port (#2947), Codex review token usage (#2948), Codex refresh-token recovery docs (#1542), and a configurable PR review bot instead of hard-coded Greptile in /review, /ship and /retro (#3122, folded here by decision D4). One provider seam serves all of them. **Priority:** P3.
+- **Provider pluralism (7 issues)** — Gemini CLI as an outside-voice backend (#619), more image providers in `design` beyond OpenAI and Gemini (Gemini shipped for #990), Codex-native `image_gen` in the `design` binary's own generate, evolve and prototype calls (#2174; design-shotgun itself already uses `$imagegen` on Codex since #2175), GPT-6 Sol/Luna tiers in Codex call routing (#2943), a `/grok` review port (#2947), Codex review token usage (#2948), and Codex refresh-token recovery docs (#1542). One provider seam serves all of them. **Priority:** P3.
- **Install footprint and layout (7 issues)** — skills.sh distribution (#113), `~/.gstack` as the global install path (#753), a repo-local install that keeps state in the project (#1235), five compiled binaries each bundling Bun (#1245), Windows junctions instead of copies (#1877), the 1.1 GB `~/.claude/skills` footprint (#2199), and a skill-only mode (#2528). Decision D6: setup keeps the staged copy on Windows, because it works today and links change what every cleanup deletes; #1943 (@JonasFocus, junctions) and #2129 (@TTmo123, symlinks with the privilege check) are closed with credit and their designs stay here as the starting point. See also "install footprint follow-ups from the October fix wave". **Priority:** P3.
- **New skills (12 issues)** — GUI kanban for plan reviews (#247), journey maps (#729), UI-doc review (#851), assumption verification `/avl` (#1058), `/a11y-check` (#1104), `/env-check` (#1106), native plan writing (#1170), `/plan-experiment` and `/review-experiment` (#1195), `/plan-status` (#1343), language-specific reviewers (#1753), `/benchmark-mobile` (#1754), session start/end skills (#2088). **Priority:** P3.
- **Ship, review and safety (9 issues)** — `/review` shadowing the native command (#422), a silent-deploy-failures checklist section (#2089), Conductor TODO proposals and a /ship block on unresolved ones (#2090), a light-ship mode (#2188), auditing an issue before filing it (#2642), maintainer-gated CI versus failures in landing-report (#2766), Flutter pubspec versions with build metadata (#2833), a deterministic fact-consistency check for document skills (#2887), a dual-engine /careful guard (#2888). **Priority:** P3.
diff --git a/USING_GBRAIN_WITH_GSTACK.md b/USING_GBRAIN_WITH_GSTACK.md
index 7b8d40cf4..3153388a0 100644
--- a/USING_GBRAIN_WITH_GSTACK.md
+++ b/USING_GBRAIN_WITH_GSTACK.md
@@ -74,6 +74,8 @@ If you accept the local PGLite, you end up in **split-engine mode**:
- **Brain/context queries** (`mcp__gbrain__search`, `mcp__gbrain__query`, `mcp__gbrain__get_page`) route to the remote MCP. Plans, retros, learnings, cross-machine memory — all on the shared server.
- **Code queries** (`gbrain code-def`, `code-refs`, `code-callers`, `code-callees`, `gbrain search` for code) route to the local PGLite via the `.gbrain-source` pin in each worktree. Indexed locally, fast, never leaves the machine.
+A `gbrain serve --http` on this machine's loopback address (`localhost`, `127.0.0.1`, `::1`) over this machine's own Postgres brain is not Path 4. That is gbrain's recommended setup for local agents on a Postgres brain (`gbrain bootstrap harness`), and gstack treats it as a local brain: transcripts go through `gbrain import` here. gstack decides this from the URL and the local gbrain config alone, and only a config with a Postgres `database_url` counts. A loopback URL next to a local PGLite stays Path 4, so the split-engine setup above keeps working when you reach the remote brain through an SSH tunnel or port forward on a loopback port.
+
The two engines are independent. Wiping the local PGLite doesn't touch the remote brain; rotating the remote MCP bearer doesn't affect local code search. This is also the right configuration if your remote brain admin can't (or shouldn't) index every developer's checkout — local code stays local.
## MCP registration for Claude Code
diff --git a/VERSION b/VERSION
index 8eae0b3f6..fe56b8245 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.93.24.0
+1.93.36.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index 1e6f2ef3d..a455c6ee4 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.93.24.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.93.36.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/bin/gstack-config b/bin/gstack-config
index 17a907bc5..c9e4f72fc 100755
--- a/bin/gstack-config
+++ b/bin/gstack-config
@@ -300,7 +300,9 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # bot owns logins starting with its name and
# # checks mentioning it).
# pr_wait_advisory_bots: coderabbit # review bots gstack-pr-wait reports but
-# # never waits for. Both lists: lowercase names,
+# # never waits for. /review and /ship triage the
+# # comments of every bot in both lists (e.g.
+# # coderabbit,copilot). Both lists: lowercase names,
# # letters, digits, . _ - only; none empties a list.
# # Invalid values are REJECTED (value kept).
#
diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope
index 7609aefbf..4f0fb7d04 100755
--- a/bin/gstack-diff-scope
+++ b/bin/gstack-diff-scope
@@ -5,6 +5,9 @@
# 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).
+# Or: gstack-diff-scope --head --merged → the same for a head GitHub
+# already merged into the base (land resuming a true merge): the empty range
+# is the answer, so the base-contains-head refusal below does not apply.
#
# Output contract (#2526 — all-false must be distinguishable from "we could
# not look" and from "nothing matched"):
@@ -15,7 +18,11 @@
# trips loudly instead of silently disabling reviewers)
# exit 2 base ref unresolvable → all false + SCOPE_ERROR=no_base
# (shallow CI checkout / missing fetch — a green here would mean
-# "we could not look")
+# "we could not look"; also no default base when origin/HEAD
+# names the checked-out branch and nothing past it resolves)
+# exit 2 base is HEAD or contains it → all false + SCOPE_ERROR=base_contains_head
+# (the branch has commits its real base lacks, so it would be
+# compared with itself, GST-774; the reason as a comment line)
# Every line is shell-safe for `source <(...)` consumers: assignments or
# `#`-comments only.
#
@@ -26,22 +33,37 @@
set -euo pipefail
# Detect the repo's default branch when no arg is given (#703-class
-# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
-_default_base() {
- git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
- git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
- git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
- echo "main"
-}
-BASE="${1:-$(_default_base)}"
+# platform-agnostic rule, lib/default-base.ts): origin/HEAD unless it names the
+# checked-out branch, its upstream or HEAD's commit (a PR flow's clone can point
+# it at the PR branch, GST-511, GST-774) -> the PR's base (gh, 5 s) -> the
+# remote's live HEAD (ls-remote, 5 s) -> origin/main -> origin/master -> main.
+# An origin/HEAD skipped that way with nothing past it resolving leaves BASE
+# empty, and that fails closed; so does a default-base.ts that cannot run.
+GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
+_default_base_ts() { bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)/lib/default-base.ts" "$@"; }
+NO_DEFAULT=""
+BASE="${1:-}"
+if [ -z "$BASE" ]; then
+ _rc=0
+ _found=$(_default_base_ts default 2>/dev/null) || _rc=$?
+ case "$_rc" in
+ 0) BASE="$_found" ;;
+ 1) BASE="main" ;;
+ 3) NO_DEFAULT="$_found" ;;
+ *) NO_DEFAULT="lib/default-base.ts could not run (exit $_rc); pass the base explicitly" ;;
+ esac
+fi
HEAD_REF=""
+MERGED_HEAD=false
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
+if [ -n "$HEAD_REF" ] && [ "${4:-}" = "--merged" ]; then MERGED_HEAD=true; fi
# A bare branch name reads origin's tracking branch, by its full ref, when that
# shares history with HEAD: /ship merges against origin/, and a linked
# worktree or a Capy clone never moves the local branch, so a stale one widens
# the scope (#3650). A remote-qualified name is taken as given.
_NOT_BRANCH='^[0-9a-f]{7,64}$|[~^]|@\{|^[A-Z_]*HEAD$|^refs/'
_QUALIFIED=false
+case "$BASE" in origin/*) _QUALIFIED=true ;; esac
for _remote in $(git remote 2>/dev/null); do
case "$BASE" in "$_remote"/*) _QUALIFIED=true ;; esac
done
@@ -118,9 +140,17 @@ _print_flags() {
echo "SCOPE_AUTH=$AUTH"
}
+if [ -z "$BASE" ]; then
+ _print_flags
+ echo "SCOPE_ERROR=no_base"
+ echo "# ${NO_DEFAULT}"
+ exit 2
+fi
+
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
# `git diff` return an empty list — all-false with exit 0, a green that means
-# "we could not look". Distinguish it before diffing.
+# "we could not look". Distinguish it before diffing. A branch name that does
+# not resolve here reads its origin twin (_resolve_base; a PR checkout has no local main).
BASE_SHA=$(_resolve_base "$BASE" "${HEAD_REF:-HEAD}" || true)
if [ -z "$BASE_SHA" ]; then
_print_flags
@@ -129,6 +159,21 @@ if [ -z "$BASE_SHA" ]; then
exit 2
fi
+# A base that is the head or already contains it reads the branch against
+# itself, all false with exit 0 (GST-774). lib/default-base.ts decides whether
+# that is the base branch itself or a fresh branch (today's output) or a
+# refusal; it is asked only when git says the base contains the head.
+if [ "$MERGED_HEAD" = false ] && git merge-base --is-ancestor "${HEAD_REF:-HEAD}" "${BASE_SHA}" >/dev/null 2>&1; then
+ _rc=0
+ _why=$(_default_base_ts check "$BASE" "${HEAD_REF:-HEAD}" gstack-diff-scope 2>/dev/null) || _rc=$?
+ if [ "$_rc" -ne 0 ]; then
+ _print_flags
+ echo "SCOPE_ERROR=base_contains_head"
+ echo "# ${_why:-lib/default-base.ts could not check the base (exit $_rc) (BASE_CONTAINS_HEAD)}"
+ exit 2
+ fi
+fi
+
# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes
# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it).
FILES_LIST=()
diff --git a/bin/gstack-docs-candidate b/bin/gstack-docs-candidate
index 517541019..a7e04e6ed 100755
--- a/bin/gstack-docs-candidate
+++ b/bin/gstack-docs-candidate
@@ -18,7 +18,9 @@
* untracked files). It prints a short summary; the JSON file is the record.
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
* (Step 14.5 Blocked recovery): the record then carries that exception, so
- * Step 16 can tell an accepted risk from a skipped audit.
+ * Step 16 can tell an accepted risk from a skipped audit. A --base that is HEAD
+ * or already contains it while the branch has commits its real base lacks is
+ * refused (BASE_CONTAINS_HEAD, lib/default-base.ts): it would select nothing.
*
* compare re-reads the same state and prints one JSON object: whether HEAD or
* the index changed, which recorded paths' content changed or disappeared, and
@@ -35,6 +37,8 @@
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
+import { baseContainsHead } from '../lib/default-base';
+import { resultLine } from '../lib/result-codes';
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
function git(args: string[]): string {
@@ -135,6 +139,8 @@ function main(argv: string[]): void {
const mode = one(args, '--mode');
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
+ const self = baseContainsHead(top, base);
+ if (self) throw new Error(resultLine('snapshot', 'BASE_CONTAINS_HEAD', self));
const exceptionFile = flag(args, '--exception')[0];
const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile);
process.chdir(top);
diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh
index 6fe734001..23db8f80d 100644
--- a/bin/gstack-doctor-components.sh
+++ b/bin/gstack-doctor-components.sh
@@ -305,6 +305,7 @@ gstack_result_anchor() {
BASE_RED_REPRO_ERROR) printf '%s' 'base-red-repro-error' ;;
BASE_RED_PAUSE_EXPIRED) printf '%s' 'base-red-pause-expired' ;;
BASE_RED_WAIT_TIMEOUT) printf '%s' 'base-red-wait-timeout' ;;
+ BASE_CONTAINS_HEAD) printf '%s' 'base-contains-head' ;;
QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;;
QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;;
QUEUE_RECEIPT_TARGETED_MISSING) printf '%s' 'queue-receipt-targeted-missing' ;;
@@ -348,6 +349,9 @@ gstack_result_anchor() {
PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;;
PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;;
PREGATE_HEAD_POLICY_LOOSENS) printf '%s' 'pregate-head-policy-loosens' ;;
+ REVIEW_DIFF_TOO_LARGE) printf '%s' 'review-diff-too-large' ;;
+ REVIEW_DIFF_UNREADABLE) printf '%s' 'review-diff-unreadable' ;;
+ REVIEW_EXCLUDE_REFERENCE) printf '%s' 'review-exclude-reference' ;;
APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;;
APPROVAL_RECORD_SECRET) printf '%s' 'approval-record-secret' ;;
APPROVAL_RECORD_NO_BRANCH) printf '%s' 'approval-record-no-branch' ;;
diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff
index 400a9a56d..6025ecaf9 100755
--- a/bin/gstack-gate-diff
+++ b/bin/gstack-gate-diff
@@ -19,9 +19,13 @@
* /review's review_binding and make /ship loop).
*
* Exit: 0 with or without hunks. 2 only when the base is unresolvable
- * (`GATE_ERROR=no_base ref=[ fix=` as the only stdout line)
- * or the tool itself failed (`GATE_ERROR=internal …`). Unmatched paths are
- * counted, never an exit 2.
+ * (`GATE_ERROR=no_base ref=][ fix=` as the only stdout line;
+ * `ref=/HEAD target=/` when no base is given, that
+ * remote's HEAD names the checked-out branch and no other default resolves),
+ * when the base is HEAD or already contains it while the branch has commits
+ * its real base lacks (`GATE_ERROR=base_contains_head ref=][`, the
+ * BASE_CONTAINS_HEAD line on stderr), or the tool itself failed
+ * (`GATE_ERROR=internal …`). Unmatched paths are counted, never an exit 2.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -31,6 +35,8 @@ import {
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
} from '../lib/gate-diff/git';
+import { baseContainsHead, noDefaultBaseLine } from '../lib/default-base';
+import { resultLine } from '../lib/result-codes';
import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render';
import { resolveStateRoot } from '../lib/state-root';
import { runScriptSync, wtreeFingerprint } from '../lib/script-invocation';
@@ -142,7 +148,14 @@ function main(argv: string[]): number {
candidate = sha.slice(0, 12);
specIsTestPath = specDirectoryHoldsSpecs(treePaths(root, sha));
} else {
- const baseRef = parsed.base ?? defaultBase(root);
+ const found = parsed.base !== undefined ? { ref: parsed.base } : defaultBase(root);
+ if ('skipped' in found) {
+ const { remote, branch } = found.skipped;
+ console.error(`gstack-gate-diff: ${noDefaultBaseLine(found.skipped)}`);
+ process.stdout.write(`GATE_ERROR=no_base ref=${remote}/HEAD target=${remote}/${branch} fix=git remote set-head ${remote} \n`);
+ return 2;
+ }
+ const baseRef = found.ref;
const head = headSha(root);
const base = resolveBase(baseRef, root, head ?? undefined);
if (!base || !head) {
@@ -150,12 +163,18 @@ function main(argv: string[]): number {
return 2;
}
if (base.local === 'fallback') console.error(`gstack-gate-diff: ${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${baseRef}`}; base is the local ${baseRef}, which may lag its remote`);
- else if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : base.local === 'differs' ? `local ${baseRef} is not ${base.ref}` : `no local branch ${baseRef}`}; base is ${base.ref}`);
+ else if (base.local) console.error(`gstack-gate-diff: ${{ unrelated: `local ${baseRef} shares no history with HEAD`, missing: `no local branch ${baseRef}`, behind: `local ${baseRef} is behind ${base.ref}`, ahead: `local ${baseRef} is ahead of ${base.ref}`, diverged: `local ${baseRef} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
+ const self = mb === head ? baseContainsHead(root, base.ref) : null;
+ if (self) {
+ console.error(resultLine('gstack-gate-diff', 'BASE_CONTAINS_HEAD', self));
+ process.stdout.write(`GATE_ERROR=base_contains_head ref=${baseRef}\n`);
+ return 2;
+ }
files = [...diffWorkingTree(mb, root), ...untrackedAsAdded(root, root)];
commits = commitsInRange(mb, head, root);
const fp = wtreeFingerprint({ cwd: root });
diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect
index e79f4d558..0183f549d 100755
--- a/bin/gstack-gbrain-detect
+++ b/bin/gstack-gbrain-detect
@@ -45,6 +45,7 @@ import {
readGbrainVersion,
} from "../lib/gbrain-local-status";
import { gbrainConfigDir, isTransactionModePooler } from "../lib/gbrain-exec";
+import { classifyGbrainMcpEntry, classifyGbrainMcpListLine, type GbrainMcpEntry } from "../lib/gbrain-mcp-locality";
import { resolveStateRoot } from "../lib/state-root";
const STATE_DIR = resolveStateRoot();
@@ -154,7 +155,19 @@ function detectBrainGit(): boolean {
// 1. `claude mcp get gbrain --json` — public CLI surface, structured output
// 2. `claude mcp list` text-grep — older claude versions without --json
// 3. `~/.claude.json` jq read — last resort if `claude` isn't on PATH
-function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
+//
+// #3539: an HTTP entry on a loopback host whose local gbrain config has an
+// engine and no remote_mcp marker is a resident `gbrain serve --http` of this
+// machine's own brain. It reports "local-stdio" (the local-engine mode every
+// consumer already understands: memory ingest imports, sync-gbrain skips the
+// remote trust-policy question) rather than a new value.
+type McpMode = "local-stdio" | "remote-http";
+function classify(entry: GbrainMcpEntry): McpMode | null {
+ const c = classifyGbrainMcpEntry(entry);
+ return c === "remote" ? "remote-http" : c === "local" ? "local-stdio" : null;
+}
+
+function detectMcpMode(): McpMode | "none" {
const claudeOnPath = tryExec("sh", ["-c", "command -v claude"], 1_000) !== null;
if (claudeOnPath) {
// Tier 1: `claude mcp get gbrain --json`
@@ -167,11 +180,8 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
command?: string;
url?: string;
};
- const mtype = parsed.type || parsed.transport || "";
- if (mtype === "http" || mtype === "sse") return "remote-http";
- if (mtype === "stdio") return "local-stdio";
- if (parsed.url) return "remote-http";
- if (parsed.command) return "local-stdio";
+ const c = classify(parsed);
+ if (c) return c;
} catch {
// fall through
}
@@ -179,34 +189,28 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
// Tier 2: `claude mcp list` text-grep. Name-pattern generalized (#2051):
// a gbrain server registered as e.g. "gbrain-remote" or "gbrain_work"
// still counts. Anchored to the gbrain token so unrelated servers can't
- // false-positive.
+ // false-positive. The exact `gbrain:` line wins over a variant, the same
+ // order as tier 3 (and memory-ingest, which reads mcpServers.gbrain):
+ // {gbrain-a: loopback, gbrain: remote} is remote-http.
const list = tryExec("claude", ["mcp", "list"], 3_000);
if (list) {
- const line = list.split("\n").find((l) => /^gbrain([-_][\w-]*)?:/.test(l));
+ const lines = list.split("\n");
+ const line =
+ lines.find((l) => l.startsWith("gbrain:")) ?? lines.find((l) => /^gbrain[-_][\w-]*:/.test(l));
if (line) {
- if (/\b(http|HTTP)\b/.test(line)) return "remote-http";
- return "local-stdio";
+ // The list line carries the URL for HTTP and SSE servers
+ // ("gbrain: http://127.0.0.1:3131/mcp (HTTP) - ✓ Connected"), so the
+ // loopback rule (#3539) applies here too. The whole URL token is
+ // classified, never cut at ")"; an HTTP or SSE line without a usable
+ // URL keeps the old remote-http answer.
+ return classifyGbrainMcpListLine(line) === "local" ? "local-stdio" : "remote-http";
}
}
}
// Tier 3: read ~/.claude.json directly
- interface McpServerEntry {
- type?: string;
- transport?: string;
- command?: string;
- url?: string;
- }
const cj = tryReadJSON(CLAUDE_JSON) as
- | { mcpServers?: Record }
+ | { mcpServers?: Record }
| null;
- const classify = (entry: McpServerEntry): "local-stdio" | "remote-http" | null => {
- const mtype = entry.type || entry.transport || "";
- if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote-http";
- if (mtype === "stdio") return "local-stdio";
- if (entry.url) return "remote-http";
- if (entry.command) return "local-stdio";
- return null;
- };
const servers = cj?.mcpServers || {};
const exact = servers["gbrain"];
if (exact) {
diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts
index b2a69fe1a..dba4605fe 100644
--- a/bin/gstack-memory-ingest.ts
+++ b/bin/gstack-memory-ingest.ts
@@ -69,6 +69,7 @@ import {
withErrorContext,
} from "../lib/gstack-memory-helpers";
import { execGbrainText, gbrainConfigDir, spawnGbrain, spawnGbrainAsync } from "../lib/gbrain-exec";
+import { classifyGbrainMcpEntry, type GbrainMcpEntry } from "../lib/gbrain-mcp-locality";
import { constrainSourceId, parseSourcesList } from "../lib/gbrain-sources";
import {
BISECT_AFTER_REFUSALS,
@@ -427,9 +428,19 @@ function fileSha256(path: string): string {
}
}
+/**
+ * #3539: a page staged for a remote brain's pull (`staged` / `remote-http`)
+ * was never imported here. Once the brain is local (a loopback serve, or the
+ * entry switched back to stdio), that page has not landed, so it must be
+ * prepared again instead of counting as unchanged.
+ */
+function stagedForRemotePull(entry: StateEntry | undefined): boolean {
+ return !!entry && entry.status === "staged" && entry.source_id === "remote-http" && !isRemoteHttpMcpMode();
+}
+
function fileChangedSinceState(path: string, state: IngestState, verifyHash = false): boolean {
const entry = state.sessions[path];
- if (isRetryable(entry)) return true;
+ if (isRetryable(entry) || stagedForRemotePull(entry)) return true;
try {
const st = statSync(path);
const mtimeNs = Math.floor(st.mtimeMs * 1e6);
@@ -1418,7 +1429,7 @@ async function probeMode(args: CliArgs): Promise {
totalBytes += size;
const entry = state.sessions[path];
- if (!entry) newCount++;
+ if (!entry || stagedForRemotePull(entry)) newCount++;
else if (fileChangedSinceState(path, state, args.scanSecrets)) updatedCount++;
else unchangedCount++;
}
@@ -1800,23 +1811,27 @@ function makePersistentTranscriptDir(): string {
*
* Reads ~/.claude.json directly (same fallback chain as gstack-gbrain-detect
* Tier 3). Cheap: one fs read, no fork-exec.
+ *
+ * #3539: an HTTP entry on a loopback host that reaches this machine's own
+ * gbrain engine (a resident `gbrain serve --http`) is not remote; see
+ * lib/gbrain-mcp-locality.ts. Memoized: the change check consults it per
+ * state entry, and ~/.claude.json can be megabytes.
*/
+let remoteHttpMcpMode: boolean | undefined;
function isRemoteHttpMcpMode(): boolean {
+ remoteHttpMcpMode ??= readRemoteHttpMcpMode();
+ return remoteHttpMcpMode;
+}
+
+function readRemoteHttpMcpMode(): boolean {
const home = process.env.HOME || homedir();
const claudeJsonPath = join(home, ".claude.json");
if (!existsSync(claudeJsonPath)) return false;
try {
const parsed = JSON.parse(readFileSync(claudeJsonPath, "utf-8")) as {
- mcpServers?: {
- gbrain?: { type?: string; transport?: string; url?: string };
- };
+ mcpServers?: { gbrain?: GbrainMcpEntry };
};
- const entry = parsed.mcpServers?.gbrain;
- if (!entry) return false;
- const mtype = entry.type || entry.transport || "";
- if (mtype === "url" || mtype === "http" || mtype === "sse") return true;
- if (entry.url) return true;
- return false;
+ return classifyGbrainMcpEntry(parsed.mcpServers?.gbrain) === "remote";
} catch {
return false;
}
diff --git a/bin/gstack-pregate b/bin/gstack-pregate
index 4a6671b46..3e31ae8ff 100755
--- a/bin/gstack-pregate
+++ b/bin/gstack-pregate
@@ -26,6 +26,7 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
+import { baseContainsHead } from '../lib/default-base';
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
@@ -71,7 +72,8 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
--stage preflight|tests|all default all
--only restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
- --base a branch on origin, not a commit (default: origin/HEAD's branch, else main)
+ --base a branch on origin, not a commit (default: origin/HEAD's branch unless it is the
+ checked-out one, else the PR's base, origin/main, origin/master; lib/default-base.ts)
--allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands; the head file may
only add test lanes over the base copy, else PREGATE_HEAD_POLICY_LOOSENS)
@@ -268,6 +270,8 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
if (!pushedSha) { process.stderr.write(`gstack-pregate sealed: ${end} is not a commit\n`); return EXIT.usage; }
const listed = range ? rangeCommits(root, range) : publishedCommits(root, { localSha: pushedSha, remoteName: remote });
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
+ const self = baseRef && baseContainsHead(root, baseRef);
+ if (self) { process.stderr.write(`${resultLine('gstack-pregate sealed', 'BASE_CONTAINS_HEAD', self)}; the policy would be the branch's own\n`); return EXIT.refused; }
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
const report = 'error' in listed
? sealedErrorReport(listed.error, remoteUrl ?? remote)
diff --git a/bin/gstack-retro-metrics b/bin/gstack-retro-metrics
index d49abd3cb..2d4155abd 100755
--- a/bin/gstack-retro-metrics
+++ b/bin/gstack-retro-metrics
@@ -448,6 +448,7 @@ fi
# ── Aux inputs (presence only — the model Reads what exists) ─────────────────
[ -f "$_GH/retro-context.md" ] && echo "RETRO_CONTEXT: present ($_GH/retro-context.md)" || echo "RETRO_CONTEXT: absent"
[ -f "$_GH/greptile-history.md" ] && echo "GREPTILE_HISTORY: present ($_GH/greptile-history.md)" || echo "GREPTILE_HISTORY: absent"
+[ -f "$_GH/review-bot-history.md" ] && echo "REVIEW_BOT_HISTORY: present ($_GH/review-bot-history.md)" || echo "REVIEW_BOT_HISTORY: absent"
[ -f "TODOS.md" ] && echo "TODOS_FILE: present (TODOS.md)" || echo "TODOS_FILE: absent"
[ -f "$_GH/analytics/skill-usage.jsonl" ] && echo "SKILL_USAGE_LOG: present ($_GH/analytics/skill-usage.jsonl)" || echo "SKILL_USAGE_LOG: absent"
[ -f "$_GH/analytics/eureka.jsonl" ] && echo "EUREKA_LOG: present ($_GH/analytics/eureka.jsonl)" || echo "EUREKA_LOG: absent"
diff --git a/bin/gstack-review-bots b/bin/gstack-review-bots
new file mode 100755
index 000000000..3adc6178e
--- /dev/null
+++ b/bin/gstack-review-bots
@@ -0,0 +1,27 @@
+#!/usr/bin/env -S bun --no-env-file --config=/dev/null
+/**
+ * gstack-review-bots — the PR review-bot comments /review and /ship triage (fetch, record).
+ *
+ * gstack-review-bots fetch [--pr ]
+ * gstack-review-bots record --dir --id --type fix|fp|already-fixed --category
+ *
+ * The bots are gstack-pr-wait's lists: gstack-config set pr_wait_gating_bots / pr_wait_advisory_bots.
+ * Logic: lib/review-bots.ts.
+ */
+import * as os from "os";
+import * as path from "path";
+import { spawnSync } from "child_process";
+import { runReviewBots } from "../lib/review-bots";
+
+process.exitCode = runReviewBots(process.argv.slice(2), {
+ run(cmd, args) {
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? (r.error ? String(r.error) : "") };
+ },
+ vars: process.env,
+ binDir: path.resolve(import.meta.dir),
+ tmpDir: process.env.TMPDIR || os.tmpdir(),
+ now: () => Date.now(),
+ out: (t) => process.stdout.write(t),
+ err: (t) => process.stderr.write(t),
+});
diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch
index 6ca52ac1a..fd121faf1 100755
--- a/bin/gstack-review-dispatch
+++ b/bin/gstack-review-dispatch
@@ -26,12 +26,14 @@ import type { Dispatch } from '../lib/review-depth';
import { runScriptSync } from '../lib/script-invocation';
import { TARGETED_RULES, parseClassList, targetedBriefSha, targetedRead } from '../lib/review-targeted';
import { resolveBaseBranch } from '../lib/ship-policy';
+import { REVIEW_DIFF_MAX_BYTES, ceilingContext, disclosureDigest, disclosureLine, parseTail, readSelection, tooLargeLine, unreadableLine, type PlanExclude, type ReaderDiff } from '../lib/review-exclude';
const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free collection for /ship
render --role [--name ] --out [--ledger ] [slots]
roles: ${BRIEF_ROLES.join(', ')}
slots: --diff-base (every role but fix-check) --exclude '' --stack ] --test-fw
+ --keep '>' --disclosure '' (both only under review_exclude_paths)
--learnings-file --checklist (default /review/specialists/.md; red-team.md for red-team)
--checklists (specialist-batched: the plan's checklists= names, or paths) --findings-file --specialists (red-team)
--fix-list --hunks --tests [--callers ] (fix-check)
@@ -58,7 +60,9 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
verify --ledger [--targeted-out ]
BRIEF, OUTPUT and JUDGE rows from the ledger alone, then VERIFY: clean (exit 0), VERIFY: dirty (exit 1) or
VERIFY: no brief records (exit 3: this run's ledger kept no prompts). A ledger with a targeted brief also prints
- TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads
+ TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads; under
+ review_exclude_paths prints REVIEW_EXCLUDE_RECORD: digest= (the selection every reader's newest brief names: the
+ value gstack-ship-receipt write --review-exclude takes), and a mixed set of digests makes it dirty
pass --base [ --out --pass [--ledger ] [--diff-base ] [--learnings-file ] [--extras ] [--audit-only]
[--checklist-root ] [--fix-list --hunks --tests [--callers ]] [-- ]
runs gstack-review-plan and writes /pass-manifest.json: stage first (audit readers marked first in --extras),
@@ -68,6 +72,11 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
--diff-base sets every reader's base, a scoped pass's Red Team included (the targeted reader keeps the merge base).
An existing manifest for the same --pass is reused (resumed). --extras: [{"name","prompt_file","first"?,"model"?}]
--audit-only skips the plan: the --extras readers are the whole pass (Step 14.5's docs audit)
+ Before anything is written (and again before stage renders), each reviewer's diff is read once with a
+ ${REVIEW_DIFF_MAX_BYTES}-byte ceiling: over it prints REVIEW_DIFF_TOO_LARGE with the largest folders and the fix for where the
+ base policy's review_exclude_paths stands (exit 3); a git failure or timeout prints REVIEW_DIFF_UNREADABLE with the
+ stderr written under --out (exit 1). render applies the same ceiling to the selection --exclude and --keep name
+ whenever --diff-base is a commit here (always for the outside voice, whose brief embeds that selection).
show --manifest MANIFEST_JSON:
stage --manifest --stage
renders a stage; the Red Team only after every earlier reader is collected, its findings file being the
@@ -105,20 +114,25 @@ function render(): number {
const mb = mergeBaseWith(base);
const full = diffBase ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim() : undefined;
if (!full || full !== mb) throw new BriefError(`the targeted reader reads the whole diff on every pass: --diff-base must be the merge base with ${base} (${mb ?? 'unresolved'}), got ${diffBase ?? 'none'}`);
+ if (!flag('--disclosure') && ceilingContext(process.cwd(), base).state === 'active') throw new BriefError(`${base}'s review_exclude_paths is active, so the targeted reader renders in the pass manifest's voices stage (gstack-review-dispatch stage --manifest --stage voices), which carries the exclusion and its disclosure line`);
}
const checklists = flag('--checklists')?.split(',').map(s => s.trim()).filter(Boolean).map(c => (c.includes('/') ? c : path.join(ROOT, 'review/specialists', `${c}.md`)));
for (const c of checklists ?? []) if (!fs.existsSync(c)) throw new BriefError(`checklist not found: ${c}`);
let diff: string | undefined;
- if (role === 'outside-voice' && diffBase) {
- const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', diffBase], { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
- if (r.status !== 0) throw new BriefError(`git diff ${diffBase} failed: ${(r.stderr ?? '').trim().split('\n')[0]}`);
- diff = r.stdout;
+ const keepItems = parseTail(flag('--keep')).filter(s => s !== '--');
+ const policyItems = parseTail(flag('--exclude')).filter(s => s.startsWith(':(top,exclude,glob)'));
+ const resolvable = !!diffBase && git(['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`]) !== null;
+ if (role !== 'fix-check' && diffBase && (resolvable || role === 'outside-voice')) {
+ const over = ceiling('gstack-review-dispatch render', resolveBaseBranch(process.cwd(), flag('--base')), out, [{ reader: readerKey(role, name), exclude: flag('--exclude'), keep: keepItems }], diffBase, undefined);
+ if (over) { process.stderr.write(over.line + '\n'); return over.exit; }
+ if (role === 'outside-voice') diff = branchDiff(diffBase, { exclude: flag('--exclude'), keep: keepItems });
}
const slots: Slots = {
name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'),
learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: checklists?.join(','),
findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff,
classes: flag('--classes'), brief,
+ ...(flag('--disclosure') ? { disclosure: flag('--disclosure'), policy_exclude: policyItems.map(quote).join(' ') || undefined, keep: keepItems.map(quote).join(' ') || undefined } : {}),
fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'),
};
const workdir = checkoutRoot();
@@ -131,11 +145,12 @@ function render(): number {
const ledger = flag('--ledger') ?? path.join(out, 'review-ledger.jsonl');
const sha = promptHash(text);
const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(checklist), diff_base: diffBase ?? null, ...(diffBase ? targetedRead(process.cwd(), diffBase) : { patch_id: 'none', head: null }) };
- appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), workdir, tree, ...targeted });
+ appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), workdir, tree, ...targeted, ...(disclosureDigest(flag('--disclosure')) ? { exclude: disclosureDigest(flag('--disclosure')) } : {}) });
process.stdout.write(`BRIEF: reader=${readerKey(role, name)} sha256=${sha} bytes=${Buffer.byteLength(text)} file=${file}\n`);
return EXIT.ok;
}
+const quote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
const wrongTree = (reader: string, t: TreeCheck) => `${t.moved ? 'COLLECT_TREE_MOVED' : 'COLLECT_WRONG_TREE'}: reader=${reader} reported=${JSON.stringify(t.reported)} expected=${JSON.stringify(t.expected)} (${t.moved ? 'the checkout moved after this brief was rendered' : 'the reply did not review the checkout its brief named'}; nothing merged; restore it with a fresh pass under a new --pass and --out)\n`;
const PROGRAM_FILE = path.join(ROOT, 'lib', 'capy-review-pass.js');
@@ -143,11 +158,30 @@ const PROGRAM_VERSION = 1;
const git = (a: string[]) => { const r = spawnSync('git', a, { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
const mergeBaseWith = (base: string) => [`origin/${base}`, base].map(ref => git(['merge-base', ref, 'HEAD'])?.trim()).find(Boolean);
const readerLine = (m: Manifest, r: PassReader) => `READER: ${r.reader} stage=${r.stage} state=${r.state} title="${r.title}"${r.sha256 ? ` sha256=${r.sha256} bytes=${r.bytes} chunks=${r.chunks}` : ''}${r.path ? ` path=${r.path}` : ''}`;
-const branchDiff = (base: string) => {
- const d = git(['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', base]);
- if (d === null) throw new BriefError(`git diff ${base} failed`);
- return d;
+/** Each selection is read once per process: the ceiling check and the outside voice's embedded diff share it. */
+const selections = new Map();
+const selectionOf = (base: string, exclude: string | undefined, keep: readonly string[] = []) => {
+ const key = JSON.stringify([base, exclude ?? '', keep]);
+ if (!selections.has(key)) selections.set(key, readSelection(process.cwd(), base, parseTail(exclude), keep));
+ return selections.get(key)!;
};
+const branchDiff = (base: string, r?: Pick) => {
+ const d = selectionOf(base, r?.exclude, r?.keep);
+ if (!d.ok) throw new BriefError(`git diff ${base} failed`);
+ return d.text;
+};
+
+/** The ceiling over every reader about to render: the first one over or unreadable stops the step before it writes anything else. */
+function ceiling(context: string, base: string, outDir: string, readers: ReadonlyArray & { diff_base?: string }>, diffBase: string, known: PlanExclude | undefined): { exit: number; line: string } | null {
+ for (const r of readers) {
+ if (r.prompt_file) continue;
+ const at = r.diff_base ?? diffBase;
+ const d = selectionOf(at, r.exclude, r.keep);
+ if (!d.ok) return { exit: EXIT.fail, line: unreadableLine(context, r.reader, d, at, base, outDir) };
+ if (d.bytes > REVIEW_DIFF_MAX_BYTES) return { exit: EXIT.refused, line: tooLargeLine(context, r.reader, d, ceilingContext(process.cwd(), base, known)).line };
+ }
+ return null;
+}
function pass(): number {
const base = flag('--base'), out = flag('--out'), id = flag('--pass');
@@ -179,7 +213,7 @@ function pass(): number {
if (text.status !== 0) { process.stdout.write(text.stdout ?? ''); process.stderr.write(text.stderr ?? ''); return EXIT.fail; }
const j = plan(true);
if (j.status !== 0) { process.stderr.write(j.stderr ?? ''); return EXIT.fail; }
- const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] }; fix_range?: { base: string } | null };
+ const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] }; fix_range?: { base: string } | null; review_exclude?: Record & { state: string; excluded?: number; kept_files?: unknown; references?: unknown } };
const step = p.next.step;
const fixChecks = step === 'fix-check' ? [] : p.dispatches.filter(d => d.role === 'fix-check');
const planLines = [...text.stdout.trimEnd().split('\n'), ...fixChecks.map(d => `FIX_CHECK: planned effort=${d.effort} (Step 3.5 runs it on its own; not in this manifest)`)];
@@ -195,7 +229,10 @@ function pass(): number {
const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase, root, checklistRoot: flag('--checklist-root') ?? ROOT,
dispatches: step === 'fix-check' ? p.dispatches : p.dispatches.filter(d => d.role !== 'fix-check'), next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
targetedClasses: p.targeted?.classes, mergeBase, rangeBase: flag('--diff-base') ? undefined : rangeBase,
- fixCheck: step === 'fix-check' && (flag('--fix-list') || flag('--hunks') || flag('--tests')) ? { fix_list: flag('--fix-list')!, hunks: flag('--hunks')!, tests: flag('--tests')!, callers: flag('--callers') } : undefined });
+ fixCheck: step === 'fix-check' && (flag('--fix-list') || flag('--hunks') || flag('--tests')) ? { fix_list: flag('--fix-list')!, hunks: flag('--hunks')!, tests: flag('--tests')!, callers: flag('--callers') } : undefined,
+ ...planExclude(p.review_exclude) });
+ const over = ceiling('gstack-review-dispatch pass', base, m.out, m.readers, m.diff_base, (m.review_exclude as PlanExclude) ?? null);
+ if (over) { process.stdout.write(planLines.join('\n') + '\n'); process.stderr.write(over.line + '\n'); return over.exit; }
fs.mkdirSync(m.out, { recursive: true, mode: 0o700 });
const t = sectionTexts(ROOT);
renderStage(m, 'first', t, branchDiff);
@@ -206,6 +243,13 @@ function pass(): number {
return EXIT.ok;
}
+/** The manifest's copy of the plan's review_exclude (no file lists) and the brief's disclosure line, when the plan had one. */
+function planExclude(x: (Record & { state: string; excluded?: number }) | undefined): { reviewExclude?: Record; disclosure?: string } {
+ if (!x) return {};
+ const { kept_files: _k, references: _r, ...rest } = x;
+ return { reviewExclude: rest, ...(x.state === 'active' ? { disclosure: disclosureLine(x as unknown as Parameters[0]) } : {}) };
+}
+
function passStep(cmd: string): number {
const file = flag('--manifest');
if (!file) { process.stderr.write(`gstack-review-dispatch ${cmd}: --manifest is required\n`); return EXIT.usage; }
@@ -214,6 +258,9 @@ function passStep(cmd: string): number {
if (cmd === 'stage') {
const stage = flag('--stage') as Stage | undefined;
if (!stage || !STAGES.includes(stage)) { process.stderr.write(`gstack-review-dispatch stage: --stage ${STAGES.join('|')}\n`); return EXIT.usage; }
+ const pending = m.readers.filter(r => r.stage === stage && r.state === 'planned');
+ const over = ceiling('gstack-review-dispatch stage', m.base, m.out, pending, m.diff_base, (m.review_exclude as PlanExclude) ?? null);
+ if (over) { process.stderr.write(over.line + '\n'); return over.exit; }
const done = renderStage(m, stage, sectionTexts(ROOT), branchDiff);
writeManifest(file, m);
process.stdout.write([`STAGE: ${stage} rendered=${done.length}`, ...done.map(r => readerLine(m, r))].join('\n') + '\n');
@@ -297,6 +344,19 @@ function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[];
return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: Array.isArray(ids) ? ids.length : 0, raised_by: 'targeted', head };
}
+/**
+ * The selection the verified briefs read under review_exclude_paths: the digest every reader's newest brief names (fix-check reads
+ * named hunks, not the selection); null when no brief names one, so a repository without the key verifies as before.
+ */
+function excludeRecord(ledger: string): { digest: string } | { mixed: string[] } | null {
+ const newest = new Map>();
+ for (const e of readLedger(ledger)) if (e.event === 'rendered' && e.role && e.role !== 'fix-check') newest.set(String(e.reader), e);
+ const named = [...newest.values()].map(e => (typeof e.exclude === 'string' ? e.exclude : 'none'));
+ if (!named.some(d => d !== 'none')) return null;
+ const digests = [...new Set(named)];
+ return digests.length === 1 ? { digest: digests[0]! } : { mixed: digests };
+}
+
function main(): number {
const cmd = args[0];
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
@@ -368,8 +428,12 @@ function main(): number {
process.stdout.write(`TARGETED_RECORD: classes=${record.classes.join(',')} status=${record.status} findings=${record.findings} brief=${record.brief_sha.slice(0, 12)} rules=${record.rules} read=${record.head?.slice(0, 12) ?? 'none'}\n`);
if (flag('--targeted-out')) fs.writeFileSync(flag('--targeted-out')!, JSON.stringify(record) + '\n');
}
- process.stdout.write(`VERIFY: ${v.clean ? 'clean' : 'dirty'} briefs=${v.briefs} outputs=${v.rows.filter(r => r.kind === 'OUTPUT').length} voided=${v.voided}${v.unavailable ? ` unavailable=${v.unavailable}` : ''}\n`);
- return v.clean ? EXIT.ok : EXIT.fail;
+ const sel = excludeRecord(ledger);
+ if (sel && 'digest' in sel) process.stdout.write(`REVIEW_EXCLUDE_RECORD: digest=${sel.digest} (gstack-ship-receipt write --review-exclude ${sel.digest})\n`);
+ if (sel && 'mixed' in sel) process.stdout.write(`REVIEW_EXCLUDE_RECORD: mixed digests=${sel.mixed.join(',')} (readers read different review_exclude_paths selections; rerun the review so every reader reads HEAD's)\n`);
+ const clean = v.clean && !(sel && 'mixed' in sel);
+ process.stdout.write(`VERIFY: ${clean ? 'clean' : 'dirty'} briefs=${v.briefs} outputs=${v.rows.filter(r => r.kind === 'OUTPUT').length} voided=${v.voided}${v.unavailable ? ` unavailable=${v.unavailable}` : ''}\n`);
+ return clean ? EXIT.ok : EXIT.fail;
}
process.stderr.write(`gstack-review-dispatch: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan
index 793a4d1f3..be34391f3 100755
--- a/bin/gstack-review-plan
+++ b/bin/gstack-review-plan
@@ -16,16 +16,23 @@
* FIX_RANGE: (with --range-base), 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.
+ * exits 0; a diff git cannot list exits 1, and so does a base that is HEAD or already
+ * contains it while the branch has commits its real base lacks (BASE_CONTAINS_HEAD,
+ * lib/default-base.ts). Logic: lib/review-depth.ts.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
+import { baseContainsHead } from '../lib/default-base';
import { locate, readRecord } from '../lib/approval-record';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
+import { resultLine } from '../lib/result-codes';
import { runScriptSync } from '../lib/script-invocation';
import { collectCommit, collectLive } from '../lib/review-collect';
-import { classifyDiff, diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
+import { excludeLines, reviewExclude, type Exclusion } from '../lib/review-exclude';
+import { isUnavailable } from '../lib/ship-policy';
+import { loadPolicyAt } from '../lib/ship-policy-at';
+import { classifyDiff, diffCommand, keepCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
import { classList, targetedBriefSha, targetedClasses, targetedPatchId } from '../lib/review-targeted';
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
@@ -120,6 +127,8 @@ function main(): number {
return EXIT.fail;
}
const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
+ const self = collected.mergeBase === head ? baseContainsHead(root, git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) ? `origin/${base}` : base) : null;
+ if (self) { process.stderr.write(`${resultLine('gstack-review-plan', 'BASE_CONTAINS_HEAD', self)}; no plan is safer than a plan for a diff compared with itself\n`); return EXIT.fail; }
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
const scopeOf = (args: string[]): Record => {
const run = runScriptSync(path.join(binDir, 'gstack-diff-scope'), args, { encoding: 'utf8', timeout: 60_000 });
@@ -128,15 +137,32 @@ function main(): number {
if (run.status !== 0 && !s.SCOPE_ERROR) s.SCOPE_ERROR = `exit ${run.status}`;
return s;
};
- const gateTagsOf = (args: string[]): number | null => {
- const g = runScriptSync(path.join(binDir, 'gstack-gate-diff'), args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
- const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? '');
- return g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
+ const runGateDiff = (args: string[]) => runScriptSync(path.join(binDir, 'gstack-gate-diff'), args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
+ const tagCount = (stdout: string | undefined, status: number | null): number | null => {
+ const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(stdout ?? '');
+ return status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
};
+ const gateTagsOf = (args: string[]): number | null => { const g = runGateDiff(args); return tagCount(g.stdout, g.status); };
const diffBase = collected.mergeBase ?? base;
const scope = scopeOf([diffBase]);
- let gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([diffBase]);
+ const ref = git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) !== null ? `origin/${base}` : base;
+ const baseSha = collected.registry.sha ?? git(['rev-parse', '--verify', '-q', `${ref}^{commit}`])?.trim() ?? '';
+ const policy = baseSha ? loadPolicyAt(root, baseSha, ref) : null;
+ const declares = !!policy && !isUnavailable(policy) && policy.policy.review_exclude_paths.length > 0;
+ let tagged: Set | null = null;
+ let gateTagged: number | null;
+ if (declares) {
+ const g = runGateDiff([diffBase, '--format', 'jsonl']);
+ gateTagged = tagCount(g.stdout, g.status);
+ tagged = gateTagged === null ? null : new Set((g.stdout ?? '').split('\n').slice(1).flatMap(l => { try { const h = JSON.parse(l) as { path?: string; tags?: unknown[] }; return h.path && h.tags?.length ? [h.path] : []; } catch { return []; } }));
+ if (flag('--gate-tagged') !== undefined) gateTagged = Number(flag('--gate-tagged'));
+ } else gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([diffBase]);
if (gateTagged !== null && !Number.isFinite(gateTagged)) gateTagged = null;
+ const exclusion: Exclusion = !baseSha || !collected.mergeBase ? { state: 'none' } : reviewExclude({ root, baseSha, ref, mergeBase: collected.mergeBase, perPath: collected.perPath, added: collected.added, classRoot: root, gateTagged: tagged, context: 'gstack-review-plan' });
+ if (exclusion.state === 'refused') { for (const l of exclusion.lines) process.stderr.write(l + '\n'); return exclusion.code === 'REVIEW_DIFF_UNREADABLE' ? EXIT.fail : EXIT.refused; }
+ const active = exclusion.state === 'active' ? exclusion : null;
+ const kept = (rec: Record) => (active ? Object.fromEntries(Object.entries(rec).filter(([p]) => !active.excluded.has(p))) : rec);
+ const exclusionInput = active ? { excluded: active.excluded, exclusion: { specs: active.specs, keep: active.keep } } : {};
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';
@@ -150,28 +176,30 @@ function main(): number {
else if (git(['diff', '--quiet', 'HEAD', '--']) === null || collected.untracked.length) fixRange = unread('uncommitted or untracked edits: commit the fixes first');
else {
const commit = collectCommit(root, resolved, 'HEAD');
- const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]) });
+ const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]), ...(active ? { excluded: active.excluded } : {}) });
fixRange = range.errors.length ? unread(range.errors.map(e => `${e.code} ${e.input}`).join('; '))
: !commit.paths.length ? unread('the fix range is empty: commit the fix after the range base')
: { base: rangeBase, runtime_lines: range.runtimeLines, ...(range.class === 'gate' ? { touched_gate: true as const } : {}) };
}
}
- const targeted = targetedClasses({ ...collected, scope, gateTagged });
+ const targeted = targetedClasses({ ...collected, ...(active ? { paths: paths.filter(p => !active.excluded.has(p)), added: kept(added), removed: kept(collected.removed) } : {}), scope, gateTagged });
const patch = collected.mergeBase ? targetedPatchId(process.cwd(), collected.mergeBase) : 'none';
const brief = targetedBriefSha();
const plan = planReview({
- ...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
+ ...collected, ...exclusionInput, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(kept(added)), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant },
});
const gateScan = { tagged: gateTagged, head };
if (has('--json')) {
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
- process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, 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, consumed: collected.consumed, drawings: collected.drawings }) + '\n');
+ process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, 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, consumed: collected.consumed, drawings: collected.drawings,
+ ...(exclusion.state === 'head-only' ? { review_exclude: exclusion } : active ? { review_exclude: { state: 'active', ...active.record, specs: active.specs, keep: active.keep, kept_files: active.kept, content: active.content, references: active.references } } : {}) }) + '\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}`,
`TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`];
+ out.push(...excludeLines(exclusion, base));
if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`);
out.push(reviewPlanLine(plan, pass + (plan.next.step === 'full' || plan.next.step === 'scoped' ? 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})` : ''}`);
@@ -179,6 +207,8 @@ function main(): number {
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`);
+ const keepCmd = keepCommand(d);
+ if (keepCmd) out.push(`DIFF_KEEP: ${key} ${keepCmd}`);
}
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 run at their effort=)`);
diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue
index 738acc9c2..2b503b0fe 100755
--- a/bin/gstack-ship-queue
+++ b/bin/gstack-ship-queue
@@ -20,7 +20,7 @@
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
* gstack-ship-queue wave plan|build|status|settle ... (wave PRs: 3-6 same-area PRs in one train slot; wave --help)
*
- * Every subcommand takes --base (default origin/HEAD, else main) and
+ * Every subcommand takes --base (default lib/default-base.ts) and
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=.
* gstack-restamp stays the primitive. Nothing here pushes, except land, ready
* under changelog_fragments (its READY receipt note), and under a
@@ -69,7 +69,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
wave plan|build|status|settle ... wave PRs under a train policy: 3-6 same-area PRs in one slot and one version
(wave --help; docs/ship-policy.md#wave-prs)
---base : base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the
+--base : base branch (default: origin/HEAD unless it names the checked-out branch, else the PR's base, origin/main). Nothing here pushes, except land (the stamp commit and the
refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains.
${exitTableHelp()}`;
diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt
index 79c64da9e..9e417faa9 100755
--- a/bin/gstack-ship-receipt
+++ b/bin/gstack-ship-receipt
@@ -49,7 +49,7 @@ import {
} from '../lib/ship-receipt';
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
import { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch';
-import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
+import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
import { readPause, receiptBaseRed } from '../lib/base-red';
@@ -96,6 +96,9 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate (the digest= on gstack-review-dispatch verify's REVIEW_EXCLUDE_RECORD: line, which the reviewers' briefs
+ name; none without that line): must equal the digest HEAD has under the base policy's review_exclude_paths, else
+ RECEIPT_INVALID field=review_exclude and nothing is written (rerun the review; write alone cannot clear it); recorded as review_exclude. Carry and the queue compare that digest.
--review-targeted ({classes, rules, brief_sha, status, findings, raised_by, head}: gstack-review-dispatch verify
--targeted-out); recorded as review_targeted bound to the receipt's product_patch_id, and refused (RECEIPT_INVALID) when the
commit the reader read (head) is not an ancestor or a change since it carries a targeted class (gate scan included). write and carry
@@ -220,7 +223,15 @@ function cmdWrite(rest: string[], json: boolean): number {
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 review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head);
+ const reviewDepth = review.depth;
+ if (review.exclude.state === 'refused') { for (const l of review.exclude.lines) process.stderr.write(`${l}; nothing written\n`); return EXIT.refused; }
+ const excludeNow = review.exclude.state === 'active' ? review.exclude.record : null;
+ const excludeGiven = flag(rest, '--review-exclude') ?? 'none';
+ if ((excludeNow?.digest ?? 'none') !== excludeGiven) {
+ process.stderr.write(`gstack-ship-receipt write: field=review_exclude expected=${excludeNow?.digest ?? 'none'} recorded=${excludeGiven} source=${excludeNow?.source ?? `origin/${base} (no review_exclude_paths)`}: the review --review-exclude names read another selection than ${base}'s review_exclude_paths gives ${head.slice(0, 12)}; fix: rerun /ship's review on ${head.slice(0, 12)} (gstack-review-dispatch pass --base ${base}), run gstack-review-dispatch verify --ledger and write with the --review-exclude its REVIEW_EXCLUDE_RECORD: line prints (none when it prints no such line); nothing written (RECEIPT_INVALID)\n`);
+ return EXIT.fail;
+ }
const product = reviewedProduct(repoRoot, base, head, pregateFile);
let targeted: ReturnType | undefined;
if (flag(rest, '--review-targeted') !== undefined) {
@@ -259,6 +270,7 @@ function cmdWrite(rest: string[], json: boolean): number {
review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product,
gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined,
base_red: pause ? receiptBaseRed(pause) : undefined,
+ review_exclude: receiptExclude(review.exclude),
};
} 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; }
@@ -733,9 +745,13 @@ function cmdCarry(rest: string[], json: boolean): number {
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);
+ const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head);
+ const now = review.depth;
+ if (review.exclude.state === 'refused') return refused('RECEIPT_CARRY_REFUSED', `${review.exclude.lines.join('; ')}; run /ship on ${head.slice(0, 12)} once the base policy reads`);
+ const digest = review.exclude.state === 'active' ? review.exclude.record.digest : 'none';
+ if (digest !== (receipt.review_exclude?.digest ?? 'none')) return refused('RECEIPT_CARRY_REFUSED', `review_exclude changed: recorded=${receipt.review_exclude?.digest ?? 'none'} now=${digest} source=${review.exclude.state === 'active' ? review.exclude.record.source : `${base} (no review_exclude_paths)`}; the reviewers read another selection than HEAD ${head.slice(0, 12)} has under the base policy, so carry does not apply; run /ship on ${head.slice(0, 12)}`);
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) } };
+ depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) }, review_exclude: receiptExclude(review.exclude) };
}
// The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on.
const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField };
diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start
index fb26ee8e5..16b1c9133 100755
--- a/bin/gstack-skill-start
+++ b/bin/gstack-skill-start
@@ -426,7 +426,29 @@ if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ] && grep -q '"gbr
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null)
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
case "$_GBRAIN_MCP_TYPE" in
- url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
+ url|http|sse)
+ _GBRAIN_MCP_MODE="remote-http"
+ # #3539: a loopback URL whose local gbrain config names a Postgres
+ # engine (a database_url, engine not pglite) and no remote_mcp marker is
+ # this machine's own `gbrain serve --http`, not a remote brain. A conservative subset of lib/gbrain-mcp-locality.ts, not
+ # an exact mirror: the URL test accepts only canonical loopback
+ # spellings (localhost, 127.a.b.c with valid decimal octets, [::1], a
+ # valid port), so other spellings stay remote here. It lowercases with
+ # ascii_downcase, not the "i" flag, which case-folds U+017F to "s". As in
+ # TS, a URL containing "${" is remote (Claude Code expands it), and the
+ # regex rejects userinfo. The config test
+ # matches the TS one: remote_mcp is a marker when JS-truthy, as in
+ # gbrain's isThinClient (null, false, 0 and "" are not), and
+ # database_url counts when non-blank after JS trim() and engine is not
+ # pglite. GBRAIN_HOME is the
+ # parent of .gbrain, as in gbrainConfigDir. test/gbrain-mcp-locality.test.ts
+ # runs both programs below against the TS rule.
+ if printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -e '(.url // "") | ascii_downcase | (contains("${") | not) and test("\\Ahttps?://(localhost|127(\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|\\[::1\\])(:([0-9]{0,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?([/?#]|\\z)")' >/dev/null 2>&1 \
+ && jq -e '((.remote_mcp // false) | . == false or . == 0 or . == "") and .engine != "pglite" and ((.database_url // "") | (type == "string" and test("[^\\s\\x{feff}]|\\x{0085}")))' \
+ "${GBRAIN_HOME:-$HOME}/.gbrain/config.json" >/dev/null 2>&1; then
+ _GBRAIN_MCP_MODE="local-stdio"
+ fi
+ ;;
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
esac
fi
diff --git a/browse/src/cli.ts b/browse/src/cli.ts
index e884952b0..208889ec2 100644
--- a/browse/src/cli.ts
+++ b/browse/src/cli.ts
@@ -523,12 +523,16 @@ export function proxyMismatchLines(flags: GlobalFlags, state: ServerState, env:
* The server env for this invocation's resolved proxy (#915); empty when
* direct, except BROWSE_PROXY_URL=direct where that bypasses the system proxy
* settings (#3440), so a daemon start or auto-restart carries the opt-out the
- * session's hash was computed from.
+ * session's hash was computed from. The resolved URL already carries any
+ * BROWSE_PROXY_USER/PASS, so those are blanked for the daemon, which inherits
+ * this environment and would otherwise see the creds twice (#3457).
*/
function proxyServerEnv(flags: Partial> | null | undefined): Record {
if (!flags?.proxyUrl) return flags?.explicitDirect ? { BROWSE_PROXY_URL: 'direct' } : {};
return {
BROWSE_PROXY_URL: flags.proxyUrl,
+ BROWSE_PROXY_USER: '',
+ BROWSE_PROXY_PASS: '',
...(flags.proxySource ? { BROWSE_PROXY_SOURCE: flags.proxySource } : {}),
...(flags.proxyBypass ? { BROWSE_PROXY_BYPASS: flags.proxyBypass } : {}),
};
diff --git a/browse/test/cli-supervisor.test.ts b/browse/test/cli-supervisor.test.ts
index eed733983..3e9e27809 100644
--- a/browse/test/cli-supervisor.test.ts
+++ b/browse/test/cli-supervisor.test.ts
@@ -124,7 +124,7 @@ describe('runHeadedSupervisor (behavior)', () => {
expect(h.calls.startEnv).toHaveLength(1);
expect(h.calls.startEnv[0]).toEqual({
BROWSE_HEADED: '1', BROWSE_PORT: '34567', BROWSE_PARENT_PID: '0',
- BROWSE_PROXY_URL: 'socks5://127.0.0.1:9050', BROWSE_CONFIG_HASH: 'abc123',
+ BROWSE_PROXY_URL: 'socks5://127.0.0.1:9050', BROWSE_PROXY_USER: '', BROWSE_PROXY_PASS: '', BROWSE_CONFIG_HASH: 'abc123',
});
expect(h.calls.startEnv[0]).toBe(h.deps.env);
expect(h.calls.agents).toEqual([5000]);
diff --git a/browse/test/proxy-env-daemon.test.ts b/browse/test/proxy-env-daemon.test.ts
index 785eef9bd..68ab854da 100644
--- a/browse/test/proxy-env-daemon.test.ts
+++ b/browse/test/proxy-env-daemon.test.ts
@@ -14,6 +14,8 @@ import * as path from 'node:path';
const CLI = path.resolve(import.meta.dir, '../src/cli.ts');
const PROXIED_HOST = 'proxied.gstack-915.test';
+const AUTH_HOST = 'auth.gstack-3457.test';
+const AUTH_HEADER = `Basic ${Buffer.from('proxy-user:proxy-secret').toString('base64')}`;
let scratch = '';
let stateFile = '';
@@ -27,6 +29,11 @@ beforeAll(async () => {
stateFile = path.join(scratch, 'state', 'browse.json');
proxy = http.createServer((req, res) => {
const host = new URL(req.url ?? '/', 'http://unknown').hostname;
+ if (host === AUTH_HOST && req.headers['proxy-authorization'] !== AUTH_HEADER) {
+ res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="gstack-3457"' });
+ res.end();
+ return;
+ }
proxiedHosts.push(host);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`]via proxy for ${host}
`);
@@ -177,4 +184,15 @@ describe.skipIf(process.platform === 'win32')('#915 proxy from the environment,
}
expect(daemonPid()).toBe(pid);
}, 120_000);
+
+ test('BROWSE_PROXY_USER/PASS with HTTP_PROXY: the daemon starts and Chromium answers the proxy auth challenge (#3457)', async () => {
+ expect((await browse(['stop'], {})).code).toBe(0);
+ const env = { HTTP_PROXY: proxyUrl, BROWSE_PROXY_USER: 'proxy-user', BROWSE_PROXY_PASS: 'proxy-secret' };
+ const r = await browse(['goto', `http://${AUTH_HOST}/`], env);
+ expect(r.code, r.out).toBe(0);
+ expect(r.stderr).toContain(`Starting server with proxy ${proxyUrl.replace('http://', 'http://***:***@')}/ from HTTP_PROXY...`);
+ expect(r.out).not.toContain('proxy-secret');
+ expect(proxiedHosts).toContain(AUTH_HOST);
+ expect((await browse(['text'], env)).stdout).toContain(`via proxy for ${AUTH_HOST}`);
+ }, 120_000);
});
diff --git a/browse/test/restart-env.test.ts b/browse/test/restart-env.test.ts
index a48eb1a32..6a9aea842 100644
--- a/browse/test/restart-env.test.ts
+++ b/browse/test/restart-env.test.ts
@@ -1,5 +1,6 @@
import { describe, test, expect } from "bun:test";
-import { buildRestartEnv } from "../src/cli";
+import { buildHeadedServerEnv, buildRestartEnv, extractGlobalFlags } from "../src/cli";
+import { parseProxyConfig, serverProxyEnv } from "../src/proxy-config";
// #1781: an auto-restart triggered by a plain command (no --headed flag) must
// NOT silently downgrade a headed session to headless. buildRestartEnv reapplies
@@ -48,3 +49,36 @@ describe("buildRestartEnv (#1781 headed persistence)", () => {
expect(env.BROWSE_CONFIG_HASH).toBe("fromstate");
});
});
+
+// #3457: the CLI folds BROWSE_PROXY_USER/PASS into the BROWSE_PROXY_URL it hands
+// the daemon, and the daemon inherits the CLI's environment. The daemon parses
+// that URL with the env creds exactly as server.ts does, so it must see one source.
+describe("daemon env carries one proxy credential source (#3457)", () => {
+ const userEnv = { BROWSE_PROXY_USER: "a", BROWSE_PROXY_PASS: "b" };
+ const daemonParse = (env: Record) => {
+ const { url, source } = serverProxyEnv(env, "linux");
+ return parseProxyConfig({ proxyUrl: url!, envUser: env.BROWSE_PROXY_USER, envPass: env.BROWSE_PROXY_PASS, source });
+ };
+ const spellings: Array<[string, string[], Record]> = [
+ ["--proxy", ["--proxy", "http://127.0.0.1:3128", "status"], {}],
+ ["BROWSE_PROXY_URL", ["status"], { BROWSE_PROXY_URL: "http://127.0.0.1:3128" }],
+ ["HTTPS_PROXY", ["status"], { HTTPS_PROXY: "http://127.0.0.1:3128" }],
+ ];
+
+ for (const [name, args, proxyEnv] of spellings) {
+ test(`${name} with BROWSE_PROXY_USER/PASS: start, restart and headed daemons parse the creds once`, () => {
+ const env = { ...userEnv, ...proxyEnv };
+ const flags = extractGlobalFlags(args, env, "linux");
+ for (const childEnv of [buildRestartEnv(flags, null), buildHeadedServerEnv(flags)]) {
+ const parsed = daemonParse({ ...env, ...childEnv });
+ expect(parsed).toMatchObject({ host: "127.0.0.1", port: 3128, userId: "a", password: "b" });
+ }
+ });
+ }
+
+ test("without a resolved proxy the daemon env leaves BROWSE_PROXY_USER/PASS alone", () => {
+ const env = buildRestartEnv(extractGlobalFlags(["status"], userEnv, "linux"), null);
+ expect(env.BROWSE_PROXY_USER).toBeUndefined();
+ expect(env.BROWSE_PROXY_PASS).toBeUndefined();
+ });
+});
diff --git a/changelog.d/gsta-258-autoplan-lean.md b/changelog.d/gsta-258-autoplan-lean.md
deleted file mode 100644
index 27c5c4751..000000000
--- a/changelog.d/gsta-258-autoplan-lean.md
+++ /dev/null
@@ -1 +0,0 @@
-- **/autoplan R65 defaults: no review for bug fixes, one lean review for features.** `gstack-autoplan next --kind fix` prints `AUTOPLAN: not needed (bug fix; R65)` and `status=skipped run=-` and writes nothing; the PR still needs the bug's proof. `--kind feature`, now the default for an unattended Capy run, runs the eng phase alone with one native reviewer whose brief merges the CEO and Eng lenses (Design and DX only when scope detects them), reads the draft code (a diff against `--base`, hashed into the snapshot, or an honest `DRAFT: none`), always asks "Any outside terms, licences or rights involved?", and has no outside voice, one round, one gate revision and no carry check. The reviewer's result must name the draft hash on its `DRAFT:` line. A failed git command reads `DRAFT: none (draft capture failed: …)`, untracked files named like secrets stay out of the draft, the `DRAFT:` line names the checkout it diffed, and a draft over 100,000 bytes is still inlined, cut there and marked. `run` stays the full run unless `--kind feature` asks for the lean one, `run --kind fix` skips like `next`, and `--kind fix --json` prints one skipped object. `--full` brings back every phase and voice; off Capy and in interactive sessions nothing changes. The gate cost line and the owner brief say a lean `rec` reopens nothing.
diff --git a/docs/CONTRIBUTOR_REFERENCE.md b/docs/CONTRIBUTOR_REFERENCE.md
index caec81fd3..d429ffcb9 100644
--- a/docs/CONTRIBUTOR_REFERENCE.md
+++ b/docs/CONTRIBUTOR_REFERENCE.md
@@ -131,8 +131,8 @@ determined leaker (a CHANGELOG line that does would fail a hostile screenshotter
→ glab → unknown(=public-strict). Public repos get STERNER per-finding
confirmation (no batch-acknowledge, no silent-proceed); MEDIUM is never
auto-promoted to HIGH.
-- **Tool-attributed fences:** wrap Codex/Greptile/eval output in ` ```codex-review `
- / ` ```greptile ` fences so example credentials those tools quote WARN-degrade
+- **Tool-attributed fences:** wrap Codex/review-bot/Greptile/eval output in ` ```codex-review `
+ / ` ```review-bot ` / ` ```greptile ` fences so example credentials those tools quote WARN-degrade
instead of blocking. A live-format credential inside the fence still blocks.
- **Config keys:** `redact_repo_visibility` (public|private|unknown, local-only
override for repos gh/glab can't read), `redact_prepush_hook` (true|false).
diff --git a/docs/pregate.md b/docs/pregate.md
index 9128fa093..4abfcd628 100644
--- a/docs/pregate.md
+++ b/docs/pregate.md
@@ -85,7 +85,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) |
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
-| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
+| `stall_timeout_s` | seconds a test command may print nothing while using under a tenth of one CPU before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
@@ -228,6 +228,18 @@ Two stages with separate clocks, both printed:
elsewhere in the file is not a consumer. A call-site name such as `attempts`
or `limit` is an ordinary word, so for it the file must also name the changed
module.
+ A test that keeps the old value on purpose, such as the refused input after a
+ minimum version goes up, marks that one line with a comment on it or on the
+ comment line directly above it:
+ `// pregate-literal: MIN_RUNTIME='1.4.0' refused input below the minimum`
+ (`#` and `/*` comments work too). The marker waives only that line, and only
+ when it names the changed constant and its old value and gives a reason (a
+ reason cannot start with `*`, so a bare `*/`, `**/` or `* */` closer is not
+ one); text inside a string is not a marker. Every other line in the file is still
+ checked. The check lists each waived line as
+ `waived: :: ()`, adding `marker added on
+ this branch` when the merge-base file lacks the marker. The policy has no
+ waiver key, so neither a base nor a pushed `pregate.json` can widen it.
- `timing`: added or changed test lines that compare an elapsed wall-clock or
CPU time against a bound, or sleep and then assert, print
@@ -447,10 +459,17 @@ test-tier command:
a one-file run (each serial file) reports its file from the launch for any
runner. Other runners (node --test, vitest, jest, pytest, playwright test, flutter test, dart test, cargo test) get heartbeats
only. A heartbeat follows each 60 s with no progress line;
-- is killed as **stalled** when its log does not grow for the stall timeout
- (`--stall-timeout `, else `stall_timeout_s`, else 300; `0` disables): the
- check is `incomplete` with `PREGATE_STALLED` and names the last file
- started; the next serial file still runs. The `--remote` gate is exempt: a
+- is killed as **stalled** when its log does not grow and its process group
+ stays under a tenth of one CPU for the stall timeout (`--stall-timeout `, else
+ `stall_timeout_s`, else 300; `0` disables): the check is `incomplete` with
+ `PREGATE_STALLED` and names the last file started; the next serial file
+ still runs. The group counts as busy when its CPU time, sampled each second,
+ grew by at least a tenth of the wall time, so a quiet suite that is working
+ (FOUN-63: a parallel `check.sh` silent for 9 minutes) is not killed, while
+ a hang that waits on nothing is. A hang that keeps burning CPU is left to
+ the timeout. Linux reads `/proc` (exited children's time included once
+ reaped); macOS and other BSDs read `ps -S`; a failed read skips that
+ sample. Windows reads no CPU time here and uses log growth alone. The `--remote` gate is exempt: a
gate that waits on CI is quiet by design, so only its timeout bounds it, and
its heartbeats add `; last output: `;
- is killed as **timed out** when that runner process reaches its timeout
diff --git a/docs/review-policy.md b/docs/review-policy.md
index 3362090f0..b886d927b 100644
--- a/docs/review-policy.md
+++ b/docs/review-policy.md
@@ -207,7 +207,14 @@ function but a same-file `url(#id)`, which closes `@import`, `image-set()`,
entity declaration, and no DOCTYPE but a bare `` or W3C's own SVG DTD, read after character references are decoded; anything else, a
script or `foreignObject` included, keeps it code. More than
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
-and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
+and fails the depth closed. The scan first lists the files that contain a candidate's
+extension and then searches only those, docs files left out, so a large data tree
+costs one cheap pass. Each scan gets one 120-second deadline, set in milliseconds with
+`GSTACK_CONSUMER_SCAN_TIMEOUT_MS`; a scan that runs past it, or whose matches pass
+256 MB, is also `consumer_scan_failed`, and its detail says what stopped it and names
+the deadline. One scan fits a minute inside the 180 seconds /ship gives
+`gstack-review-plan`; a plan that also scans a fix range runs two scans, and if they
+pass that limit the plan fails, which also leads to a full review. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same
function); every path-only caller (the receipt's gate-scan decision, the Step 16
post-review route, /autoplan's docs-only scope) keeps data files code. A numeric claim
@@ -424,6 +431,24 @@ Over the last 100 non-release commits on main (8663c3d2..8be7f213), money hit
which hit 43 of 100; those paths already get full depth, so the gate class was
narrowed to the gate scan's tagged hunks.
+
+## What readers read under `review_exclude_paths`
+
+A repository can list content folders its reviewers leave out, on its base
+branch only ([ship-policy.md](ship-policy.md#review-exclude-paths)). Under it,
+every reader (specialists, the Red Team, the native and outside voices, the
+targeted reader) reads the same selection: its `DIFF_CMD:` with one
+`:(top,exclude,glob)` item per applied pattern, the fixture command with the
+same items, and the `DIFF_KEEP:` command for the kept files under those
+patterns. The outside voice's embedded diff is that selection. Each brief says
+how many files and bytes were left out and that the reader must not report on
+their absence. Excluded paths count as docs for the depth and never trigger the
+numeric-claim scan or a targeted class; kept files keep their own class.
+
+Independently of the key, no reader is handed more than 2,000,000 bytes of
+diff: `gstack-review-dispatch` stops with `REVIEW_DIFF_TOO_LARGE` before it writes
+anything, instead of a review of the part a reader happened to see.
+
## What the receipt records
`gstack-review-scope summary --since --run ` reads this invocation's
@@ -674,6 +699,7 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on) beside its plan's `DIFF_CMD` excludes, because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above.
- `ship/sections/review-army.md` Step 9: the plan's `REVIEW_DEPTH`, `GATE_SCAN` and `--depth-floor` on every run, the depth fields of the review-log row, passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way.
+- `lib/review-exclude.ts`: the base policy's `review_exclude_paths` (matched set, kept reasons, the kept-file argument bound, the digest the receipt, carry and the queue compare) and the 2,000,000-byte reader ceiling (`test/review-exclude.test.ts`).
- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the depth (`depthFor`, `DEPTH_RULES`, `depthOfCommitInput`), each dispatch's `exclude` pathspecs and `DIFF_CMD`, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`).
- `lib/review-collect.ts`: `collectCommit` (git objects only, from any checkout) and `collectLive` (the working tree), per-path lines and the base commit's registry (`test/review-collect.test.ts`).
- `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`, `unrecorded`, `ledgers_read`); refusals are `REVIEW_SCOPE_INPUT`, `REVIEW_SCOPE_RANGE` and `REVIEW_SCOPE_LEDGER` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
diff --git a/docs/ship-policy.md b/docs/ship-policy.md
index 7fd896a28..d76edb603 100644
--- a/docs/ship-policy.md
+++ b/docs/ship-policy.md
@@ -58,6 +58,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
| `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. |
| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log ` from its run on the gated head. Needs `min_gstack_version`. |
| `review_scope` | `frozen` \| `diff-plus-blocking` \| `all` | `frozen` | The finding scope of `/ship`'s review loop; see [review_scope](#review-scope). `frozen` fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. `diff-plus-blocking` is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. `all` is the earlier loop: every finding is fixed on every pass. Needs `min_gstack_version` when not `frozen`. |
+| `review_exclude_paths` | git glob pathspecs | `[]` | Content files (data, docs, binary, `.log`) `/ship`'s reviewers leave out; read from the base only, with code, gate, config, executable and named-by-code files kept. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. |
| `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. |
| `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. |
| `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
@@ -1252,6 +1253,127 @@ opens no fence, and a fence that never closes stays prose, along with the rest
of the body. Data PRs (evals,
measurements, sweeps) run it before READY so the body's numbers are the run's.
+
+## `review_exclude_paths`: what reviewers leave out
+
+Since the release that added it (`REVIEW_EXCLUDE_KEY_VERSION` in
+`lib/ship-policy.ts`). A results PR can carry thousands of receipt files that
+no reviewer needs to read. `review_exclude_paths` lists git glob pathspecs
+whose content files `/ship`'s reviewers leave out:
+
+```json
+{ "review_exclude_paths": ["receipts/**"], "min_gstack_version": "" }
+```
+
+**Read from the base only.** `gstack-review-plan` reads the policy committed on
+the base commit it already resolved (`origin/`, or ``), never
+the branch's copy, so a branch cannot exclude its own files. A branch that adds
+the key gets `REVIEW_EXCLUDE: none (head sets review_exclude_paths; it applies
+after it lands on )` and a normal review. A base policy that does not
+validate stops the plan (`POLICY_INVALID`), and a base git cannot read stops it
+(`POLICY_SOURCE_UNAVAILABLE`); neither turns exclusion off quietly.
+
+**Git's glob dialect.** Each pattern is a `:(top,glob)` pathspec: root-anchored
+and case-sensitive. The matched set comes from git itself (`git diff --raw -z`),
+so the plan, the receipt and what readers see are one set. Untracked files are never part of it: no reader's `git diff` shows them, and the receipt reads the committed head.
+
+| Pattern | gitignore would match | `review_exclude_paths` matches |
+|---|---|---|
+| `receipts/**` | everything under `receipts/` at any depth | everything under the top-level `receipts/` |
+| `receipts/*` | files directly in `receipts/` | files directly in `receipts/` only (`*` never crosses `/`) |
+| `data/` | any `data/` directory at any depth | nothing: write `data/**` (and `**/data/**` for any depth) |
+
+A pattern that matches no changed file is listed as `unmatched=` on the plan line.
+
+**What is excluded, and what is kept.** A matched file is excluded only when it
+is content: a data file (JSON, JSONL, CSV, TSV, images), a docs-class file, a
+`.log` file, or a file git reports as binary (`-\t-` in `--numstat`; readers would only see `Binary files differ`) whose bytes hold a NUL in their first 8,000, so a branch's `.gitattributes` cannot make a text file binary content. A binary file is content even under
+`config/` or in a skill directory; only `policy-dir`, `gate-path`, `executable`, `link`, `gate-tagged`, or a source or script extension (`code`) keep it.
+Everything else stays in every reader's diff with one reason code:
+
+| Reason | Kept because |
+|---|---|
+| `policy-dir` | it is under `.gstack/` or `.github/` |
+| `gate-path` | it is a gate path (lib/review-depth.ts) |
+| `executable` | its old or new mode is executable |
+| `link` | its old or new mode is a symlink or a submodule commit |
+| `agent-prose` | it is a skill, section, resolver, template or rule file |
+| `config` | it is a data-shaped config file (`package.json`, `tsconfig*.json`, `config/`, dotfiles) |
+| `gate-tagged` | the gate scan tagged one of its hunks |
+| `code` | it is code or a test, not content; a binary file with a source or script extension (`.js`, `.py`, `.sh`, `.wasm`) counts as code |
+| `secret-line` | an added line reads a credential (the targeted pass's secret rule) |
+| `named-by-code` | a code or test file that stays in review (not docs, a data file, a `.log` or binary, and not itself excluded) names its full repository path, at the head or the merge base |
+| `consumer-scan-unavailable` | the `git grep` for those names failed, so every candidate is kept; also a live binary file whose bytes could not be read |
+| `gate-scan-unavailable` | the gate scan failed or did not run at the head, so no hunk's tag is known and every candidate is kept |
+
+Only a full path counts as a name. A basename such as `result.json` would keep
+every receipt in the tree, and a directory reference such as `receipts/` (the
+harness that writes the receipts names it) keeps nothing: the plan prints an
+informational `REVIEW_EXCLUDE_REFERENCE:` line naming the code that reads the
+folder. A data file never keeps another file: a results manifest such as
+`docs/receipts-manifest.json` lists every receipt by full path, and it is
+content, not a program that reads them. The name scan is one uncapped `git grep -F -f` over the candidates'
+paths, not the collector's capped consumer scan.
+
+**What readers run.** Every reader's `DIFF_CMD:` gains one
+`':(top,exclude,glob)'` item per applied pattern, and the fixture
+command the security specialist, the Red Team and the native adversarial pass
+read through gets the same items. A git exclude beats any positive pathspec, so
+kept files under an applied pattern come back through a second command,
+`DIFF_KEEP: git diff -- ':(top,literal)' …`, which every
+brief names. That command is bounded by its argument bytes (24,000 across all
+patterns, under Windows' command-line limit): a pattern whose kept files would
+pass the bound is not applied, and the plan line says so as
+`skipped=:kept=:bytes=`. Every brief also gets one line:
+`Excluded by base policy [@ (selection ): files, bytes
+on this branch matching . They are not in your diff; do not report on
+their absence.`
+
+**Depth, numbers and the targeted pass.** Excluded paths count as docs for the
+review depth everywhere it is computed (`gstack-review-plan`, the receipt
+writer, carry and the queue), and their lines never trigger the numeric-claim
+scan or the targeted money, gate, auth and secret classes. Defects inside a
+correctly excluded data file are coverage the owner gives up by policy.
+
+**The plan line.** `REVIEW_EXCLUDE: patterns=/ excluded=
+bytes= kept= content=:,… source=][@ digest=`
+(`content=` counts the excluded files by kind: `binary`, `data`, `docs`, `log`), then up to 20
+`REVIEW_EXCLUDE_KEPT: ` lines (`--json` lists every one). A
+repository with no key anywhere prints none of these lines, and its plan, briefs
+and diff commands are byte for byte what they were.
+
+**The receipt, carry and the queue.** Each rendered brief records the digest it
+names in the review ledger, and `gstack-review-dispatch verify` prints
+`REVIEW_EXCLUDE_RECORD: digest=` when every reader's newest brief names the
+same one (a mixed set makes it `VERIFY: dirty`). `gstack-ship-receipt write
+--review-exclude `, with that digest, records `review_exclude` (source, patterns, counts, the gate-tagged kept
+paths and three hashes). The `digest` hashes the declared patterns and the
+excluded and kept lists; `source` is provenance only. Write, carry and the queue
+each recompute the digest at the current base and compare it, so a base merge
+or a moved base that leaves the patterns and the lists unchanged still carries
+and binds. A different digest means the reviewers read another selection:
+write refuses with `RECEIPT_INVALID field=review_exclude`, carry with
+`RECEIPT_CARRY_REFUSED review_exclude changed`, and the queue with
+`RECEIPT_INVALID field=review_exclude expected= recorded=`. The
+fix is to rerun the review, never a receipt write alone.
+
+**Adopting it.** (1) Open a PR on the base that adds the patterns and
+`min_gstack_version` at least `REVIEW_EXCLUDE_KEY_VERSION`, and ship it like any
+policy change. (2) Once it merges, bring the base into the results branch (a
+crew needs its coordinator's word for that merge) and rerun `/ship`; the plan
+prints `REVIEW_EXCLUDE:`. (3) Check the kept list: a file you expected to be
+excluded names its reason.
+
+
+**The review ceiling.** Independently of the key, `gstack-review-dispatch` reads
+each reviewer's diff once before it writes a manifest or a brief (`pass`, `stage`
+and `render`). A diff over 2,000,000 bytes (about 500K tokens) stops with
+[`REVIEW_DIFF_TOO_LARGE`](troubleshooting.md#review-diff-too-large), exit 3, and a
+fix chosen by where the policy stands; a `git diff` that fails or runs past
+120 seconds stops with
+[`REVIEW_DIFF_UNREADABLE`](troubleshooting.md#review-diff-unreadable), exit 1.
+There is no override flag: the recovery is a policy PR on the base, or a split.
+
## `review_scope`: how the review loop converges
diff --git a/docs/skills.md b/docs/skills.md
index d5315e262..18dadd110 100644
--- a/docs/skills.md
+++ b/docs/skills.md
@@ -631,6 +631,8 @@ One exception: a shortcut you took deliberately and logged. A `gstack-shortcut(d
**Design pass.** When the diff touches frontend files, the Design specialist reads `review/design-checklist.md`, which is generated from `lib/design-catalog.ts`, so `/review`, `/ship`, and `/design-review` flag the same patterns under the same rule ids. If you have [impeccable](https://impeccable.style) installed, its engine scans the changed frontend files first (`.dart` files get the checklist pass only): its rows bucket by tier (auto-fix, ask, possible), a detector hit and a checklist hit at the same file:line collapse into one row, and your repo's `.impeccable/config*.json` ignores are read as settled decisions. Without it, the checklist pass runs alone.
+**PR type.** The checklist starts by naming the kind of PR from the changed paths: docs-only, CI/infra, scripts/tooling, application or mixed, and the review's first line says which (`PR type: `). Application and mixed diffs run every category as before. A CI/infra, scripts/tooling or docs-only diff always gets Gate Integrity, Shell Injection, Completeness Gaps, Distribution & CI/CD Pipeline and Repo Conventions & Platform Fit; every other category with nothing to check is listed once, in one line, as not applicable, and never counts as passed. Repo Conventions & Platform Fit flags new files that don't match the languages and tools the repo already uses or the platforms its CI runs on, such as a `.bat` build script in a bash-and-Makefile repo.
+
### Example
Suppose the smart listing flow is implemented and the tests are green.
@@ -1538,7 +1540,7 @@ Install Greptile on your GitHub repo at [greptile.com](https://greptile.com) —
The problem with any automated reviewer is triage. Greptile is good, but not every comment is a real issue. Some are false positives. Some flag things you already fixed three commits ago. Without a triage layer, the comments pile up and you start ignoring them — which defeats the purpose.
-gstack solves this. `/review` and `/ship` are now Greptile-aware. They read Greptile's comments, classify each one, and take action:
+gstack solves this. `/review` and `/ship` read your review bots' comments (Greptile, CodeRabbit, Macroscope, or any bot you name), classify each one, and take action:
- **Valid issues** get added to the critical findings and fixed before shipping
- **Already-fixed issues** get an auto-reply acknowledging the catch
@@ -1548,7 +1550,11 @@ The result is a two-layer review: Greptile catches things asynchronously on the
### Learning from history
-Every false positive you confirm gets saved to `~/.gstack/greptile-history.md`. Future runs auto-skip known FP patterns for your codebase. And `/retro` tracks Greptile's batting average over time — so you can see whether the signal-to-noise ratio is improving.
+Every false positive you confirm gets saved to `~/.gstack/review-bot-history.md`, tagged with the bot that made it. Future runs auto-skip that bot's known FP patterns for your codebase. And `/retro` tracks each bot's batting average over time — so you can see whether the signal-to-noise ratio is improving.
+
+### Choosing your bots
+
+The bots are the two lists `gstack-pr-wait` uses: `gstack-config set pr_wait_gating_bots greptile,macroscope` (waited for, then triaged) and `gstack-config set pr_wait_advisory_bots coderabbit,copilot` (triaged, never waited for). Set both to `none` to turn bot triage off. Greptile's early review (`/ship` opening the PR early) runs only while `greptile` is in one of the lists.
### Example
diff --git a/docs/standalone-binaries.md b/docs/standalone-binaries.md
index 8518318d6..71f2ef47c 100644
--- a/docs/standalone-binaries.md
+++ b/docs/standalone-binaries.md
@@ -21,6 +21,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-evidence` | **Verification-evidence ledger** — `run --label -- ` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint (tracked and untracked files that are not gitignored, minus an untracked `.bun/` folder at the repo root, which Bun can create during a run); a run whose files changed while it ran is recorded without a fingerprint and with up to five of the changed paths, which `check` names in its STALE reason. `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. |
| `gstack-flaky` | **Flaky-test rerun for any JUnit runner** — `run --junit -- ` runs your test command (Playwright, `bun test`, jest, vitest, pytest) and, when at most `--max-reruns` (3) tests failed and the runner made no retries of its own, reruns the whole command once (`--rerun-scope test` opts in to rerunning only the failed tests). A test that fails and then passes on that rerun is a flaky pass: the run stays green and one redacted line per flaky pass or failed rerun goes on a ledger (`GSTACK_FLAKE_LEDGER_JUNIT`, default `/flake-ledger-junit.jsonl`). A missing or malformed report, a crash or a killed runner never passes. `quarantine add|renew|release|check|report` keeps `.gstack/quarantine.json`: a quarantined test still runs, gets up to 3 attempts of the whole command, passes only on a full green attempt and is never counted as a pass (`run --base ][` reads the registry from the base branch). `ids --junit ` prints a report's test ids. Exit 0 pass, 1 a test failed, 2 usage, 3 no usable report. [Guide](flaky.md). |
| `gstack-pr-wait` | **PR event watcher** — `gstack-pr-wait [--repo owner/name] [--until green\|ready\|any-change] [--interval 20] [--timeout 60] [--exclude CHECK,...]` polls one pull request through `gh` and prints one stdout line per real event (a check failed with its link, all checks finished, a comment or review from someone other than the author, a review bot finished on the head, the base moved and its new commits touch this PR's files, BEHIND or DIRTY, merged or closed), with progress on stderr, so a monitor wakes the agent once per event instead of on a timer. `green` gates on every check on the head (an advisory bot's review is never waited for, its checks gate like any other), as `gstack-ci-gate` does; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission; two identical polls confirm a check set is complete; `--exclude` leaves a named non-required check out of the verdict as `gstack-ci-gate --exclude` does (a required name is refused and gates). Exit 0 when the condition is met, 1 when it needs action or `--timeout` passes (`PR_WAIT_BLOCKED`), 2 on bad input, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`). State under `/pr-wait/`, baseline discarded after 6 hours. Review bots are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`, `pr_wait_advisory_bots coderabbit` (advisory bots are reported, never waited on). `/land-and-deploy` Step 3 is one call of it. Design ported from tstack's pr-wait (MIT, Theo Browne; NOTICE.md). [Exit codes](troubleshooting.md#gstack-pr-wait). |
+| `gstack-review-bots` | **Review-bot comments** — `fetch [--pr ]` reads every root line comment, conversation comment and newest review body from each bot in `pr_wait_gating_bots` and `pr_wait_advisory_bots` (Bot accounts only, all pages, through `gh`) into a private directory with per-bot counts, or prints `REVIEW_BOTS: skip` or `REVIEW_BOTS: error` (never a false zero). `record --dir --id --type fix\|fp\|already-fixed --category ` appends one triage outcome to the project and global `review-bot-history.md`, taking the bot and file from the fetched comment. `/review` Step 2.5 and `/ship` Step 10 run it. [Triage reference](../review/greptile-triage.md). |
| `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue `, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. |
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone or iPad over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](howto-ios-testing-with-gstack.md). |
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index d78dfb536..56add0826 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -2434,7 +2434,10 @@ default `fetch-depth: 1`), a remote branch never fetched
first parent is missing from a shallow history. The tool never guesses a
base, because an empty diff against a wrong or absent base would read as "no
gate edits" and pass review. `gstack-diff-scope` prints `SCOPE_ERROR=no_base`
-(all flags false, exit 2) for the same cause.
+(all flags false, exit 2) for the same cause, and also when no base was given
+and none resolves because `origin/HEAD` names the checked-out branch (or its
+upstream, or HEAD's own commit) and no PR base, live remote HEAD, `origin/main`
+or `origin/master` resolves (its comment line says so).
**Fix.** Run the command the line prints. It is built for this checkout:
`git fetch origin +:refs/remotes/origin/` for an unfetched
@@ -3330,6 +3333,72 @@ block. For the `--head` refusal, check out that commit and write there. For a
targeted refusal, run the targeted reader on HEAD, `gstack-review-dispatch verify
--targeted-out .gstack/tmp/targeted.json`, and write again with
`--review-targeted @.gstack/tmp/targeted.json`.
+
+**`field=review_exclude`.** `gstack-ship-receipt write` and the queue's receipt check also
+use this code when the receipt's review_exclude digest (or the
+`--review-exclude ` write was given) is not the digest HEAD has under the
+base policy's [`review_exclude_paths`](ship-policy.md#review-exclude-paths). The line
+names `expected=` (now), `recorded=` (what the review read, `none` when it read
+without exclusion) and `source=` (the base commit). The reviewers read another
+selection than the one the head now has, so a receipt write alone cannot clear
+it: rerun `/ship`'s review on the head (`gstack-review-dispatch pass --base `),
+run `gstack-review-dispatch verify --ledger `, which prints
+`REVIEW_EXCLUDE_RECORD: digest=` from the briefs the readers got, then
+`gstack-ship-receipt write --pr --review-exclude --body `.
+
+
+### `REVIEW_DIFF_TOO_LARGE`
+
+**Meaning.** One reviewer's diff is over the 2,000,000-byte review ceiling
+(about 500K tokens). `gstack-review-dispatch pass`, `stage` and `render` read
+each reader's diff once before writing anything, and stop (exit 3, nothing
+written) rather than hand a reader more than it can read. The line names the
+reader, its bytes, the five largest top-level folders (`top=:`) and
+the base policy commit.
+
+**Fix.** The line prints the fix for where the base policy stands:
+
+- **No key:** copy the printed JSON (`{"review_exclude_paths":[…],"min_gstack_version":"…"}`)
+ into `.gstack/ship-policy.json` on the base as its own PR. A lane cannot land on
+ the base itself: ask the repo owner or the coordinator. Once it merges, bring
+ the base into the branch and rerun once; if it is still over, split the PR.
+- **The branch declares the key but the base does not:** only the base copy
+ applies; land that hunk on the base first, as above.
+- **A pattern was not applied** (`skipped=` on the `REVIEW_EXCLUDE:` line): its kept
+ files need more than 24,000 argument bytes. Narrow the pattern, move the kept
+ files out of it, or split the PR.
+- **Still over after exclusion:** add the printed folders to the base policy, or
+ split the PR.
+
+There is no override flag. See [the review ceiling](ship-policy.md#review-diff-ceiling).
+
+
+### `REVIEW_DIFF_UNREADABLE`
+
+**Meaning.** git could not read a reviewer's diff, so the pass stopped (exit 1)
+before writing a manifest or a brief. `reason=timeout` means `git diff` ran past
+`timeout_ms` (120,000 ms; fixed, no flag); `reason=git-exit` gives git's exit code.
+The line carries the diff base, git's first stderr line with remote URLs
+replaced by ``, and `log=`, a file under the run directory holding git's whole
+stderr, redacted the same way. It is also printed when git cannot list the
+files `review_exclude_paths` matches.
+
+**Fix.** By reason: a timeout, rerun once unchanged, then split the PR or
+exclude its largest data folders on the base; git exit 128 (a missing object or
+an unfetched base), `git fetch origin ` and rerun once; anything else, stop
+and ask a person with the log file.
+
+
+### `REVIEW_EXCLUDE_REFERENCE`
+
+**Meaning.** Informational, never a stop. A kept code file names a folder that
+`review_exclude_paths` excludes (for example the harness that writes
+`receipts/`). A folder reference does not keep files; only a full file path
+does, so readers see that code without the files it reads.
+
+**Fix.** Nothing for the review itself. If the code's correctness depends on the
+excluded files, narrow the pattern on the base so they stay in the diff.
+
### `EXECUTION_UNKNOWN`
@@ -3707,7 +3776,12 @@ directory, or under a scratch name (`zz-*`, `tmp*`, `scratch*`).
appears with its old value in a test file the branch did not update — nightly-
and Heavy-only files included.
-**Fix.** Update every listed `file:line`, or keep the old value.
+**Fix.** Update every listed `file:line`, or keep the old value. When a test
+keeps the old value on purpose (the refused input after a minimum version goes
+up), mark that line: `// pregate-literal: NAME= ` on the line
+or on the comment line directly above it. The marker waives only that line, and
+the check lists it as `waived:`. Don't use `warn: ["literals"]` for this, since
+that turns the check off for every constant.
### `PREGATE_NO_LANE`
@@ -3843,14 +3917,14 @@ the lane log, then rerun or split the selection. A tool missing from PATH is
### `PREGATE_STALLED`
-**Meaning.** A test command the lanes check (or a guard) launched wrote nothing to its log for the stall timeout (default 300 s,
+**Meaning.** A test command the lanes check (or a guard) launched wrote nothing to its log, and its process group used under a tenth of one CPU (Windows: log growth alone), for the stall timeout (default 300 s,
`stall_timeout_s` in the base branch's `.gstack/pregate.json` or the user-owned file, or
`--stall-timeout`). pregate killed its process group and names the last test
file started. The check is incomplete, never pass.
**Fix.** Open the printed log, find the hang in the named file (a blocked
child, a server that never exits, a synchronous wait), fix it and rerun
-`gstack-pregate --stage tests`. A test that is legitimately quiet for longer
+`gstack-pregate --stage tests`. A test that legitimately waits quietly for longer
needs a larger `--stall-timeout`; `0` disables the stall kill and leaves only
`--timeout`. The `--remote` gate never stalls: it is bounded by `--timeout`
(else `timeout_s`), so a gate that waits on CI needs a timeout long enough for
@@ -5483,6 +5557,29 @@ wait`'s `--timeout`.
**Fix.** Run `wait` again (the pause stays valid until it expires), or find who
owns the base's red.
+
+### `BASE_CONTAINS_HEAD`
+
+**Meaning.** A base-relative step (`gstack-ship-queue ready`, `land` and
+`train` receipt binding, `gstack-review-plan`, `gstack-pregate`,
+`gstack-gate-diff`, `gstack-diff-scope`, `gstack-docs-candidate snapshot`)
+was given a base that is the head or already contains it, while the branch
+has commits its real base lacks. The comparison would read the branch against
+itself and report no changes, a false green. The usual cause is a checkout
+made by a PR flow: `refs/remotes/origin/HEAD` names the PR branch and there is
+no local `main` (GST-774). The detail names the base, the head, and the real
+base (the PR's base from `gh`, else the remote's live HEAD from
+`git ls-remote --symref`, else `origin/main`, else `origin/master`). For
+a pull request's head the queue refuses whenever the base contains it. On the
+base branch itself, on a detached HEAD, and on a fresh branch with no commits
+past its base, the step runs as before. `gstack-diff-scope` reports it as
+`SCOPE_ERROR=base_contains_head` (all flags false, exit 2) with the reason on a
+comment line.
+
+**Fix.** Pass the real base (`--base main`, or the PR's base branch). When
+`origin/HEAD` names the checked-out branch, `git remote set-head origin main`
+repairs every default at once.
+
### `QUEUE_RECEIPT_DEPTH_RAISED`
diff --git a/lib/default-base.ts b/lib/default-base.ts
new file mode 100644
index 000000000..b1cf27a6d
--- /dev/null
+++ b/lib/default-base.ts
@@ -0,0 +1,111 @@
+/**
+ * default-base — the base branch a command uses when none is given (GST-511), and the
+ * refusal every base-relative step shares when its base is the branch itself (GST-774).
+ * A remote's HEAD counts only when it names a branch other than the checked-out one, its
+ * upstream, or HEAD's own commit: a clone made by a PR flow can point origin/HEAD at the PR
+ * branch, and that base diffs the branch against itself, an empty diff every gate passes.
+ * Past it: the PR's base from gh, then the remote's live HEAD (`git ls-remote --symref`,
+ * which keeps a default branch such as develop or trunk), then `/main`, then
+ * `/master`. Both network reads use a 5 s timeout with prompts off and ignore any
+ * failure; gh names no base for a PR from another repository. bin/gstack-diff-scope reads
+ * both through this file's command line.
+ */
+import { spawnSync } from 'node:child_process';
+import { resultLine } from './result-codes';
+
+export interface DefaultBase {
+ /** The base branch name, or null when nothing resolves. */
+ branch: string | null;
+ /** The remote whose HEAD named `branch`; unset for the PR base, main and master. */
+ remote?: string;
+ /** The first remote HEAD skipped because it names the checked-out branch. */
+ skipped?: { remote: string; branch: string };
+}
+
+/** gh's jq for the PR's base branch; a PR from another repository names a branch origin may not hold, so it names none. */
+const PR_BASE = 'if .isCrossRepository then "" else .baseRefName end';
+
+function read(cmd: string, args: string[], cwd: string, timeout: number): string {
+ const r = spawnSync(cmd, args, {
+ cwd, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'],
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1' },
+ });
+ return r.status === 0 ? (r.stdout ?? '').trim() : '';
+}
+
+const commit = (cwd: string, ref: string) => read('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], cwd, 30_000);
+const ancestor = (cwd: string, a: string, b: string) => spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd, timeout: 30_000, stdio: 'ignore' }).status === 0;
+
+export function defaultBaseBranch(cwd: string, remotes: readonly string[] = ['origin']): DefaultBase {
+ const current = read('git', ['symbolic-ref', '--quiet', 'HEAD'], cwd, 30_000).replace(/^refs\/heads\//, '');
+ const upstream = current ? read('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], cwd, 30_000) : '';
+ const headSha = commit(cwd, 'HEAD');
+ let skipped: DefaultBase['skipped'];
+ for (const remote of remotes) {
+ const prefix = `refs/remotes/${remote}/`;
+ const target = read('git', ['symbolic-ref', '--quiet', `${prefix}HEAD`], cwd, 30_000);
+ if (!target.startsWith(prefix)) continue;
+ const branch = target.slice(prefix.length);
+ if (branch !== current && upstream !== `${remote}/${branch}` && commit(cwd, target) !== headSha) return { branch, remote };
+ skipped ??= { remote, branch };
+ }
+ const pr = current ? read('gh', ['pr', 'view', '--json', 'baseRefName,isCrossRepository', '-q', PR_BASE], cwd, 5_000).split('\n')[0] : '';
+ if (pr) return { branch: pr, skipped };
+ if (skipped) {
+ const live = /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(read('git', ['ls-remote', '--symref', skipped.remote, 'HEAD'], cwd, 5_000))?.[1];
+ if (live) return { branch: live, remote: skipped.remote, skipped };
+ }
+ for (const name of ['main', 'master']) {
+ if (remotes.some((remote) => commit(cwd, `refs/remotes/${remote}/${name}`))) return { branch: name, skipped };
+ }
+ return { branch: null, skipped };
+}
+
+/** Why there is no default base: a remote HEAD named the checked-out branch and nothing past it resolved. */
+export function noDefaultBaseLine({ remote, branch }: { remote: string; branch: string }): string {
+ return `${remote}/HEAD names the checked-out branch ${branch}, and no PR base, ${remote}/main or ${remote}/master resolves; pass the base explicitly or run git remote set-head ${remote} `;
+}
+
+/**
+ * Why `base` cannot be the base of a comparison with `head` (BASE_CONTAINS_HEAD), or null. A
+ * base that is the head or already contains it compares the branch with itself, so the step
+ * would report no changes. A pull-request head (`head` other than HEAD) is refused whenever the
+ * base contains it. The checked-out branch is refused only when its real base (defaultBaseBranch)
+ * is another branch that lacks HEAD, so the branch has commits this base hides; on the base branch
+ * itself, on a detached HEAD, on a fresh branch with no commits past its base, or with no real base
+ * resolvable, it is null and the step keeps today's result.
+ */
+export function baseContainsHead(cwd: string, base: string, head = 'HEAD'): string | null {
+ if (!ancestor(cwd, head, base)) return null;
+ const baseSha = commit(cwd, base);
+ const headSha = commit(cwd, head);
+ if (!baseSha || !headSha) return null;
+ const what = `${base} (${baseSha.slice(0, 12)}) ${baseSha === headSha ? 'is' : 'already contains'}`;
+ if (head !== 'HEAD') return `${what} the head ${headSha.slice(0, 12)}, which then has no changes of its own`;
+ const branch = read('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd, 30_000);
+ if (!branch) return null;
+ const real = defaultBaseBranch(cwd);
+ if (!real.branch || real.branch === branch) return null;
+ const realRef = `${real.remote ?? 'origin'}/${real.branch}`;
+ const realSha = commit(cwd, `refs/remotes/${realRef}`);
+ if (!realSha || ancestor(cwd, headSha, realSha)) return null;
+ return `${what} HEAD ${headSha.slice(0, 12)} on ${branch}, whose commits ${realRef} lacks; the real base is ${real.branch}`;
+}
+
+if (import.meta.main) {
+ const [verb, ...args] = process.argv.slice(2);
+ const cwd = process.cwd();
+ if (verb === 'default') {
+ const found = defaultBaseBranch(cwd);
+ if (found.branch) console.log(found.branch);
+ else if (found.skipped) console.log(noDefaultBaseLine(found.skipped));
+ process.exitCode = found.branch ? 0 : found.skipped ? 3 : 1;
+ } else if (verb === 'check' && args[0] && args.length <= 3) {
+ const refused = baseContainsHead(cwd, args[0], args[1] || 'HEAD');
+ if (refused) console.log(resultLine(args[2] || 'gstack', 'BASE_CONTAINS_HEAD', refused));
+ process.exitCode = refused ? 1 : 0;
+ } else {
+ console.error('usage: default-base.ts default | check [] []');
+ process.exitCode = 2;
+ }
+}
diff --git a/lib/gate-diff/git.ts b/lib/gate-diff/git.ts
index ecc03e578..adc46fbbf 100644
--- a/lib/gate-diff/git.ts
+++ b/lib/gate-diff/git.ts
@@ -13,6 +13,7 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
+import { defaultBaseBranch, type DefaultBase } from '../default-base';
import type { CommitInfo, FileDiff, FileStatus, Hunk, Unsupported } from './types';
export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
@@ -63,20 +64,19 @@ function remoteNames(cwd: string): string[] {
}
/**
- * The remote's HEAD target → `main` → `master` (the gstack-diff-scope chain),
- * as a ref name; the remotes are walked in resolveBase's order, so the default
- * and the fix line come from the same remote.
+ * The default base (lib/default-base.ts, the gstack-diff-scope chain) as a ref
+ * name: a remote's HEAD target as `/` unless it names the
+ * checked-out branch, the PR's base, `main`, `master`; the remotes are walked
+ * in resolveBase's order, so the default and the fix line come from the same
+ * remote. With no remote naming one, a local main or master, else `main`.
+ * `skipped` when a remote HEAD named the checked-out branch and nothing past it
+ * resolved: the caller fails closed instead of diffing the branch against itself.
*/
-export function defaultBase(cwd: string): string {
- const remotes = remoteNames(cwd);
- for (const remote of remotes) {
- const head = gitText(['symbolic-ref', '-q', `refs/remotes/${remote}/HEAD`], cwd);
- if (head) return head.replace(/^refs\/remotes\//, '');
- }
- for (const name of ['main', 'master']) {
- if (resolvesToCommit(name, cwd) || remotes.some((remote) => resolvesToCommit(`${remote}/${name}`, cwd))) return name;
- }
- return 'main';
+export function defaultBase(cwd: string): { ref: string } | { skipped: NonNullable } {
+ const found = defaultBaseBranch(cwd, remoteNames(cwd));
+ if (found.branch) return { ref: found.remote ? `${found.remote}/${found.branch}` : found.branch };
+ if (found.skipped) return { skipped: found.skipped };
+ return { ref: ['main', 'master'].find((name) => resolvesToCommit(name, cwd)) ?? 'main' };
}
export function isShallow(cwd: string): boolean {
@@ -89,7 +89,8 @@ export function isShallow(cwd: string): boolean {
* (`refs/remotes/origin/main` for `main`), the base /ship merges against and
* the one review-collect's collectLive and review-dispatch read: a stale local
* `main` behind it would scan upstream commits the branch only inherited
- * (#3650, GST-883), so a local branch that differs is reported as `differs`.
+ * (#3650, GST-883), so a local branch that differs says how: `behind` it,
+ * `ahead` of it (commits the branch would land unscanned) or `diverged` (GST-462).
* Without a related origin twin the related local branch wins over any other
* remote, so a fork's branch cannot replace it; it is `fallback` when origin
* lacks the branch or shares no history with HEAD (named as `unrelatedRemote`),
@@ -102,7 +103,7 @@ export function isShallow(cwd: string): boolean {
* cannot stand in for them, and a remote-qualified name (`upstream/main`) is
* taken as given.
*/
-export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' | 'differs' | 'fallback'; unrelatedRemote?: string } | null {
+export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' | 'behind' | 'ahead' | 'diverged' | 'fallback'; unrelatedRemote?: string } | null {
const related = (sha: string) => !head || mergeBase(sha, head, cwd) !== null;
const direct = () => resolvesToCommit(ref, cwd);
if (!isBranchName(ref)) { const sha = direct(); return sha ? { ref, sha } : null; }
@@ -115,7 +116,10 @@ export function resolveBase(ref: string, cwd: string, head?: string): { ref: str
const sha = resolvesToCommit(`refs/remotes/` + remote + '/' + ref, cwd);
if (!sha || !related(sha)) return null;
const tracking = remote + '/' + ref;
- return sha === local ? { ref: tracking, sha } : { ref: tracking, sha, local: !local ? 'missing' as const : localRelated ? 'differs' as const : 'unrelated' as const };
+ if (sha === local) return { ref: tracking, sha };
+ if (!local || !localRelated) return { ref: tracking, sha, local: !local ? 'missing' as const : 'unrelated' as const };
+ const fork = mergeBase(local, sha, cwd);
+ return { ref: tracking, sha, local: fork === local ? 'behind' as const : fork === sha ? 'ahead' as const : 'diverged' as const };
};
const hasOrigin = remotes.includes('origin');
const origin = hasOrigin ? twin('origin') : null;
diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts
index 7f18a8c46..1dbeb7caa 100644
--- a/lib/gbrain-local-status.ts
+++ b/lib/gbrain-local-status.ts
@@ -54,6 +54,7 @@ import { atomicWriteSync } from "./fs-atomic";
import { homedir } from "os";
import { dirname, join } from "path";
import { buildGbrainEnv, gbrainChildCwd, gbrainConfigDir, isExecTimeout, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
+import { classifyGbrainMcpEntry } from "./gbrain-mcp-locality";
import { resolveStateRoot } from "./state-root";
export type LocalEngineStatus =
@@ -187,16 +188,11 @@ export function hasRemoteOnlyGbrainMcp(
} catch {
return false;
}
- // Same classification rules as gstack-gbrain-detect's detectMcpMode tier 3,
- // including the #2051 name generalization (gbrain, gbrain-remote, gbrain_work).
- const classify = (entry: McpEntry): "remote" | "local" | null => {
- const mtype = entry.type || entry.transport || "";
- if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote";
- if (mtype === "stdio") return "local";
- if (entry.url) return "remote";
- if (entry.command) return "local";
- return null;
- };
+ // The classifier gstack-gbrain-detect's detectMcpMode uses, so a loopback
+ // `gbrain serve --http` over this machine's own engine is local here too
+ // (#3539): detect never prints local-stdio next to thin-client. Name
+ // generalization (#2051: gbrain, gbrain-remote, gbrain_work) is below.
+ const classify = (entry: McpEntry): "remote" | "local" | null => classifyGbrainMcpEntry(entry, env ?? process.env);
/** Extract the gbrain-relevant entries from an mcpServers object. */
const gbrainEntries = (servers: unknown): Record => {
const out: Record = {};
diff --git a/lib/gbrain-mcp-locality.ts b/lib/gbrain-mcp-locality.ts
new file mode 100644
index 000000000..559f15fb9
--- /dev/null
+++ b/lib/gbrain-mcp-locality.ts
@@ -0,0 +1,139 @@
+/**
+ * gbrain-mcp-locality — is a registered gbrain MCP entry a remote brain? (#3539)
+ *
+ * An http/url/sse MCP entry used to mean "Path 4: the brain lives on another
+ * machine", so transcripts were staged for a brain admin's pull instead of
+ * going through `gbrain import`. gbrain's own recommended setup for local
+ * agents on a Postgres brain is one resident `gbrain serve --http` on
+ * loopback, registered by `gbrain bootstrap harness`. That entry is HTTP but
+ * the brain is on this machine, and no brain admin will ever index the
+ * staged pages.
+ *
+ * Rule: an HTTP entry is LOCAL when its URL is http(s) on a loopback host AND
+ * this machine's gbrain config names a Postgres engine (a `database_url`,
+ * engine not `pglite`) and no `remote_mcp` thin-client marker. Every other
+ * HTTP entry stays remote, as before. A PGLite config does not count: Path 4's
+ * split-engine setup pairs a remote brain with a local PGLite for code search,
+ * and a remote brain reached through a tunnel on a loopback port must stay
+ * remote there; a PGLite brain held open by a resident serve is engine-locked
+ * for imports anyway. stdio entries are local, as before. Any
+ * other URL scheme (ws:, file:) stays remote. The marker test is gbrain's own
+ * isThinClient() truthiness (`!!remote_mcp`), so `remote_mcp: 0` or `""` is
+ * not a marker.
+ *
+ * File reads only: no subprocess, no network (a classifier network probe is
+ * the #1964 pathology).
+ *
+ * bin/gstack-skill-start carries a jq copy of this rule for its hot path. It
+ * is a conservative subset, not an exact mirror: its URL regex accepts only
+ * canonical loopback spellings (localhost, 127.a.b.c without leading zeros,
+ * [::1]), so whenever jq says local this file says local too, while some
+ * spellings this file accepts (127.1, 0x7f.1, 127.0.0.1.) stay remote in jq.
+ * The config half matches exactly. test/gbrain-mcp-locality.test.ts runs both
+ * copies over the same table and enforces that relation.
+ */
+
+import { readFileSync } from "fs";
+import { join } from "path";
+import { gbrainConfigDir } from "./gbrain-exec";
+
+export interface GbrainMcpEntry {
+ type?: string;
+ transport?: string;
+ command?: string;
+ url?: string;
+}
+
+/**
+ * True when the URL is http(s) and its host is localhost, 127.0.0.0/8 or ::1.
+ * A URL with userinfo, or containing `${`, is never loopback: Claude Code
+ * expands `${VAR}` in `url` before connecting, so `http://${X}@127.0.0.1/mcp`
+ * can dial any host.
+ */
+export function isLoopbackMcpUrl(url: string | undefined | null): boolean {
+ if (typeof url !== "string" || url.trim() === "" || url.includes("${")) return false;
+ let parsed: URL;
+ try {
+ parsed = new URL(url.trim());
+ } catch {
+ return false;
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
+ if (parsed.username || parsed.password) return false;
+ const host = parsed.hostname.toLowerCase();
+ if (host === "localhost" || host === "::1" || host === "[::1]") return true;
+ // WHATWG URL normalizes IPv4 forms (127.1, 0x7f.1) to dotted quads.
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
+}
+
+/**
+ * This machine's gbrain config (gbrainConfigDir()/config.json, honoring
+ * GBRAIN_HOME) names a Postgres engine (a non-blank database_url, engine not
+ * pglite) and carries no remote_mcp marker (truthy, as in gbrain's
+ * isThinClient).
+ */
+export function hasLocalPostgresEngine(env: NodeJS.ProcessEnv = process.env): boolean {
+ try {
+ const cfg = JSON.parse(readFileSync(join(gbrainConfigDir(env), "config.json"), "utf-8")) as {
+ engine?: unknown;
+ database_url?: unknown;
+ remote_mcp?: unknown;
+ } | null;
+ if (!cfg || typeof cfg !== "object") return false;
+ if (cfg.remote_mcp) return false;
+ return cfg.engine !== "pglite" && typeof cfg.database_url === "string" && cfg.database_url.trim() !== "";
+ } catch {
+ return false;
+ }
+}
+
+/** An HTTP MCP URL that reaches this machine's own Postgres gbrain engine. */
+export function isLocalHttpGbrain(url: string | undefined | null, env: NodeJS.ProcessEnv = process.env): boolean {
+ return isLoopbackMcpUrl(url) && hasLocalPostgresEngine(env);
+}
+
+/**
+ * Classify one MCP registration: "remote" for an HTTP brain on another
+ * machine, "local" for stdio or a loopback HTTP serve of the local engine,
+ * null when the entry carries no transport signal.
+ */
+export function classifyGbrainMcpEntry(
+ entry: GbrainMcpEntry | null | undefined,
+ env: NodeJS.ProcessEnv = process.env,
+): "local" | "remote" | null {
+ if (!entry || typeof entry !== "object") return null;
+ // Same precedence as the transport-only rule this replaces: explicit HTTP
+ // type, explicit stdio, then a bare url, then a bare command.
+ const mtype = entry.type || entry.transport || "";
+ const remoteUnlessLoopback = (): "local" | "remote" => (isLocalHttpGbrain(entry.url, env) ? "local" : "remote");
+ if (mtype === "url" || mtype === "http" || mtype === "sse") return remoteUnlessLoopback();
+ if (mtype === "stdio") return "local";
+ if (entry.url) return remoteUnlessLoopback();
+ if (entry.command) return "local";
+ return null;
+}
+
+/**
+ * Classify the gbrain line of `claude mcp list`. HTTP and SSE servers print
+ * as `gbrain: (HTTP) - ✓ Connected` (or `(SSE)`); stdio servers print
+ * their command with no transport marker.
+ *
+ * The URL token is everything between the name and the LAST ` (HTTP)` /
+ * ` (SSE)` marker. It is never cut at `)`: a cut turns
+ * `http://127.0.0.1)@evil.com/mcp` (host evil.com) into `http://127.0.0.1`.
+ * A token containing whitespace, or one that is not a loopback http(s) URL
+ * over a local engine, is remote. A line that names HTTP/SSE but has no
+ * such token is remote, as before; a line with neither is stdio (local).
+ */
+export function classifyGbrainMcpListLine(
+ line: string,
+ env: NodeJS.ProcessEnv = process.env,
+): "local" | "remote" {
+ const m = /^gbrain(?:[-_][\w-]*)?:\s+(.+)\s+\((?:HTTP|SSE)\)/.exec(line);
+ if (m) {
+ const token = m[1];
+ return !/\s/.test(token) && isLocalHttpGbrain(token, env) ? "local" : "remote";
+ }
+ if (/\b(?:http|HTTP)\b/.test(line) || /\((?:SSE|sse)\)/.test(line)) return "remote";
+ return "local";
+}
diff --git a/lib/greptile-early.ts b/lib/greptile-early.ts
index 3cec8256e..3707c9971 100644
--- a/lib/greptile-early.ts
+++ b/lib/greptile-early.ts
@@ -24,6 +24,7 @@
import * as fs from "fs";
import * as path from "path";
import { resolveStateRoot, readConfigKey, type StateRootEnv } from "./state-root";
+import { botLists, botOwning } from "./pr-wait";
import { remoteSlug } from "./remote-identity";
export interface EarlyEnv {
@@ -78,14 +79,14 @@ function readOr(file: string): string {
try { return fs.readFileSync(file, "utf8"); } catch { return ""; }
}
-/** A Greptile comment on any PR of this repo in the last 90 days (conversation or review comments). */
+/** A comment by Greptile's bot account (gstack-pr-wait's login rule, Bot accounts only) on any PR of this repo in the last 90 days. */
function recentComment(env: EarlyEnv): boolean {
const since = new Date(env.now() - COMMENT_WINDOW_DAYS * 86_400_000).toISOString();
for (const kind of ["issues", "pulls"]) {
const r = env.run("gh", ["api", `repos/{owner}/{repo}/${kind}/comments?sort=created&direction=desc&per_page=100&since=${since}`,
- "--jq", "[.[] | {login: .user.login, created: .created_at}]"]);
- const rows = r.status === 0 ? json>(r.stdout) : undefined;
- if (rows?.some(c => isGreptile(c.login) && c.created >= since)) return true;
+ "--jq", "[.[] | {login: .user.login, type: .user.type, created: .created_at}]"]);
+ const rows = r.status === 0 ? json>(r.stdout) : undefined;
+ if (rows?.some(c => c.type === "Bot" && botOwning(c.login, ["greptile"]) && c.created >= since)) return true;
}
return false;
}
@@ -129,6 +130,8 @@ function detect(env: EarlyEnv): number {
env.out(`Greptile early review is off for you. Turn it back on: ${config(env)} set ship_greptile_early true\n`);
return 0;
}
+ const lists = botLists(env.vars);
+ if (![...lists.gating, ...lists.advisory].includes("greptile")) return off("greptile is not in pr_wait_gating_bots or pr_wait_advisory_bots");
const root = gitRoot(env);
if (!root) return off("not in a git repository");
if (env.run("gh", ["auth", "status"]).status !== 0) return off("gh is missing or not logged in");
diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts
index 3d1d85326..1fa58bb1b 100644
--- a/lib/headless-artifacts.ts
+++ b/lib/headless-artifacts.ts
@@ -121,6 +121,8 @@ export interface ShipReceipt {
review_targeted?: { classes: string[]; rules: string; brief_sha: string; product_patch_id: string; status: 'done' | 'failed' | 'not-run'; findings: number; raised_by: string };
/** Step 5 failures gstack-base-red proved are the base's own (lib/base-red.ts): a conditional receipt the queue refuses until /ship --resume rewrites it without this block. */
base_red?: { cases: string[]; files: string[]; base: string; lane: string; expires_at: string };
+ /** The base policy's review_exclude_paths at the reviewed head (lib/review-exclude.ts); absent when the base declares none. Carry and the queue compare `digest`. */
+ review_exclude?: { source: string; patterns: string[]; applied: string[]; excluded: number; excluded_bytes: number; kept: number; gate_tagged: string[]; patterns_sha256: string; list_sha256: string; digest: string };
}
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused'];
@@ -247,6 +249,7 @@ export const ARTIFACT_SCHEMAS = {
base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: 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, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data'] } } } },
review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } },
+ review_exclude: { type: 'object', required: ['source', 'patterns', 'applied', 'excluded', 'excluded_bytes', 'kept', 'gate_tagged', 'patterns_sha256', 'list_sha256', 'digest'], properties: { source: str, patterns: strList, applied: strList, excluded: { type: 'integer', minimum: 0 }, excluded_bytes: { type: 'integer', minimum: 0 }, kept: { type: 'integer', minimum: 0 }, gate_tagged: strList, patterns_sha256: str, list_sha256: str, digest: str } },
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/pr-wait.ts b/lib/pr-wait.ts
index b9462a1de..cff55732e 100644
--- a/lib/pr-wait.ts
+++ b/lib/pr-wait.ts
@@ -165,6 +165,13 @@ export function parsePrTarget(raw: string | undefined, repo?: string): { repo?:
return { repo: resolved, pr };
}
+/** The configured bot that owns a login: the longest name the lowercased login starts with (greptile-apps[bot] is greptile, notgreptile is nobody). Check names keep botOf's first-match rule. */
+export function botOwning(login: string | null | undefined, bots: readonly string[]): string | null {
+ const lowered = (login ?? '').toLowerCase();
+ if (!lowered) return null;
+ return bots.filter(bot => bot !== '' && lowered.startsWith(bot)).sort((a, b) => b.length - a.length)[0] ?? null;
+}
+
function botList(value: string | null, fallback: string[]): string[] {
if (value === null) return fallback;
return value.split(',').map(s => s.trim().toLowerCase()).filter(s => s && s !== 'none');
@@ -291,8 +298,10 @@ export class Watcher {
/** The configured bot a check name mentions, or (for a login) that the login starts with: greptile-apps[bot] is greptile, notgreptile is nobody. */
private botOf(text: string, login = false): string | null {
+ const bots = [...this.bots.gating, ...this.bots.advisory];
+ if (login) return botOwning(text, bots);
const lowered = text.toLowerCase();
- return [...this.bots.gating, ...this.bots.advisory].find(bot => login ? lowered.startsWith(bot) : lowered.includes(bot)) ?? null;
+ return bots.find(bot => lowered.includes(bot)) ?? null;
}
private isAdvisory(name: string): boolean {
diff --git a/lib/pregate/checks/guards.ts b/lib/pregate/checks/guards.ts
index 3f6ca1c46..1a67ff05a 100644
--- a/lib/pregate/checks/guards.ts
+++ b/lib/pregate/checks/guards.ts
@@ -20,7 +20,7 @@ import { baseInputs, sha256 } from '../context';
import { classifyCommandEnvironment, classifyEnvironment, environmentLine, environmentResult, launchCause } from '../environment';
import { launchRuntime } from '../runtime';
import { checkScratch } from '../run-scratch';
-import { readRange, supervise } from '../supervise';
+import { readRange, STALL_IDLE, supervise } from '../supervise';
import type { EnvironmentCause } from '../types';
import type { CheckDef, CheckResult, PregateContext } from '../types';
@@ -56,7 +56,7 @@ async function run(ctx: PregateContext): Promise {
for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir));
const lines = [`runtime: ${runtime.label}`];
for (const r of runs) {
- lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`);
+ lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`);
if (r.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`));
if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`);
}
diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts
index 1de603914..2e16b61f5 100644
--- a/lib/pregate/checks/lanes.ts
+++ b/lib/pregate/checks/lanes.ts
@@ -39,7 +39,7 @@ import { isDeviceDart, listRepoFiles, readRepoFile } from '../import-graph';
import { selectLanes, type Selection } from '../lane-selection';
import { launchRuntime } from '../runtime';
import { cargoModel, type CargoModel } from '../rust';
-import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
+import { adapterFor, readRange, STALL_IDLE, supervise, type Counter, type Supervised } from '../supervise';
import { verifyFromRun } from '../../run-evidence';
import { matchesAny } from '../../ship-policy';
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
@@ -401,7 +401,7 @@ function unfinished(ctx: PregateContext, r: Awaited>
const timedOut = r.runs.filter(x => x.outcome === 'timeout');
const rest = r.failed ? `; ${r.failed} failed` : '';
if (stalled.length) {
- return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that is quiet for longer` };
+ return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that waits quietly for longer` };
}
const launch = r.runs.find(x => x.outcome === 'spawn-failed');
if (launch) {
diff --git a/lib/pregate/checks/literals.ts b/lib/pregate/checks/literals.ts
index 2e1121a71..b34311e09 100644
--- a/lib/pregate/checks/literals.ts
+++ b/lib/pregate/checks/literals.ts
@@ -14,6 +14,23 @@ import { isTestFile, listRepoFiles, scansLiterals } from '../import-graph';
import type { CheckDef, CheckResult, PregateContext } from '../types';
export interface ChangedLiteral { name: string; old: string; new: string; file: string }
+/** A consumer line a `pregate-literal:` marker waived; `added` when the merge-base file lacks the marker. */
+export interface LiteralWaiver { at: string; reason: string; added: boolean }
+
+const MARKER = /pregate-literal:[ \t]*([A-Za-z_]\w*)=(\S+)[ \t]+([^\s*].*?)[ \t]*(?:\*+\/[ \t]*)?$/;
+
+/** The marker in this line's trailing comment (`//`, `#` or `/*` outside a string), or null. */
+function markerIn(line: string): { marker: string; name: string; value: string; reason: string } | null {
+ const at = line.indexOf('pregate-literal:');
+ if (at < 0) return null;
+ const before = line.slice(0, at);
+ const open = /(?:\/\/|#|\/\*)[ \t]*$/.exec(before);
+ if (!open) return null;
+ const code = before.slice(0, open.index);
+ if (['\'', '"', '`'].some(q => code.split(q).length % 2 === 0)) return null;
+ const m = MARKER.exec(line.slice(at));
+ return m ? { marker: line.slice(at).trim(), name: m[1]!, value: m[2]!, reason: m[3]! } : null;
+}
const CONST_PATTERNS: RegExp[] = [
/^\s*export\s+(?:const|let|var)\s+([A-Z][A-Z0-9_]{1,})\s*(?::\s*[^=]+?)?\s*=\s*(.+?);?\s*$/,
@@ -83,8 +100,12 @@ function unquote(v: string): string { return /^['"`]/.test(v) ? v.slice(1, -1) :
* has no new-value row: the branch extended the table. A table it did not
* extend, a file that already held a new-value row, and an unreadable base
* still count, and the scan goes on past skipped rows to the first real consumer.
+ *
+ * A consumer line is waived by a `pregate-literal: NAME= ` comment on that line or
+ * on the comment line directly above it, naming this constant and its old value (#3490: the refused
+ * input after a minimum-version bump). Each waiver goes to `waived`; the scan goes on to the next line.
*/
-export function consumersOf(repoRoot: string, testFiles: readonly string[], change: ChangedLiteral, baseText?: (rel: string) => string | null | undefined): string[] {
+export function consumersOf(repoRoot: string, testFiles: readonly string[], change: ChangedLiteral, baseText?: (rel: string) => string | null | undefined, waived?: LiteralWaiver[]): string[] {
const value = unquote(change.old);
const valueRe = /^-?\d/.test(value) ? new RegExp(`(? new RegExp(`^[ \\t]*\\[[ \\t]*${/^['"`]/.test(raw) ? `['"\`]${escape(unquote(raw))}['"\`]` : escape(raw)}[ \\t]*,`, 'm');
@@ -131,7 +152,15 @@ export function consumersOf(repoRoot: string, testFiles: readonly string[], chan
if (extended === undefined) { const base = baseText?.(rel); extended = base === null || (base !== undefined && !newRowRe.test(base)); }
return extended;
};
- for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!) && (!nameRequired || oldRowRe.test(lines[i]!) || named(i)) && !skip(i)) { hits.push(`${rel}:${i + 1}`); break; }
+ const waiver = (i: number) => {
+ const m = [markerIn(lines[i]!), i > 0 && comment(lines[i - 1]!) ? markerIn(lines[i - 1]!) : null].find(c => c && c.name === change.name && unquote(c.value) === value);
+ if (!m) return false;
+ const before = baseText?.(rel);
+ waived?.push({ at: `${rel}:${i + 1}`, reason: m.reason, added: typeof before !== 'string' || !before.includes(m.marker) });
+ return true;
+ };
+ const carries = (i: number) => { const m = markerIn(lines[i]!); return valueRe.test(m ? lines[i]!.slice(0, lines[i]!.indexOf('pregate-literal:')) : lines[i]!); };
+ for (let i = 0; i < lines.length; i++) if (carries(i) && (!nameRequired || oldRowRe.test(lines[i]!) || named(i)) && !skip(i) && !waiver(i)) { hits.push(`${rel}:${i + 1}`); break; }
}
return hits;
}
@@ -150,19 +179,23 @@ function run(ctx: PregateContext): CheckResult {
if (!changes.length) return { id: 'literals', stage: 'preflight', status: 'pass', inputs, detail: `no exported constant or named timeout literal changed in ${candidates.length} modified source file(s)` };
const testFiles = listRepoFiles(ctx.repoRoot).filter(f => scansLiterals(f) && !f.startsWith('bin/'));
const lines: string[] = [];
+ const notes: string[] = [];
for (const c of changes) {
+ const waived: LiteralWaiver[] = [];
const hits = consumersOf(ctx.repoRoot, testFiles, c, rel => {
const from = ctx.touched.find(t => t.path === rel)?.renamedFrom ?? rel;
const r = spawnSync('git', ['show', `${ctx.mergeBase}:${from}`], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, env: ctx.env });
if (r.status === 0) return r.stdout;
const ls = spawnSync('git', ['ls-tree', ctx.mergeBase, '--', from], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, env: ctx.env });
return ls.status === 0 && !ls.stdout.trim() ? null : undefined;
- });
+ }, waived);
+ for (const w of waived) notes.push(`waived: ${w.at}: ${c.name} ${c.old} (${w.reason}${w.added ? '; marker added on this branch' : ''})`);
ctx.explain.push(`literals: ${c.file} ${c.name} ${c.old} → ${c.new}: ${hits.length} test file(s) still carry ${c.old}`);
for (const h of hits) lines.push(`${h}: ${c.name} still ${c.old} (now ${c.new} in ${c.file})`);
}
- if (lines.length) return { id: 'literals', stage: 'preflight', status: 'fail', inputs, lines, code: 'PREGATE_LITERALS', detail: `${lines.length} test location(s) still carry an old literal`, fix: 'update each listed test to the new value (nightly- and Heavy-only files included), or keep the old constant' };
- return { id: 'literals', stage: 'preflight', status: 'pass', inputs, detail: `${changes.length} changed literal(s), no test still carries an old value (${testFiles.length} test-tree files scanned)` };
+ const waivers = notes.length ? `; ${notes.length} line(s) waived by pregate-literal markers` : '';
+ if (lines.length) return { id: 'literals', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...notes], code: 'PREGATE_LITERALS', detail: `${lines.length} test location(s) still carry an old literal${waivers}`, fix: "update each listed test to the new value (nightly- and Heavy-only files included), keep the old constant, or, when the old value is the test's input on purpose, mark that line with `// pregate-literal: NAME= `" };
+ return { id: 'literals', stage: 'preflight', status: 'pass', inputs, ...(notes.length ? { lines: notes } : {}), detail: `${changes.length} changed literal(s), no test still carries an old value (${testFiles.length} test-tree files scanned)${waivers}` };
}
export const literalsCheck: CheckDef = { id: 'literals', stage: 'preflight', tier: 1, downgradable: true, needsRepoCommands: false, run };
diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts
index 19a09b289..863b5f894 100644
--- a/lib/pregate/config.ts
+++ b/lib/pregate/config.ts
@@ -29,8 +29,9 @@
* the pre-push hook and `gstack-pregate sealed`, not by `run`)
* price_file the call-plan data file `gstack-eval-plan price-gate` prices
* a paid gate from (GSTACK_EVAL_CALLS v1; never executed)
- * stall_timeout_s seconds without test output before the lanes check kills
- * the runner's process group (default 300; 0 disables)
+ * stall_timeout_s seconds without test output or CPU activity before the
+ * lanes check kills the runner's process group (default 300;
+ * 0 disables)
* timeout_s seconds each runner process may take (the unit-file group,
* each serial file, each guard without its own timeout_s, the
* remote gate) when `--timeout` is not passed (default 600)
@@ -352,7 +353,7 @@ export function explainConfig(): string {
`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, allow_paths, case_insensitive, label_keys, identifier_keys, 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; label_keys turns on sealed.label_join, which refuses a published record that puts one of those fields next to an identifier (identifier_keys globs, or a hex value) (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)',
+ 'stall_timeout_s: seconds without test output or CPU activity before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'timeout_s: seconds each runner process may take (the unit-file group, each serial file, each guard without its own timeout_s, and the remote gate\'s deadline) when --timeout is not passed; 1 to 3600, default 600',
'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)',
'run_profiles: name → { workflow, events, jobs, artifact, max_age_hours?, os? }; the GitHub Actions runs gstack-pregate clear --run verifies as evidence on the identical tree (docs/pregate.md#run-evidence)',
diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts
index b745cb5fc..c9b5038c0 100644
--- a/lib/pregate/context.ts
+++ b/lib/pregate/context.ts
@@ -17,6 +17,7 @@ import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type
import { resolveStateRoot } from '../state-root';
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
+import { baseContainsHead } from '../default-base';
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
import { headPolicy } from './head-policy';
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
@@ -48,7 +49,7 @@ export function readTestBackend(env: NodeJS.ProcessEnv, invocation?: InvocationO
return (r.status === 0 && (r.stdout ?? '').trim()) || 'local';
}
export interface ContextRefusal {
- code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'PREGATE_HEAD_POLICY_LOOSENS' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
+ code: 'POLICY_SOURCE_UNAVAILABLE' | 'BASE_CONTAINS_HEAD' | 'PREGATE_CONFIG_INVALID' | 'PREGATE_HEAD_POLICY_LOOSENS' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
message: string;
/** A cause-specific fix that replaces the code's own (a gstack-wtree exit names its repair; so does a `--base` that is no origin branch). */
fix?: string;
@@ -210,6 +211,8 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
const baseRef = `origin/${base}`;
const baseSha = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
+ const self = baseContainsHead(o.repoRoot, baseRef);
+ if (self) return { refused: { code: 'BASE_CONTAINS_HEAD', message: self } };
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
let configLoad: ConfigLoad = loadConfigFromBase(o.repoRoot, base, o.knownChecks);
diff --git a/lib/pregate/supervise.ts b/lib/pregate/supervise.ts
index a7ff3ed88..37cc3210e 100644
--- a/lib/pregate/supervise.ts
+++ b/lib/pregate/supervise.ts
@@ -2,8 +2,10 @@
* pregate/supervise — how a test-tier command runs (Lane C, GBRA-95): its
* own process group, stdout and stderr appended to a log file (no pipes, so
* a backgrounded grandchild holding the output can never keep the check
- * waiting), supervised asynchronously. A stall is no log growth for
- * `stallMs`; the deadline is the check's `--timeout`. Either kills the whole
+ * waiting), supervised asynchronously. A stall is `stallMs` with no log growth
+ * and no CPU activity in the process group (GST-1095: a quiet parallel suite
+ * that is busy working is alive; a hang that keeps burning CPU is left to the
+ * deadline); the deadline is the check's `--timeout`. Either kills the whole
* process group (SIGTERM, then SIGKILL after a grace period) and the run is
* incomplete. After a normal exit the group's leftover members are killed.
*
@@ -34,7 +36,7 @@ export interface SuperviseOptions {
log: string;
/** Absolute epoch ms when the run is killed as timed out. */
deadline: number;
- /** No log growth for this long kills the group; 0 disables. */
+ /** No log growth and no CPU activity in the group for this long kills it; 0 disables. */
stallMs: number;
heartbeatMs?: number;
graceMs?: number;
@@ -63,6 +65,11 @@ export interface Supervised {
}
const ACTIVE = new Set();
+const USER_HZ = 100;
+const CPU_SAMPLE_MS = 1_000;
+const CPU_BUSY_SHARE = 0.1;
+/** What a stall means beyond silence, for the PREGATE_STALLED detail and the guard line; Windows reads no CPU time. */
+export const STALL_IDLE = process.platform === 'win32' ? '' : ' and its process group used under a tenth of one CPU';
/** SIGKILL every supervised process group still running (the CLI's SIGINT/SIGTERM handler, before it exits). */
export function cancelActive(): number {
@@ -80,6 +87,31 @@ export function killGroup(pid: number, signal: NodeJS.Signals): void {
try { process.kill(-pid, signal); } catch { /* group already gone */ }
}
+/** CPU time in ms used by the process group `pgid`, reaped children included where the platform reports them; null where it cannot be read (Windows). */
+export function groupCpuMs(pgid: number): number | null {
+ if (process.platform === 'win32') return null;
+ if (fs.existsSync('/proc/self/stat')) {
+ let ticks = 0;
+ for (const name of fs.readdirSync('/proc')) {
+ if (!/^\d+$/.test(name)) continue;
+ let stat: string;
+ try { stat = fs.readFileSync(`/proc/${name}/stat`, 'utf8'); } catch { continue; }
+ const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
+ if (Number(f[2]) === pgid) ticks += Number(f[11]) + Number(f[12]) + Number(f[13]) + Number(f[14]);
+ }
+ return ticks * 1000 / USER_HZ;
+ }
+ const ps = spawnSync('ps', ['-A', '-S', '-o', 'pgid=,time='], { encoding: 'utf8', timeout: 15_000 });
+ if (ps.status !== 0) return null;
+ let ms = 0;
+ for (const line of ps.stdout.split('\n')) {
+ const [id, time] = line.trim().split(/\s+/);
+ const t = time && /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(time);
+ if (Number(id) === pgid && t) ms += (((Number(t[1] ?? 0) * 24 + Number(t[2] ?? 0)) * 60 + Number(t[3])) * 60 + Number(t[4])) * 1000;
+ }
+ return ms;
+}
+
/** The adapter that reads per-file progress from this runner's output, or null when it has none. */
export function adapterFor(command: string): ProgressAdapter | null {
for (const [name, re] of Object.entries(PROGRESS_ADAPTERS)) if (re.test(command)) return name as ProgressAdapter;
@@ -110,6 +142,8 @@ export function supervise(o: SuperviseOptions): Promise {
let readAt = start;
let partial = '';
let lastGrowth = started;
+ let lastBusy = started;
+ let cpu = { at: started, ms: 0 };
let lastProgress = started;
let current: { file: string; at: number; failed: boolean } | null = null;
let lastFile: string | null = null;
@@ -184,7 +218,12 @@ export function supervise(o: SuperviseOptions): Promise {
if (grown > size) { size = grown; lastGrowth = now; readNew(); }
if (outcome) return;
if (now >= o.deadline) { stop('timeout'); return; }
- if (o.stallMs > 0 && now - lastGrowth >= o.stallMs) { stop('stalled'); return; }
+ if (o.stallMs > 0 && process.platform !== 'win32' && now - cpu.at >= CPU_SAMPLE_MS) {
+ const used = groupCpuMs(child.pid!);
+ if (used !== null && used - cpu.ms >= CPU_BUSY_SHARE * (now - cpu.at)) lastBusy = now;
+ cpu = { at: now, ms: used ?? cpu.ms };
+ }
+ if (o.stallMs > 0 && now - Math.max(lastGrowth, lastBusy) >= o.stallMs) { stop('stalled'); return; }
if (now - lastProgress < heartbeatMs) return;
let last: string | undefined;
if (o.lastLine) {
diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts
index fd9ddda19..3e13116f6 100644
--- a/lib/pregate/types.ts
+++ b/lib/pregate/types.ts
@@ -54,7 +54,7 @@ export interface PregateContext {
timeoutMs: number;
/** Where timeoutMs came from: `--timeout`, `timeout_s from `, or the 600 s default. */
timeoutSource?: string;
- /** No output for this long kills a test-tier process group (`--stall-timeout`, `stall_timeout_s`); 0 disables. */
+ /** No output and no CPU activity for this long kills a test-tier process group (`--stall-timeout`, `stall_timeout_s`); 0 disables. */
stallTimeoutMs: number;
/** Live stderr lines: per-file progress, heartbeats, the stage result. */
progress: (line: string) => void;
diff --git a/lib/redact-engine.ts b/lib/redact-engine.ts
index 9b64bb8d8..3a29729e9 100644
--- a/lib/redact-engine.ts
+++ b/lib/redact-engine.ts
@@ -17,7 +17,7 @@
* finding (drives sterner AUQ wording in the skill) but never promotes a
* MEDIUM to HIGH. (TENSION-2-followup.)
* - Placeholder suppression is per-matched-span.
- * - Tool-attributed fences (``` ```codex-review ``` / ``` ```greptile ```)
+ * - Tool-attributed fences (``` ```codex-review ``` / ``` ```review-bot ``` / ``` ```greptile ```)
* degrade credential findings to a non-blocking WARN — UNLESS the span is a
* live-format credential the doc-example heuristic can't excuse. No nonce,
* no trust exemption (the marker scheme was dropped as theater).
@@ -224,7 +224,7 @@ export function maskPreview(span: string): string {
// ── Tool-attributed fence detection ───────────────────────────────────────────
-const TOOL_FENCE_INFO = /^```(codex-review|greptile|eval|codex|tool-output)\b/;
+const TOOL_FENCE_INFO = /^```(codex-review|review-bot|greptile|eval|codex|tool-output)\b/;
/**
* Returns a sorted list of [start, end) offset ranges (in normalized text) that
diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts
index 3d6385ee7..2d0a0dddb 100644
--- a/lib/result-codes-release.ts
+++ b/lib/result-codes-release.ts
@@ -254,6 +254,11 @@ export const RELEASE_RESULT_CODES = {
summary: 'no new base head passed the paused cases before gstack-base-red wait’s timeout',
fix: 'run gstack-base-red wait --pause again (the pause stays valid until it expires), or check who owns the base red',
},
+ BASE_CONTAINS_HEAD: {
+ anchor: 'base-contains-head',
+ summary: 'the base is the head or already contains it while the branch has commits its real base lacks, so a base-relative step would compare the branch with itself and report no changes',
+ fix: 'compare against the branch’s real base (the PR’s base branch, usually main): pass it as the base, or run git remote set-head origin when origin/HEAD names the checked-out branch',
+ },
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',
@@ -478,4 +483,20 @@ export const RELEASE_RESULT_CODES = {
summary: 'under --policy-from head, the branch’s .gstack/pregate.json removes, shrinks or loosens something the trusted policy declares; a head policy may only add test lanes',
fix: 'keep every key of the trusted policy as it is and only add dependencies globs with tests (or tests on an existing glob); land any other change, a [] declaration included, in its own PR on the base branch first',
},
+ // The reviewer diff ceiling and the base policy's review_exclude_paths (lib/review-exclude.ts, GST-508).
+ REVIEW_DIFF_TOO_LARGE: {
+ anchor: 'review-diff-too-large',
+ summary: 'one reviewer\'s diff is over the 2,000,000-byte review ceiling, so /ship stopped before writing a manifest or a brief instead of handing a reader more than it can read',
+ fix: 'follow the fix the line prints for where the base policy stands: land the printed review_exclude_paths JSON on the base as its own PR, then bring the base into the branch and rerun once; or narrow a pattern the kept-file bound skipped; or split the PR',
+ },
+ REVIEW_DIFF_UNREADABLE: {
+ anchor: 'review-diff-unreadable',
+ summary: 'git could not read a reviewer\'s diff (reason=timeout after timeout_ms, or reason=git-exit with git\'s exit code), so /ship stopped; the full stderr, with remote URLs redacted, is in the log= file',
+ fix: 'timeout: rerun once unchanged, then split the PR or exclude its largest data folders on the base; git exit 128: git fetch origin , then rerun once; anything else: stop and ask a person',
+ },
+ REVIEW_EXCLUDE_REFERENCE: {
+ anchor: 'review-exclude-reference',
+ summary: 'informational: a kept code file names an excluded folder (a directory reference, not a full file path), so its files stay excluded and readers see that code without the files it reads',
+ fix: 'nothing to do for the review; if that code\'s correctness depends on the excluded files, narrow review_exclude_paths on the base so they stay in the diff',
+ },
} as const satisfies Record;
diff --git a/lib/result-codes.ts b/lib/result-codes.ts
index 59c25980c..8bb6f4089 100644
--- a/lib/result-codes.ts
+++ b/lib/result-codes.ts
@@ -410,8 +410,8 @@ export const RESULT_CODES = {
},
PREGATE_STALLED: {
anchor: 'pregate-stalled',
- summary: 'a test command printed nothing for the stall timeout, so its process group was killed; the check is incomplete, never pass',
- fix: 'read the lane log for the last file started, fix the hang, or raise `--stall-timeout` (`stall_timeout_s`; 0 disables) for a test that is quiet for longer, then rerun `gstack-pregate --stage tests`',
+ summary: 'a test command printed nothing for the stall timeout while its process group used under a tenth of one CPU (log growth alone on Windows), so the group was killed; the check is incomplete, never pass',
+ fix: 'read the lane log for the last file started, fix the hang, or raise `--stall-timeout` (`stall_timeout_s`; 0 disables) for a test that waits quietly for longer, then rerun `gstack-pregate --stage tests`',
},
PREGATE_RECEIPT_INVALID: {
anchor: 'pregate-receipt-invalid',
diff --git a/lib/review-bots.ts b/lib/review-bots.ts
new file mode 100644
index 000000000..94db9baf4
--- /dev/null
+++ b/lib/review-bots.ts
@@ -0,0 +1,211 @@
+/**
+ * review-bots: the PR review-bot comments /review Step 2.5 and /ship Step 10 triage (#3122).
+ *
+ * The bots are the two gstack-pr-wait lists (`pr_wait_gating_bots`, `pr_wait_advisory_bots`;
+ * `none` in both turns triage off), owned by login with gstack-pr-wait's rule (`botOwning`),
+ * and only Bot accounts count, so a human named `greptile-ci` is never triaged.
+ *
+ * fetch [--pr ] every root review comment, conversation comment and the newest review
+ * body of each configured bot, all pages; prints REVIEW_BOTS: skip|error
+ * () or REVIEW_BOTS_DIR: with comments.jsonl and the counts
+ * record --dir D --id N --type fix|fp|already-fixed --category C
+ * append a triage outcome to review-bot-history.md (project and global); the bot
+ * and path come from comment N in D/comments.jsonl, never from a shell argument,
+ * so a contributor-named file cannot reach the command line
+ *
+ * fetch marks each record with the false-positive rows that could suppress it: same repo, same
+ * bot, matching file. Rows in the old greptile-history.md count as Greptile's.
+ * bin/gstack-review-bots is the CLI; tests drive `runReviewBots` with a fake runner.
+ */
+import * as fs from "fs";
+import * as path from "path";
+import { botLists, botOwning } from "./pr-wait";
+import { remoteSlug } from "./remote-identity";
+import { resolveStateRoot, type StateRootEnv } from "./state-root";
+
+export interface ReviewBotsEnv {
+ run(cmd: string, args: string[]): { status: number; stdout: string; stderr: string };
+ vars: StateRootEnv;
+ /** Directory holding gstack-slug. */
+ binDir: string;
+ /** Parent directory for the private fetch directory. */
+ tmpDir: string;
+ now(): number;
+ out(text: string): void;
+ err(text: string): void;
+}
+
+export const HISTORY_FILE = "review-bot-history.md";
+export const LEGACY_HISTORY_FILE = "greptile-history.md";
+export const HISTORY_TYPES = ["fix", "fp", "already-fixed"] as const;
+
+export interface BotComment {
+ id: number;
+ bot: string;
+ source: "line" | "top" | "review";
+ path?: string;
+ line?: number | null;
+ url: string;
+ body: string;
+ suppress_candidates: string[];
+}
+
+interface GhUser { login?: string; type?: string }
+interface GhItem {
+ id: number; user?: GhUser | null; body?: string | null; html_url?: string; path?: string;
+ line?: number | null; position?: number | null; in_reply_to_id?: number | null; submitted_at?: string | null; subject_type?: string | null;
+}
+interface HistoryRow { repo: string; type: string; pattern: string; category: string; bot: string }
+
+const firstLine = (text: string) => text.trim().split("\n")[0] || "no detail";
+/** gh's answers when the checkout has no GitHub remote at all; any other repo lookup failure is an error, never a skip. */
+const NO_GITHUB_REMOTE = /no git remotes found|none of the git remotes configured for this repository point to a known GitHub host|not a git repository/i;
+/** The issue types a triage outcome may carry (review/greptile-triage.md, Suppressions Check). */
+export const CATEGORIES = ["race-condition", "null-check", "error-handling", "style", "type-safety", "security", "performance", "correctness", "other"] as const;
+
+/** The bot that owns an item's author, or null for humans, other bots and deleted accounts. */
+function ownerOf(item: GhItem, bots: readonly string[]): string | null {
+ return item.user?.type === "Bot" ? botOwning(item.user.login, bots) : null;
+}
+
+function slug(env: ReviewBotsEnv): string {
+ const s = env.run(path.join(env.binDir, "gstack-slug"), ["--get", "SLUG"]);
+ if (s.status === 0 && s.stdout.trim()) return s.stdout.trim();
+ const origin = env.run("git", ["remote", "get-url", "origin"]);
+ return origin.status === 0 ? remoteSlug(origin.stdout.trim()) : "";
+}
+
+function historyFiles(env: ReviewBotsEnv, name: string): string[] {
+ const root = resolveStateRoot(env.vars);
+ const id = slug(env);
+ return [...(id ? [path.join(root, "projects", id, name)] : []), path.join(root, name)];
+}
+
+/** Rows of one history file; a five-column row is Greptile's (the old format). */
+export function parseHistory(text: string): HistoryRow[] {
+ return text.split("\n").flatMap(line => {
+ const cells = line.split(" | ").map(c => c.trim());
+ if (cells.length !== 5 && cells.length !== 6) return [];
+ const [, repo, type, pattern, category, bot] = cells as [string, string, string, string, string, string?];
+ return repo && type && pattern && category ? [{ repo, type, pattern, category, bot: (bot || "greptile").toLowerCase() }] : [];
+ });
+}
+
+/** An exact path, or a glob where `*` stays inside one directory and `**` crosses them. */
+export function patternMatches(pattern: string, file: string): boolean {
+ if (pattern === file) return true;
+ const re = pattern.split("**").map(part => part.split("*").map(s => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")).join(".*");
+ return new RegExp(`^${re}$`).test(file);
+}
+
+function readOr(file: string): string {
+ try { return fs.readFileSync(file, "utf8"); } catch { return ""; }
+}
+
+/** All pages of one REST list, one item per line (no --slurp: gh before 2.48 lacks it), or the reason it could not be read whole. */
+function pages(env: ReviewBotsEnv, endpoint: string): GhItem[] | string {
+ const r = env.run("gh", ["api", "--paginate", `${endpoint}?per_page=100`, "--jq", ".[] | tojson"]);
+ if (r.status !== 0) return `${endpoint}: ${firstLine(r.stderr)}`;
+ try {
+ const items: unknown[] = r.stdout.split("\n").filter(l => l.trim()).map(l => JSON.parse(l));
+ if (!items.every(i => i !== null && typeof i === "object" && !Array.isArray(i))) return `${endpoint}: not a list of objects`;
+ return items as GhItem[];
+ } catch { return `${endpoint}: invalid JSON`; }
+}
+
+function fetch(env: ReviewBotsEnv, args: string[]): number {
+ const skip = (reason: string) => { env.out(`REVIEW_BOTS: skip (${reason})\n`); return 0; };
+ const error = (reason: string) => { env.out(`REVIEW_BOTS: error (${reason})\n`); return 0; };
+ const lists = botLists(env.vars);
+ const bots = [...new Set([...lists.gating, ...lists.advisory])];
+ if (bots.length === 0) return skip("pr_wait_gating_bots and pr_wait_advisory_bots are both none");
+ if (env.run("gh", ["--version"]).status !== 0) return skip("gh not installed");
+ if (env.run("gh", ["auth", "status"]).status !== 0) return skip("gh not logged in");
+ const repoRun = env.run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
+ const repo = repoRun.stdout.trim();
+ if (repoRun.status !== 0 || !repo) {
+ const why = firstLine(repoRun.stderr);
+ return NO_GITHUB_REMOTE.test(repoRun.stderr) ? skip(`not a GitHub remote: ${why}`) : error(`repository lookup failed: ${why}`);
+ }
+ const i = args.indexOf("--pr");
+ let pr = i >= 0 ? args[i + 1] ?? "" : "";
+ if (!pr) {
+ const view = env.run("gh", ["pr", "view", "--json", "number", "--jq", ".number"]);
+ if (view.status !== 0) return /no pull requests found/.test(view.stderr) ? skip("no PR yet") : error(`PR lookup failed: ${firstLine(view.stderr)}`);
+ pr = view.stdout.trim();
+ }
+ if (!/^[1-9][0-9]*$/.test(pr)) return error(`'${pr}' is not a PR number`);
+ const line = pages(env, `repos/${repo}/pulls/${pr}/comments`);
+ const top = pages(env, `repos/${repo}/issues/${pr}/comments`);
+ const reviews = pages(env, `repos/${repo}/pulls/${pr}/reviews`);
+ for (const r of [line, top, reviews]) if (typeof r === "string") return error(r);
+ const records: BotComment[] = [];
+ const add = (item: GhItem, bot: string, source: BotComment["source"], extra: Partial = {}) =>
+ records.push({ id: item.id, bot, source, url: item.html_url ?? "", body: item.body ?? "", suppress_candidates: [], ...extra });
+ for (const c of line as GhItem[]) {
+ const bot = ownerOf(c, bots);
+ if (bot && c.in_reply_to_id == null && (c.position != null || c.subject_type === "file")) add(c, bot, "line", { path: c.path, line: c.line ?? null });
+ }
+ for (const c of top as GhItem[]) {
+ const bot = ownerOf(c, bots);
+ if (bot) add(c, bot, "top");
+ }
+ const newest = new Map();
+ for (const r of reviews as GhItem[]) {
+ const bot = ownerOf(r, bots);
+ if (bot && (r.body ?? "").trim() && (r.submitted_at ?? "") >= (newest.get(bot)?.submitted_at ?? "")) newest.set(bot, r);
+ }
+ for (const [bot, r] of newest) add(r, bot, "review");
+ const id = slug(env);
+ const rows = id ? [HISTORY_FILE, LEGACY_HISTORY_FILE].flatMap(name => {
+ const rows = parseHistory(readOr(path.join(resolveStateRoot(env.vars), "projects", id, name)));
+ return name === LEGACY_HISTORY_FILE ? rows.map(r => ({ ...r, bot: "greptile" })) : rows;
+ }) : [];
+ for (const rec of records) {
+ if (!rec.path) continue;
+ rec.suppress_candidates = [...new Set(rows.filter(r => r.type === "fp" && r.repo === repo && r.bot === rec.bot && patternMatches(r.pattern, rec.path!)).map(r => r.category))];
+ }
+ const dir = fs.mkdtempSync(path.join(env.tmpDir, "gstack-review-bots-"));
+ fs.chmodSync(dir, 0o700);
+ fs.writeFileSync(path.join(dir, "comments.jsonl"), records.map(r => JSON.stringify(r) + "\n").join(""), { mode: 0o600 });
+ env.out(`REVIEW_BOTS_DIR: ${dir}\n`);
+ env.out(`REVIEW_BOTS_COUNTS: ${bots.map(b => `${b}=${records.filter(r => r.bot === b).length}`).join(" ")}\n`);
+ env.out(`REVIEW_BOTS_SUPPRESSION_CANDIDATES: ${records.filter(r => r.suppress_candidates.length > 0).length}\n`);
+ return 0;
+}
+
+function record(env: ReviewBotsEnv, args: string[]): number {
+ const flag = (name: string) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
+ const [dir, id, type, category] = [flag("--dir"), flag("--id"), flag("--type"), flag("--category")];
+ const usage = "usage: gstack-review-bots record --dir --id --type fix|fp|already-fixed --category \n";
+ if (!dir || !id || !type || !category) { env.err(usage); return 64; }
+ if (!/^[1-9][0-9]*$/.test(id)) { env.err("gstack-review-bots: --id must be a comment id from comments.jsonl\n"); return 64; }
+ if (!(HISTORY_TYPES as readonly string[]).includes(type)) { env.err(`gstack-review-bots: --type must be one of ${HISTORY_TYPES.join(", ")}\n`); return 64; }
+ if (!(CATEGORIES as readonly string[]).includes(category)) { env.err(`gstack-review-bots: --category must be one of ${CATEGORIES.join(", ")}\n`); return 64; }
+ const jsonl = path.join(dir, "comments.jsonl");
+ if (!fs.existsSync(jsonl)) { env.err(`gstack-review-bots: ${jsonl} is gone; run fetch again and record by the new ids\n`); return 1; }
+ let matches: BotComment[];
+ try { matches = readOr(jsonl).split("\n").filter(Boolean).map(l => JSON.parse(l) as BotComment).filter(c => String(c.id) === id); }
+ catch { env.err(`gstack-review-bots: ${jsonl} is not valid JSONL; run fetch again\n`); return 1; }
+ if (matches.length !== 1) { env.err(`gstack-review-bots: ${matches.length ? "more than one" : "no"} comment ${id} in ${jsonl}\n`); return 1; }
+ const [bot, file] = [matches[0]!.bot, matches[0]!.path || "top-level"];
+ if (/[|*\r\n]/.test(file)) { env.err("gstack-review-bots: the comment's path contains |, * or a line break, so it cannot be a literal history row\n"); return 1; }
+ const repoRun = env.run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
+ const repo = repoRun.status === 0 ? repoRun.stdout.trim() : "";
+ if (!repo) { env.err(`gstack-review-bots: cannot resolve the GitHub repository: ${firstLine(repoRun.stderr)}\n`); return 1; }
+ const row = `${new Date(env.now()).toISOString().slice(0, 10)} | ${repo} | ${type} | ${file} | ${category} | ${bot.toLowerCase()}\n`;
+ for (const target of historyFiles(env, HISTORY_FILE)) {
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.appendFileSync(target, row);
+ }
+ env.out(`REVIEW_BOT_HISTORY: recorded ${type} for ${bot.toLowerCase()}\n`);
+ return 0;
+}
+
+export function runReviewBots(argv: string[], env: ReviewBotsEnv): number {
+ const [cmd, ...rest] = argv;
+ if (cmd === "fetch") return fetch(env, rest);
+ if (cmd === "record") return record(env, rest);
+ env.err("usage: gstack-review-bots {fetch [--pr ] | record --dir D --id N --type fix|fp|already-fixed --category C}\n");
+ return cmd === "help" || cmd === "--help" ? 0 : 64;
+}
diff --git a/lib/review-brief.ts b/lib/review-brief.ts
index 30a9565f3..e1670f04d 100644
--- a/lib/review-brief.ts
+++ b/lib/review-brief.ts
@@ -13,6 +13,11 @@
* outside-voice diff_base, diff (the branch diff, which the bin reads from git)
* fix-check fix_list, hunks, tests, callers (scripts/resolvers/fix-check.ts)
* targeted classes, brief (review/specialists/targeted.md's text), checklist (its path), diff_base, stack, test_fw, learnings
+ *
+ * With the base policy's review_exclude_paths active (lib/review-exclude.ts) every role but fix-check also takes
+ * policy_exclude (the `:(top,exclude,glob)` items, which the fixture-handling command of the security specialist, the
+ * Red Team and the native adversarial pass gets), keep (the kept files' `:(top,literal)` items) and disclosure (the one
+ * line naming what was left out). Unset, every brief renders byte for byte as before.
*/
import { spawnSync } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
@@ -81,7 +86,7 @@ function fill(text: string, pairs: Array<[string, string]>, what: string): strin
}
const SPECIALIST_DIFF = 'run\n`DIFF_BASE= && git diff "$DIFF_BASE" ` to get the diff';
-const PLACEHOLDERS = ['', '', '', '{checklist path}', '{STACK}', '{TEST_FW}', '{merged findings summary}', '{red-team checklist path}', '{learnings'];
+const PLACEHOLDERS = ['', '', '', '', '{checklist path}', '{STACK}', '{TEST_FW}', '{merged findings summary}', '{red-team checklist path}', '{learnings'];
/**
* Render one reader's brief. Throws BriefError on a missing slot, a section that no longer carries an anchor or placeholder, or a placeholder left unfilled.
@@ -94,7 +99,9 @@ export function renderBrief(role: BriefRole, slots: Slots, t: SectionTexts): str
const keys = ['findings', 'learnings'].filter(k => slots[k]?.trim());
const held: Slots = { ...slots };
for (const k of keys) held[k] = `\u0000${nonce}:${k}\u0000`;
- const text = renderRole(role, held, t);
+ const rendered = renderRole(role, held, t);
+ const keep = slots.keep ? ` Read the files kept despite a match as well: \`git diff ${slots.diff_base} -- ${slots.keep}\`.` : '';
+ const text = slots.disclosure && role !== 'fix-check' && role !== 'outside-voice' ? `${rendered}\n\n${slots.disclosure}${keep}` : rendered;
const left = role === 'fix-check' || role === 'outside-voice' ? [] : PLACEHOLDERS.filter(p => text.includes(p));
if (left.length) throw new BriefError(`${role}: unfilled placeholder ${left.join(', ')}`);
return keys.reduce((out, k) => out.split(held[k]!).join(slots[k]!.trim()), text);
@@ -103,6 +110,8 @@ export function renderBrief(role: BriefRole, slots: Slots, t: SectionTexts): str
function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
const diffBase = role === 'fix-check' ? '' : need(slots, 'diff_base');
if (diffBase && !SHA.test(diffBase)) throw new BriefError(`slot diff_base must be a literal commit sha, got ${JSON.stringify(diffBase)}`);
+ const fixtures = (text: string) => text.split(' `').join(slots.policy_exclude ? ` ${slots.policy_exclude}\`` : '`')
+ .split('`').join(slots.policy_exclude ? ` -- . ${slots.policy_exclude}\`` : '`');
const common = (body: string, checklist: string) => fill(body, [
...(checklist ? [['{checklist path}', checklist] as [string, string]] : []), ['', diffBase],
['{TEST_FW}', slots.test_fw || 'unknown'], ['{STACK}', slots.stack || 'unknown'], ['{learnings or \'none\'}', slots.learnings?.trim() || 'none'],
@@ -112,7 +121,7 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
const name = need(slots, 'name');
const checklist = need(slots, 'checklist');
if (name !== 'security') return common(t.specialist, checklist);
- const body = fill(t.specialist, [[SPECIALIST_DIFF, 'set `DIFF_BASE=` first. ' + t.fixtures + ' Then read the diff that way']], role);
+ const body = fill(t.specialist, [[SPECIALIST_DIFF, 'set `DIFF_BASE=` first. ' + fixtures(t.fixtures) + ' Then read the diff that way']], role);
return `${t.framing}\n\n${common(body, checklist)}`;
}
case 'specialist-batched': {
@@ -123,13 +132,14 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
case 'red-team': {
const body = fill(t.redTeam, [['{merged findings summary}', (slots.findings ?? '').trim() || '(none)'], ['by N specialists', `by ${need(slots, 'specialists')} specialists`],
['{red-team checklist path}', need(slots, 'checklist')], ['', diffBase]], role);
- return `${t.framing}\n\n${t.fixtures}\n\n${body}`;
+ return fixtures(`${t.framing}\n\n${t.fixtures}\n\n${body}`);
}
case 'adversarial-native':
- return fill(t.native, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role);
+ return fixtures(fill(t.native, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role));
case 'outside-voice': {
- const body = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role);
- return [body, '', `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n');
+ const filled = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role);
+ const body = slots.policy_exclude ? filled.split('git diff "$DIFF_BASE".').join(`git diff "$DIFF_BASE" -- . ${slots.policy_exclude}${slots.keep ? ` and git diff "$DIFF_BASE" -- ${slots.keep}` : ''}.`) : filled;
+ return [body, '', ...(slots.disclosure ? [slots.disclosure, ''] : []), `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n');
}
case 'targeted': {
const classes = parseClassList(need(slots, 'classes'));
diff --git a/lib/review-collect.ts b/lib/review-collect.ts
index 7e456bd5e..ac16ba0d2 100644
--- a/lib/review-collect.ts
+++ b/lib/review-collect.ts
@@ -37,6 +37,14 @@ function git(root: string, args: string[]): { ok: boolean; status: number | null
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, status: r.status, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
+/** git with what is left of an overall deadline (epoch ms) as its timeout, for a read that may take minutes. */
+function gitUntil(root: string, args: string[], deadline: number): { status: number | null; out: string; err: string; timedOut: boolean } {
+ const left = deadline - Date.now();
+ if (left <= 0) return { status: null, out: '', err: '', timedOut: true };
+ const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: left, maxBuffer: 256 * 1024 * 1024 });
+ const status = r.status ?? null;
+ return { status, out: r.stdout ?? '', err: (r.stderr ?? '').trim() || ((r.error as NodeJS.ErrnoException | undefined)?.code ?? ''), timedOut: status === null && Date.now() >= deadline };
+}
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' } });
@@ -101,36 +109,96 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick= 1 ? Math.floor(v) : CONSUMER_SCAN_TIMEOUT_MS;
+}
+
+export type GrepFailure = { timedOut: boolean; status: number | null; err: string };
+
+/**
+ * The lines `git grep -I -F -o --null` prints for `patterns` at `rev` ('' is the working tree with untracked files), for
+ * the files `keep` accepts, in some order. Every pattern holds its own extension, so a `-l` pass for the extensions alone
+ * lists every file that can match; the full pattern list then reads only those files, CONSUMER_SCAN_PATHSPEC_BYTES of
+ * paths per call under --literal-pathspecs, CONSUMER_SCAN_OUTPUT_BYTES of output in all (an inherited GIT_GLOB_PATHSPECS makes git refuse, which fails closed). A listed name
+ * that is not valid UTF-8 cannot be passed back as a path, so then one grep reads the whole tree, as the scan did before GST-544. Each call gets what is left of `deadline` (epoch ms).
+ */
+export function grepConsumers(root: string, patterns: readonly string[], rev: string, keep: (file: string) => boolean, deadline: number, chunkBytes = CONSUMER_SCAN_PATHSPEC_BYTES, outputBytes = CONSUMER_SCAN_OUTPUT_BYTES): string[] | GrepFailure {
+ const where = rev ? [rev, '--'] : ['--untracked', '--'];
+ const exts = [...new Set(patterns.map(b => b.slice(b.lastIndexOf('.'))))].flatMap(e => ['-e', e]);
+ const listed = gitUntil(root, ['grep', '-I', '-F', '-l', '-z', '--no-color', ...exts, ...where], deadline);
+ if (listed.status !== 0 && listed.status !== 1) return listed;
+ const names = listed.out.split('\0').filter(Boolean).map(f => (rev ? f.slice(rev.length + 1) : f));
+ const flags = ['--literal-pathspecs', 'grep', '-I', '-F', '-o', '--null', '--no-line-number', '--no-column', '--no-color', ...patterns.flatMap(b => ['-e', b]), ...where];
+ if (names.some(f => f.includes('\uFFFD'))) {
+ const whole = gitUntil(root, flags, deadline);
+ return whole.status !== 0 && whole.status !== 1 ? whole : whole.out.split('\n');
+ }
+ const files = names.filter(keep);
+ const lines: string[] = [];
+ let total = 0;
+ for (let i = 0; i < files.length;) {
+ const chunk: string[] = [];
+ for (let bytes = 0; i < files.length; i++) {
+ const spec = files[i]!;
+ if (chunk.length && bytes + spec.length > chunkBytes) break;
+ chunk.push(spec);
+ bytes += spec.length;
+ }
+ const r = gitUntil(root, [...flags, ...chunk], deadline);
+ if (r.status !== 0 && r.status !== 1) return r;
+ total += Buffer.byteLength(r.out);
+ if (total > outputBytes) return { status: null, err: 'ENOBUFS', timedOut: false };
+ for (const line of r.out.split('\n')) lines.push(line);
+ }
+ return lines;
+}
/**
* The data candidates (lib/review-depth.ts dataCandidate) among the changed and deleted paths that a non-docs file names
* by basename, at each rev and, with `live`, in the working tree with untracked files. The merge base is one of the revs,
* so a consumer the change removes still counts. A data file named only by another data file is consumed when that one
- * is, followed CONSUMER_SCAN_DEPTH files deep; anything still open after that counts as consumed. A git grep failure is
- * a collector error, which fails the depth closed.
+ * is, followed CONSUMER_SCAN_DEPTH files deep; anything still open after that counts as consumed. A git grep failure, or
+ * a scan that outlives `timeoutMs`, is a collector error, which fails the depth closed.
*/
-export function scanConsumers(root: string, paths: readonly string[], revs: readonly string[], live: boolean, classRoot: string | readonly string[], errors: DepthError[]): string[] | 'all' {
+export function scanConsumers(root: string, paths: readonly string[], revs: readonly string[], live: boolean, classRoot: string | readonly string[], errors: DepthError[], timeoutMs = consumerScanTimeoutMs()): string[] | 'all' {
const candidates = [...new Set(paths)].filter(p => dataCandidate(p, classRoot) || svgCandidate(p, classRoot)).sort();
if (candidates.length > CONSUMER_SCAN_CAP) return 'all';
+ const deadline = Date.now() + timeoutMs;
const base = (p: string) => p.slice(p.lastIndexOf('/') + 1);
+ const read = (file: string) => !scratch(file) && classifyPath(file, classRoot) !== 'docs';
const named = new Map>();
const direct = new Set();
const probed = new Set();
let probe = candidates;
for (let depth = 0; probe.length && depth < CONSUMER_SCAN_DEPTH; depth++) {
- const patterns = [...new Set(probe.map(base))].flatMap(b => ['-e', b]);
- const runs = [...revs.map(rev => ({ at: rev, args: [rev, '--'] })), ...(live ? [{ at: '', args: ['--untracked', '--'] }] : [])];
+ const patterns = [...new Set(probe.map(base))];
const next = new Set();
- for (const run of runs) {
- const r = git(root, ['grep', '-I', '-F', '-o', '--null', '--no-line-number', '--no-column', '--no-color', ...patterns, ...run.args]);
- if (r.status !== 0 && r.status !== 1) { errors.push({ code: 'consumer_scan_failed', detail: oneLine(r.err || `git grep exited ${r.status}`), input: `git grep -F ${run.at || '--untracked'}`, repair: 'run the git grep by hand and fix what git reports' }); return 'all'; }
- for (const line of r.out.split('\n')) {
+ for (const at of [...revs, ...(live ? [''] : [])]) {
+ const r = grepConsumers(root, patterns, at, read, deadline);
+ if (!Array.isArray(r)) {
+ const detail = r.timedOut ? `timed out: the scan's ${timeoutMs / 1000} s deadline passed (GSTACK_CONSUMER_SCAN_TIMEOUT_MS)` : `git grep exited ${r.status}${r.err ? `: ${r.err}` : ''} (deadline ${timeoutMs / 1000} s)`;
+ errors.push({ code: 'consumer_scan_failed', detail: oneLine(detail), input: `git grep -F ${at || '--untracked'}`, repair: r.timedOut ? 'raise GSTACK_CONSUMER_SCAN_TIMEOUT_MS (milliseconds) or run the git grep by hand to see why it is slow' : 'run the git grep by hand and fix what git reports' });
+ return 'all';
+ }
+ for (const line of r) {
const parts = line.split('\0');
const where = parts[0];
const match = parts.length > 1 ? parts[parts.length - 1] : undefined;
if (!where || !match) continue;
- const file = run.at ? where.slice(run.at.length + 1) : where;
- if (scratch(file) || classifyPath(file, classRoot) === 'docs') continue;
+ const file = at ? where.slice(at.length + 1) : where;
+ if (!read(file)) continue;
for (const p of probe) {
if (file === p || !match.includes(base(p))) continue;
if (!dataCandidate(file, classRoot)) { direct.add(p); continue; }
@@ -187,10 +255,11 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings });
}
-/** The working tree against the merge base with origin/ (or ), untracked files counted per path; the registry is the base ref's commit. */
+/** The working tree against the merge base with origin/ (or ; a remote-qualified base reads refs/remotes/ only), 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 = [`refs/remotes/origin/${base}`, `refs/heads/${base}`].find(r => git(root, ['rev-parse', '--verify', '-q', `${r}^{commit}`]).ok) ?? base;
+ const qualified = base.startsWith('origin/') || git(root, ['remote']).out.split('\n').some(r => r && base.startsWith(`${r}/`));
+ const ref = (qualified ? [`refs/remotes/${base}`] : [`refs/remotes/origin/${base}`, `refs/heads/${base}`]).find(r => git(root, ['rev-parse', '--verify', '-q', `${r}^{commit}`]).ok) ?? base;
const registry = loadRegistryAt(root, ref);
const mb = git(root, ['merge-base', ref, 'HEAD']);
if (!mb.ok) {
diff --git a/lib/review-depth.ts b/lib/review-depth.ts
index 37b1febe3..e8751cc8e 100644
--- a/lib/review-depth.ts
+++ b/lib/review-depth.ts
@@ -58,7 +58,12 @@ const GATE_PATH = [
/^lib\/review-(depth|collect|scope|targeted|risk)\.ts$/, /^review\/specialists\/targeted\.md$/, /^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$/,
];
-/** Data files: JSON, CSV, TSV and raster images. YAML and TOML are never data here: deploy configs and API specs stay code. An SVG is data only as a drawing (svgDrawing). */
+/** The path rules lib/review-exclude.ts keeps a matched file for: a gate path, agent prose, or a data-shaped config file. */
+export const isGatePath = (p: string) => GATE_PATH.some(r => r.test(p));
+export const isAgentProse = (p: string) => AGENT_PROSE.some(r => r.test(p));
+export const isDataConfig = (p: string) => DATA_CONFIG.some(r => r.test(p));
+
+/** Data files: JSON, CSV, TSV and raster images. YAML and TOML are never data here: deploy configs and API specs stay code. An SVG is data only as a drawing (svgDrawing); executable SVG stays code. */
export const DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$/i;
/** Data-shaped files tools read by convention, with no code naming them: always code. */
const DATA_CONFIG = [
@@ -222,13 +227,15 @@ export interface ClassInput {
/** Added and deleted lines per path (lib/review-collect.ts): the risk classes read them; without them no line signal applies. */
added?: Record;
removed?: Record;
+ /** Paths the base policy's review_exclude_paths leave out of every reader's diff (lib/review-exclude.ts): classified as docs. */
+ excluded?: ReadonlySet;
}
export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null; risk: RiskClass[] }
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 unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && (dataCandidate(p, i.root) || (!!i.drawings?.includes(p) && svgCandidate(p, i.root)));
-const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root));
+const pathClass = (i: ClassInput, p: string): PathClass => (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, 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 {
@@ -250,7 +257,7 @@ export function classifyDiff(i: ClassInput): ClassResult {
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');
+ const deletedGuarded = (i.deleted ?? []).filter(p => !i.excluded?.has(p) && classifyPath(p, i.root) !== 'docs');
if (deletedGuarded.length) raise('gate', `deletes ${deletedGuarded.length} code or test path(s)`);
}
if (CLASS_RANK[cls] >= CLASS_RANK.code) {
@@ -412,9 +419,11 @@ export interface PlanInput extends ClassInput {
tiers?: boolean;
/** The targeted classes (lib/review-targeted.ts) and whether the targeted reader already read this product patch. */
targeted?: { classes: readonly string[]; ran?: boolean };
+ /** The base policy's exclusion: one `:(top,exclude,glob)` pathspec per applied pattern, added to every reader, and the kept files under them as `:(top,literal)` pathspecs. */
+ exclusion?: { specs: readonly string[]; keep: readonly string[] };
}
/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what the batched specialist reads. */
-export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string }
+export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string; keep?: 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;
@@ -487,6 +496,11 @@ export function diffCommand(d: Dispatch): string {
return d.exclude.length ? `git diff -- . ${d.exclude.map(shellQuote).join(' ')}` : 'git diff ';
}
+/** The second command a reader runs when the base policy's exclusion keeps files its patterns match (a git exclude beats any positive pathspec); null otherwise. */
+export function keepCommand(d: Dispatch): string | null {
+ return d.keep?.length ? `git diff -- ${d.keep.map(shellQuote).join(' ')}` : null;
+}
+
/** 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 c = classifyDiff(i);
@@ -499,8 +513,10 @@ export function planReview(i: PlanInput): ReviewPlan {
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' && !(i.numbers.publishes && DOC_PATH.test(p))) || isOutput(i, p)).map(excludeSpec);
+ const x = i.exclusion;
+ const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial = {}) =>
+ dispatches.push({ role, name, effort, exclude: x ? [...exclude, ...x.specs] : exclude, ...extra, ...(x?.keep.length ? { keep: [...x.keep] } : {}) });
+ const docsAndOutputs = i.paths.filter(p => !i.excluded?.has(p) && ((pathClass(i, p) === 'docs' && !(i.numbers.publishes && DOC_PATH.test(p))) || isOutput(i, p))).map(excludeSpec);
const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint;
const firstVoices = h.fullPasses === 0 || !i.adversarialRan;
const specialist: Effort = i.tiers === false ? 'host' : ROLE_EFFORT.specialist;
diff --git a/lib/review-exclude.ts b/lib/review-exclude.ts
new file mode 100644
index 000000000..80533bc7d
--- /dev/null
+++ b/lib/review-exclude.ts
@@ -0,0 +1,408 @@
+/**
+ * review-exclude — the base policy's review_exclude_paths applied to one range, and the
+ * bounded read of each reviewer's diff (docs/ship-policy.md#review-exclude-paths, GST-508).
+ *
+ * The policy is read at the base commit the caller already resolved (loadPolicyAt), never
+ * from the head: a branch cannot exclude its own files. Git owns the glob dialect: every
+ * matched path comes from `git diff --raw -z` under one `:(top,glob)` pathspec
+ * per pattern (untracked files never count), so the receipt, the plan line and what
+ * readers see are one set. A matched file is excluded only when it is
+ * content (data, docs, binary or a .log) that no rule keeps; every kept file carries one
+ * stable reason. Applied patterns become `:(top,exclude,glob)` pathspecs on every reader,
+ * and kept files under them come back through a second `:(top,literal)` command, because a
+ * git exclude beats any positive pathspec. That second command is bounded by its argument
+ * bytes: a pattern whose kept files would pass KEEP_ARG_MAX_BYTES is not applied.
+ *
+ * readSelection reads one reader's diff once, with a timeout, and counts its bytes;
+ * gstack-review-dispatch stops with REVIEW_DIFF_TOO_LARGE above REVIEW_DIFF_MAX_BYTES and
+ * with REVIEW_DIFF_UNREADABLE when git fails, before it writes a manifest or a brief.
+ */
+import { spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { collectLive } from './review-collect';
+import { DATA_PATH, classifyPath, isAgentProse, isDataConfig, isGatePath, type PathLines } from './review-depth';
+import { secretLine } from './review-targeted';
+import { cmpVersion, parseVersion } from './version-source';
+import { REVIEW_EXCLUDE_KEY_VERSION, SHIP_POLICY_PATH, isUnavailable, policyUnavailableLine } from './ship-policy';
+import { loadPolicyAt } from './ship-policy-at';
+
+/** The most diff bytes one reader is handed (about 500K tokens); above it the pass stops instead of truncating. */
+export const REVIEW_DIFF_MAX_BYTES = 2_000_000;
+/** The most argument bytes the kept-file command may carry across all applied patterns (under Windows' 32,767-character command line). */
+export const KEEP_ARG_MAX_BYTES = 24_000;
+/** How long one reader's `git diff` may run. */
+export const REVIEW_DIFF_TIMEOUT_MS = 120_000;
+const MAX_BUFFER = 256 * 1024 * 1024;
+export const KEPT_REASONS = ['policy-dir', 'gate-path', 'executable', 'agent-prose', 'config', 'gate-tagged', 'code', 'secret-line', 'named-by-code', 'consumer-scan-unavailable', 'gate-scan-unavailable', 'link'] as const;
+export type KeptReason = (typeof KEPT_REASONS)[number];
+
+/** What the receipt records and the queue and carry compare (by `digest`, never by `source`). */
+export interface ExcludeRecord {
+ source: string; patterns: string[]; applied: string[]; skipped: Array<{ pattern: string; kept: number; bytes: number }>; unmatched: string[];
+ excluded: number; excluded_bytes: number; kept: number; gate_tagged: string[]; patterns_sha256: string; list_sha256: string; digest: string;
+}
+export interface ActiveExclusion {
+ state: 'active'; record: ExcludeRecord; excluded: Set; specs: string[]; keep: string[];
+ kept: Array<{ path: string; reason: KeptReason }>; content: Record; references: Array<{ prefix: string; files: string[] }>;
+}
+export type Exclusion =
+ | { state: 'none' }
+ | { state: 'head-only'; patterns: string[]; source: string }
+ | { state: 'refused'; code: 'POLICY_SOURCE_UNAVAILABLE' | 'POLICY_INVALID' | 'REVIEW_DIFF_UNREADABLE'; lines: string[] }
+ | ActiveExclusion;
+
+export interface ExcludeInput {
+ root: string;
+ /** The base commit whose policy applies, and the ref it was resolved from (origin/ or ). */
+ baseSha: string; ref: string;
+ /** The merge base, and the head commit; no head reads the working tree with untracked files. */
+ mergeBase: string; head?: string;
+ /** The collector's per-path lines (binary flags) and added lines (lib/review-collect.ts). */
+ perPath: readonly PathLines[]; added: Record;
+ /** Skill directory names (commit mode) or the repository root (live): markdown in a skill directory is code. */
+ classRoot: string | readonly string[];
+ /** Paths whose hunks the gate scan tagged; null when unknown. */
+ gateTagged?: ReadonlySet | null;
+ /** The command name the refusal lines start with. */
+ context: string;
+}
+
+const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
+const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
+const literal = (p: string) => `:(top,literal)${p}`;
+const argBytes = (paths: Iterable) => { let n = 0; for (const p of paths) n += Buffer.byteLength(shellQuote(literal(p))) + 1; return n; };
+
+function git(root: string, args: string[], input?: string): { ok: boolean; status: number | null; out: string; err: string } {
+ const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, input, encoding: 'utf8', timeout: 120_000, maxBuffer: MAX_BUFFER });
+ return { ok: r.status === 0, status: r.status, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
+}
+
+/** The patterns a policy text declares, or [] for anything unreadable: only a notice reads the head's copy. */
+function declared(text: string | null): string[] {
+ try { const v = JSON.parse(text ?? 'null')?.review_exclude_paths; return Array.isArray(v) ? v.filter((p): p is string => typeof p === 'string') : []; } catch { return []; }
+}
+function headPatterns(i: ExcludeInput): string[] {
+ if (!i.head) { try { return declared(fs.readFileSync(path.join(i.root, SHIP_POLICY_PATH), 'utf8')); } catch { return []; } }
+ const r = git(i.root, ['show', `${i.head}:${SHIP_POLICY_PATH}`]);
+ return r.ok ? declared(r.out) : [];
+}
+
+/** A pattern's literal directory prefix (`receipts/` for `receipts/**`), or '' when it starts with a glob. */
+export function literalPrefix(pattern: string): string {
+ const at = pattern.search(/[*?[]/);
+ const head = at < 0 ? pattern : pattern.slice(0, at);
+ return head.slice(0, head.lastIndexOf('/') + 1);
+}
+
+interface Matched { oldMode: string; newMode: string; oldSha: string; newSha: string }
+
+/** Every changed path each pattern matches, with its modes, from git's raw diff; untracked files are never part of it, since no reader's git diff shows them and the receipt reads the committed head. */
+function select(i: ExcludeInput, patterns: readonly string[]): { byPattern: Map; files: Map } | { error: string } {
+ const byPattern = new Map();
+ const files = new Map();
+ const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase];
+ for (const p of patterns) {
+ const spec = `:(top,glob)${p}`;
+ const raw = git(i.root, ['diff', '--raw', '-z', '--no-abbrev', '--no-renames', ...range, '--', spec]);
+ if (!raw.ok) return { error: `git diff --raw ${range.join(' ')} -- ${spec}: ${raw.err.split('\n')[0] || `exit ${raw.status}`}` };
+ const parts = raw.out.split('\0');
+ const hit: string[] = [];
+ for (let k = 0; k + 1 < parts.length; k += 2) {
+ const [oldMode, newMode, oldSha, newSha] = parts[k]!.slice(1).split(' ');
+ const f = parts[k + 1]!;
+ if (!f) continue;
+ hit.push(f);
+ if (!files.has(f)) files.set(f, { oldMode: oldMode ?? '', newMode: newMode ?? '', oldSha: oldSha ?? '', newSha: newSha ?? '' });
+ }
+ byPattern.set(p, hit);
+ }
+ return { byPattern, files };
+}
+
+/**
+ * The matched paths git calls binary whose bytes hold a NUL in their first 8,000 (git's own test), read from the head blob,
+ * the file on a live tree, or the old blob for a deletion. A branch's .gitattributes can make git call a text file binary;
+ * only real bytes make it binary content here.
+ */
+function binaryContent(i: ExcludeInput, files: ReadonlyMap, claimed: ReadonlySet, unread: Set]