diff --git a/.gstack/pregate.json b/.gstack/pregate.json
index 6be73a295..5d1caa4f3 100644
--- a/.gstack/pregate.json
+++ b/.gstack/pregate.json
@@ -22,6 +22,11 @@
"ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" },
"ci-reuse/windows-free-tests": { "workflow": "windows-free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["windows-plan", "windows-free-tests"], "artifact": "tested-tree", "os": "Windows" }
},
+ "remote_gate_runs": {
+ "profiles": ["ci-reuse/free-tests"],
+ "inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'",
+ "skipped": "bun --no-env-file --config=/dev/null -e 'import { isPaidTestFile } from \"./test/helpers/paid-test-set.ts\"; import * as fs from \"node:fs\"; for (const f of fs.readFileSync(process.env.PREGATE_FILES_FILE, \"utf8\").split(/\\r?\\n/)) if (f && isPaidTestFile(f)) console.log(f);'"
+ },
"warn": [],
"strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c97693ba1..dae015abc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,243 @@
# Changelog
+## [1.93.37.0] - 2026-11-03
+
+**Eval planning prices Claude Haiku 5.5, GPT-6 Luna and Gemini 4 Argon, and the Codex docs name the current OpenAI tiers (#3640, #2943).** Before, `gstack-eval-plan arms --arms claude-haiku-5-5` stopped with EVAL_PRICE_MISSING because `lib/pricing.ts` had no row for it. docs/hosts.md and the /codex example still named gpt-5.6-sol as the current Sol model.
+
+### Added
+- **Price rows** for `claude-haiku-5-5` ($0.10 / $0.50, cached $0.01, standard tier up to 100,000 tokens), `gpt-6-luna` ($0.10 / $0.50, cached $0.01) and `gemini-4-argon` ($4.00 / $20.00, cached $0.20). Each row cites its provider page. Argon uses the rates that start on 2026-12-01, so estimates never come in low after that date. A repository's model-policy document that names one of these models now gets it as an eval arm.
+
+### Changed
+- **docs/hosts.md and /codex** name `gpt-6-astra`, `gpt-6.1-sol` and `gpt-6-luna` as the current tiers. They also say the bounded-scope overlay still applies only to `gpt-5.6-sol` until it has its own eval.
+
+Thanks to @exGeni (#3640) and @DanCanadian (#2943) for the reports. The TypeSafe Jev rows from #3640 are not included, because a $0 output price breaks the price table's rule that every row has an output price.
+
+## [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.**
+
+### Fixed
+- **`ship/SKILL.md` Steps 12 and 15**: Step 15 no longer tells the agent to make the VERSION/CHANGELOG commit by hand while Step 16's `gstack-ship-release finish` makes the same commit. Feature work is committed in Step 15, and `finish` commits the release files last with the co-author trailer. Step 12 now says what a `VERSION: skipped` (stamp-at-merge or changelog fragments) or `VERSION: kept` result leaves for Steps 13, 18 and 20. All six judge samples in the two recorded reds (E2E Evals 41429095451, 41445571955) named the commit conflict, and three named the undefined skipped/kept result. Three focused judge runs on the fix passed (actionability median 4 each), and none of their nine samples cited either one.
+
+## [1.93.23.0] - 2026-11-02
+
+**Four tests from the 2026-11-02 flaky pass stop failing correct runs, and one more names its cause when it fails.**
+
+### Fixed
+- **`test/skill-e2e-plan-design-with-ui.test.ts`**: the Step 0D focus detector accepts the native question header "Focus" with the design rating and an all-7 option, instead of needing one more label spelling. Train gate 41414111284 offered "Full review, all 7 (recommended)", which the old label rule missed. All ten captured focus questions carry that header, and no other captured question does. The capture is saved as a fixture, and eight negative controls cover a wrong header, a missing rating, a 4/7 rating, issue-numbered options, a single option and no all-7 option.
+- **`test/skill-e2e-shared-libs.test.ts`**: the `shared-libs-review-revalidation` callback passes the advisory's source files to the checker, so a batched read that exits nonzero but prints the whole file counts, as the checker already allows elsewhere. Train screen 40737741454 failed this way: its one read of `src/retry-worker.ts` was `cat …; echo ===ROUTE; …`, which exited 1 after printing the file. A truncated output and a single-command errored read still fail.
+- **`test/helpers/git-fixture-template.ts`**: the template leak scan skips every `.capy` directory, not only `.capy/git-shim.log`. Capy's git shim creates `$HOME/.capy` in whatever home it runs under, so a fixture home that ran git holds the machine's files, not gstack's. A file named `.capy` and look-alike directories (`.capy-old`, `capy`) are still scanned.
+- **`test/helpers/restamp-fixture.ts`, `test/helpers/pregate-fixture.ts`**: fixture repositories pin `GSTACK_STATE_ROOT` to their own home, so a value exported by the gstack launch file no longer redirects their state when bun runs without the test preload (`--config=/dev/null`). `test/review-targeted.test.ts`'s end-to-end case failed every time under that setup and passes with the fix.
+
+### Changed
+- `test/plan-reality-receipts.test.ts` checks each git step's exit status and prints its stderr, so a red like Free Tests 41316085384 and 41375226438 (an empty commit sha) names the git step that failed.
+
+## [1.93.22.0] - 2026-11-02
+
+### Fixed
+
+- **The targeted reader's record is honest from verify to the receipt.** `gstack-review-dispatch verify` no longer marks a completed targeted reader `status=failed findings=0` because a Step 11 fix moved HEAD after it read (gstack-evals#140, C5-36 and C6-36). The record now says `done` with its real finding count and names the commit it read. `gstack-ship-receipt write --review-targeted` binds it only while that commit's product patch is the receipt's and nothing since it carries a money, gate, auth or secret change (a test edit that skips a case counts as gate), so a record can no longer vouch for code it never read.
+- **`write` and `carry` refuse a receipt the queue refuses (#3854, #3859).** When the head touches money, gate, auth or secret code and no done targeted pass covers it, `write` refuses with `RECEIPT_INVALID` and `carry` with `RECEIPT_CARRY_REFUSED`, instead of publishing a receipt `ready` turns away. Legacy receipts still carry.
+- **/ship passes the targeted record to the receipt (#3853).** Step 11's verify writes `.gstack/tmp/targeted.json`, and the Step 19, handoff and driver receipt writes pass it with `--review-targeted`.
+- **Fix-check rounds count without hand edits (#3862).** `gstack-review-scope summary --run` counts fix-checks from the dispatch ledgers when no `review-fix-check` row was logged, and counts the blockers those fix-checks followed as fixed.
+
+## [1.93.21.0] - 2026-11-02
+
+**/ship's finding collector lets the native reviewer's own Severity field decide outside gate and risk reviews.**
+
+### Fixed
+
+- On a code or docs review with no risk reason (security, secrets, auth, money, user data), no depth error and no history or floor escalation, a native adversarial finding marked FIXABLE that states its own `Severity: Medium` or `Severity: Low` and `Blocks: no` now collects at that severity instead of CRITICAL. Gate and risk reviews, and any collect without this pass's manifest, keep collecting FIXABLE as CRITICAL, and so does a FIXABLE finding without `Blocks: no` or without a Severity field. (GST-987)
+
+## [1.93.20.0] - 2026-11-02
+
+### Changed
+
+- `gstack-factory-scorecard` now counts a PR as "at the cap" once it reaches 2 review passes, the two-round cap the review policy sets, instead of 3. The default `--pass-cap` is 2, and the "Review passes per PR" benchmark reads "median 1 pass, few at the cap". Pass `--pass-cap 3` to get the old count. Rows from earlier weeks in `trend.csv` were counted at the old cap, and each row names the cap it used.
+
+### Docs
+
+- CLAUDE.md now describes how gstack versions its own PRs today: a branch writes its entry as `changelog.d/.md` and never edits VERSION, package.json or CHANGELOG.md, and the merge train's stamp assigns the version (`bump: minor` or `bump: major` front matter asks for more than a patch). The old text said to claim a version slot with `bin/gstack-next-version`.
+- docs/CHANGELOG_STYLE.md, which CLAUDE.md says to read before writing an entry, now describes the same fragment flow: what goes in the fragment, when to add `bump:` front matter, and what to check after merging main. It no longer tells a branch to bump VERSION or claim a slot with `bin/gstack-next-version`.
+- docs/ship-policy.md marks the queue-side procedure as being for repos on `stamp-at-merge` without changelog fragments, and points gstack's own PRs to the fragment flow and `train land`. The old link to that section still works.
+
+## [1.93.19.0] - 2026-11-02
+
+**`gstack-ship-receipt read --check-prose` no longer reads the Documentation exception line or code fences as counter claims (GST-983, GST-984).** `write --docs` with an owner exception requires the PR body to carry the whole `Documentation exception: …` line word for word, but the prose check read the owner's reason in it as prose, so a reason like "sections 1 and 2 skip the audit" was a `2 skip` claim and no body could pass both checks. That is the deadlock GST-977 fixed for the Review-loop exception line. Separately, on gstack-evals #142, test output pasted in a fenced block ("113 passed, 0 failed") and a quoted code comment ("the first 35 fail") were read as receipt counts and refused.
+
+### Fixed
+- **`lib/ship-receipt-prose.ts`:** the line that matches the receipt's own docs exception exactly is skipped, like the Review-loop exception line. The same words anywhere else, or a line for a different exception, are still checked.
+- **`lib/ship-receipt-prose.ts`:** a closed code fence (backticks or tildes, closed by a run of its own character at least as long as the opener) is skipped. Counts outside the fence are still checked. A line that starts with inline code like ```` ```npm test``` ```` opens no fence, and a fence that never closes stays prose along with the rest of the body, so an unclosed opener cannot hide the claims after it.
+
+### Tests
+- `test/ship-receipt-prose.test.ts`: the docs exception line passes `--check-prose` beside a matching `70 skip` claim; the #142 fenced evidence passes while a wrong count outside the fence still fails; an unclosed fence stays prose; inline triple backticks and a shorter fence inside an unclosed one hide no claim. On main the first three cases failed.
+
+## [1.93.18.0] - 2026-11-02
+
+**A green Free Tests run at the pregate tree now clears gstack's remote gate, so a slow Ubicloud VM no longer blocks a PR whose CI already passed (GST-1024).** docs/pregate.md described `gstack-pregate clear --remote pregate.json/remote_gate --run --allow-repo-commands`, but gstack's own `.gstack/pregate.json` declared no `remote_gate_runs`, so the route always refused. On #3869, every check passed at 3f2bcf3 (Free Tests run 41484634592), while two remote gate runs at the same tree failed on bun timeouts in files the PR doesn't touch.
+
+### Added
+- **`.gstack/pregate.json` `remote_gate_runs`:** the `ci-reuse/free-tests` profile, with `scripts/test-free-shards.ts --list` as the inventory (the complete free suite the Free Tests plan shards).
+- **`remote_gate_runs.skipped`** (optional, `lib/run-evidence.ts`, `lib/pregate/index.ts`, `lib/pregate/run-check.ts`): a command that reads the selected files from `PREGATE_FILES_FILE` and prints the ones the remote gate itself never runs. Those files need no run, and the receipt records them as `skipped=`. An inventory file stays required even when `skipped` names it, and a failing command refuses with `skipped_failed`. When every selected file is skipped, the clear refuses with `paid_only`, just as the gate's own `tests=0` report never clears. gstack's command prints the selected paid test files, the same split `scripts/ubicloud/pregate-remote.sh` makes. Without it, run 41484634592 still refused: all 71 of the selected files it didn't cover are paid.
+
+### Tests
+- `test/pregate-remote-gate-runs.test.ts`: gstack's inventory equals the Free Tests plan's file set, and its skipped command prints exactly the paid files of a mixed list. In a fixture with gstack's real profile, a green Free Tests `pull_request` run clears the gate. A run at another tree, a run of another workflow, a failed or cancelled run, a fork run, a merge commit behind its base, an uncovered inventory file, a skipped list naming an inventory file, a failing skipped command, a paid-only selection, and a declaration without `skipped` all refuse. Malformed `skipped` values fail config validation. On main, all four tests fail.
+
## [1.93.17.0] - 2026-11-02
- `gstack-ship-queue wave plan` now leaves out a READY head that does not merge onto main. It prints `NOT_MERGEABLE: # (...)` with the conflicting files, the same way `NOT_GREEN` works for red CI. A conflict only in the CHANGELOG doesn't count, since the train merges those. Before, such a head was planned into a wave and the wave build stopped on it.
diff --git a/CLAUDE.md b/CLAUDE.md
index f2a630b7b..67810a17d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -374,18 +374,22 @@ is not a reason to choose MINOR. Choose **MINOR (X.Y+1.0.0)** or **MAJOR
("ridiculous"), such as an incompatible public-interface change or a genuinely
new product-scale release. Make that judgment without another approval question.
-Use `bin/gstack-next-version` to check the live release queue before publishing.
-If a slot is claimed, advance to the next available version at the chosen bump
-level and use `bin/gstack-version-bump` to synchronize release metadata. A higher
-base version does not itself require a MINOR bump. Keep the PR ready for Garry to
-merge; autonomous version decisions do not authorize merging, deploying or
-skipping required validation.
+gstack's policy sets `changelog_fragments` (#3654), so a branch never writes a
+version and never checks `bin/gstack-next-version` for a slot. The merge
+train's stamp (`train land`) assigns base + PATCH at merge. To ask for more,
+open the branch's fragment with `bump: minor` (or `major`) front matter. A
+higher base version does not itself require a MINOR bump. Keep the PR READY for
+the merge train; autonomous version decisions do not authorize merging,
+deploying or skipping required validation.
-**VERSION and CHANGELOG are branch-scoped.** Each shipping branch gets its own
-bump and one entry, written at `/ship` time, that describes what THIS branch
-adds versus main, for users, with no branch-internal versions or development
-narrative. VERSION stays 4-digit; package.json carries the 3-digit npm
-translation owned by `bin/gstack-version-bump`, so never hand-edit it. Read
+**The CHANGELOG entry is a branch fragment.** Each shipping branch writes one
+entry at `/ship` time as `changelog.d/.md` (entry text only, no
+`## [x]` heading) that describes what THIS branch adds versus main, for users,
+with no branch-internal versions or development narrative. A branch never edits
+VERSION, package.json or a released CHANGELOG.md section: the merge stamp folds
+the fragment into CHANGELOG.md under its version, keeps VERSION 4-digit and
+writes package.json's 3-digit npm translation through `bin/gstack-version-bump`
+([changelog fragments](docs/ship-policy.md#changelog-fragments)). Read
[docs/CHANGELOG_STYLE.md](docs/CHANGELOG_STYLE.md) BEFORE writing an entry: it
holds the versioning invariant, the merge-main checks, what stays out and the
entry format. Always credit community contributions with
diff --git a/TODOS.md b/TODOS.md
index 53af0edce..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.
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 a194d2ff7..c6dda2541 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.93.17.0
+1.93.37.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index 12980b593..75530d6d5 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.93.17.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.93.37.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/autoplan/DRIVER.md b/autoplan/DRIVER.md
index 89b7f2782..a2aef2c50 100644
--- a/autoplan/DRIVER.md
+++ b/autoplan/DRIVER.md
@@ -20,7 +20,7 @@ The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected)
TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (``) as the plan's first
-line yourself; gstack never writes the plan.
+line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base ` (default `origin/HEAD`), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none ()`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: ` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full.
## The loop
@@ -57,8 +57,8 @@ line yourself; gstack never writes the plan.
### Commands
-- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--scaffold] [--ceo-only]`
- first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves 5.00 per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends `status=reviewed` with `/ceo-review.md` (`REVIEWED:`): no gate, and the gate verbs refuse it (`CEO_ONLY_NO_GATE`).
+- `gstack-autoplan next --out --plan [--kind feature|--full] [--base ] [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--scaffold] [--ceo-only]`
+ first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --kind feature (the default for an unattended Capy run) is the lean run above and prints LEAN: and DRAFT:, --full keeps every phase and voice, and `next --kind fix` alone (no --out or --plan) reviews nothing and ends `status=skipped run=-`; --max-rounds caps review rounds per phase (default 2 unattended); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves 5.00 per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends `status=reviewed` with `/ceo-review.md` (`REVIEWED:`): no gate, and the gate verbs refuse it (`CEO_ONLY_NO_GATE`).
- `gstack-autoplan run --out --plan --native-model --outside-model --native-runner api --runner api [--max-tokens ] [--spend-cap ]`
hosts without subagents: one command calls both models directly (ANTHROPIC_API_KEY, OPENAI_API_KEY) and loops next/submit to the gate; a response cut off at the output limit is reported truncated, so raise --max-tokens (default 16384 for both providers; OpenAI counts reasoning tokens toward it); each api call reserves its priced worst case (a model with no price reserves the per-voice default under a cap), a native call that went out but bound no result settles at its usage, or its full reservation when the usage is unknown, and a failed outside call stays counted at its reservation.
- `gstack-autoplan next --out [--redispatch ]`
diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl
index 9b3ed3765..2831814f7 100644
--- a/autoplan/DRIVER.md.tmpl
+++ b/autoplan/DRIVER.md.tmpl
@@ -17,7 +17,7 @@ The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected)
TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (``) as the plan's first
-line yourself; gstack never writes the plan.
+line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base ` (default `origin/HEAD`), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none ()`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: ` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full.
## The loop
diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan
index 5cec0e763..c5f6d88eb 100755
--- a/bin/gstack-autoplan
+++ b/bin/gstack-autoplan
@@ -2,7 +2,8 @@
/**
* gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2).
*
- * gstack-autoplan next --out [--plan ] [--plan-heading ] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only]
+ * gstack-autoplan next --kind fix a bug fix: no plan review (R65), status=skipped, nothing written (run --kind fix too)
+ * gstack-autoplan next --out [--plan ] [--kind feature|--full] [--base ] [--plan-heading ] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only]
* [--no-outside] [--light] [--spend-cap ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json]
* gstack-autoplan submit --out --phase
--voice native|outside --result | --record [--attest ]
* --model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--json]
@@ -45,6 +46,7 @@ import { capyRelayLine } from '../lib/captain-brief';
import { exportPublic } from '../lib/autoplan-public';
import { migrateRun } from '../lib/autoplan-migrate';
import { reviseRun } from '../lib/autoplan-revise';
+import { FIX_LINES, KINDS, type AutoplanKind } from '../lib/autoplan-lean';
import { pendingAmendments, type RunState, type Voice } from '../lib/autoplan-state';
import { FILES, RunError, currentPhase, fitReservation, initRun, markOutside, markSent, nextStep, readState, resumeRun, retireAttempt, statusLines, submitResult, withRunLock, writeState, type NextOptions } from '../lib/autoplan-run';
import { driverFile, type Phase } from '../lib/autoplan-prompts';
@@ -61,9 +63,16 @@ import { exportShadow, renderShadowExport } from '../lib/autoplan-shadow-reader'
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
DRIVER: ${driverFile()}
- next --out [--plan ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only]
+ next --kind fix a bug fix needs no plan review (R65): prints AUTOPLAN: not needed and status=skipped, writes nothing (no --out or --plan);
+ the PR still needs the bug's proof (a repro on current main or a failing test written first)
+ next --out [--plan ] [--kind feature|--full] [--base ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only]
[--no-outside] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json]
- --max-rounds (first next): review rounds per phase (default 2 unattended); at the cap a phase closes accepted-with-findings
+ --kind feature (first next; the default for an unattended Capy run, GSTACK_CAPY=1): the lean run (R65): the eng phase alone with one
+ native reviewer whose brief merges the CEO and Eng lenses (Design/DX when scope detects them), reads the draft code (git diff of
+ --base, default origin/HEAD, hashed into the snapshot; DRAFT: none when there is no code yet) and always asks about outside terms;
+ no outside voice, one round, one gate revision (accepted changes apply without re-review), no carry check
+ --full (first next): every phase and both voices, as before R65; off Capy, or in an interactive session, the default stays full
+ --max-rounds (first next): review rounds per phase (default 2 unattended, 1 lean); at the cap a phase closes accepted-with-findings
--docs-only (first next; also detected when every Implementation-plan path is docs-class): CEO (native voice only) + Eng
--ceo-only (first next, command line only): the CEO phase alone, no gate; ends status=reviewed with /ceo-review.md, never approved
--estimate-usd: reserved per new attempt, kept for the rest of the run; with --spend-cap any run (or an unattended run on Capy) reserves a default so the cap binds
@@ -88,6 +97,7 @@ DRIVER: ${driverFile()}
--settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4)
run --out --plan --native-model --outside-model [--runner codex-cli|api] [--native-runner api] ...
next in a loop, only when a runner executes both voices here (hosts without subagents: --native-runner api --runner api);
+ the full run unless --kind feature (the lean run) or --kind fix (no review, nothing written);
--deadline cancels the children it started; --max-tokens caps each api call's output (default 16384; OpenAI counts reasoning tokens in it), and a
response cut off at that limit is reported truncated; submit's result checks judge every completed response
answer --out --gate-rev --reply "" --by > [--json]
@@ -247,7 +257,7 @@ function doNext(out: string, rest: string[], price?: NextOptions['price']): Repo
if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
const shadow = shadowSwitch({ outsideEnabled: !rest.includes('--no-outside'), ceoOnly }, process.env);
if (shadow.line) initLines.push(shadow.line);
- initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
+ initRun({ plan, out, cwd, kind: flag(rest, '--kind') as AutoplanKind | undefined, full: rest.includes('--full'), base: flag(rest, '--base'), planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly, shadow: shadow.shadow,
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
}
@@ -342,7 +352,9 @@ function doBudget(out: string, rest: string[], json: boolean): number {
return EXIT.ok;
}
-async function doRun(out: string, rest: string[], json: boolean): Promise {
+async function doRun(out: string, args: string[], json: boolean): Promise {
+ // run pays for both voices, so it starts the full run unless --kind feature asks for the lean one (R65).
+ const rest = args.includes('--kind') ? args : [...args, '--full'];
const nativeModel = flag(rest, '--native-model'); const outsideModel = flag(rest, '--outside-model');
const outsideRunner = (flag(rest, '--runner') ?? 'codex-cli') as RunnerId; const nativeRunner = (flag(rest, '--native-runner') ?? 'api') as RunnerId;
if (!nativeModel || !outsideModel) return usage('run needs --native-model and --outside-model (a runner must execute both voices)');
@@ -444,6 +456,16 @@ async function main(argv: string[]): Promise {
try { process.stdout.write(checkpointLines(from).join('\n') + '\n'); return EXIT.ok; }
catch (e: any) { if (!(e instanceof RunError)) throw e; process.stderr.write(resultLine('gstack-autoplan checkpoints', e.code, e.detail) + '\n'); return EXIT.refused; }
}
+ if ((cmd === 'next' || cmd === 'run') && rest.includes('--kind')) {
+ const kind = flag(rest, '--kind');
+ if (!(KINDS as readonly string[]).includes(kind ?? '')) return usage(`--kind takes fix or feature (got ${JSON.stringify(kind ?? '')})`);
+ if (kind === 'fix') {
+ const live = flag(rest, '--out');
+ if (live && fs.existsSync(path.join(path.resolve(live), FILES.run))) return usage(`--kind fix starts no run, but ${live} already holds a run: continue it without --kind, or pass another --out`);
+ emit(json, FIX_LINES, { skill: 'autoplan', status: 'skipped', kind: 'fix', run: null, lines: FIX_LINES.slice(0, -1) });
+ return EXIT.ok;
+ }
+ }
const outFlag = flag(rest, '--out');
if (!outFlag && cmd !== 'shadow') return usage(`${cmd} needs --out `);
const out = path.resolve(outFlag ?? '.');
diff --git a/bin/gstack-factory-scorecard b/bin/gstack-factory-scorecard
index 76793c70c..1195406bd 100755
--- a/bin/gstack-factory-scorecard
+++ b/bin/gstack-factory-scorecard
@@ -11,7 +11,7 @@
* gstack-factory-scorecard [--repo owner/name] [--base main] [--since-days 7]
* [--crew login,login] [--gate-workflow evals.yml] [--gate-event workflow_dispatch|any]
* [--free-workflow free-tests.yml] [--train-prefix train/] [--ready-marker ]
- * [--pass-marker ] [--pass-cap 3] [--max-artifact-runs 60]
+ * [--pass-marker ] [--pass-cap 2] [--max-artifact-runs 60]
* [--out-dir ] [--csv ] [--json] [--cache-dir ]
* [--fixtures ] [--now ]
*
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-review-dispatch b/bin/gstack-review-dispatch
index 06b9414fb..28dd304b0 100755
--- a/bin/gstack-review-dispatch
+++ b/bin/gstack-review-dispatch
@@ -21,11 +21,11 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { BRIEF_ROLES, BriefError, briefFile, checkReviewedTree, checkoutRoot, checkoutTree, promptHash, renderBrief, sectionTexts, sliceDiff, sliceNote, withWorkspace, type BriefRole, type Slots, type TreeCheck } from '../lib/review-brief';
-import { cleanMarkers, collect, mayBeUnavailable, parseFindings, readJsonl, readLedger, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine, type VerifyRow } from '../lib/review-ledger';
+import { cleanMarkers, collect, fixableRaises, mayBeUnavailable, parseFindings, readJsonl, readLedger, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine, type VerifyRow } from '../lib/review-ledger';
import { buildManifest, chunkCount, chunkOf, manifestPath, markReader, readManifest, renderStage, writeManifest, STAGES, type Extra, type Manifest, type PassReader, type ReaderState, type Stage } from '../lib/review-pass';
import type { Dispatch } from '../lib/review-depth';
import { runScriptSync } from '../lib/script-invocation';
-import { TARGETED_RULES, parseClassList, targetedBriefSha, targetedPatchId } from '../lib/review-targeted';
+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';
@@ -154,7 +154,7 @@ function render(): number {
fs.writeFileSync(file, text, { mode: 0o600 });
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(merged ? path.join(ROOT, 'review/specialists/targeted.md') : checklist), diff_base: diffBase ?? null, patch_id: diffBase ? targetedPatchId(process.cwd(), diffBase) : 'none' };
+ const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(merged ? path.join(ROOT, 'review/specialists/targeted.md') : 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, ...(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;
@@ -336,10 +336,12 @@ function program(): number {
/**
* The receipt's review_targeted record from the newest targeted dispatch: done only when its brief was sent verbatim, its output
- * collected, and the committed diff it was rendered on is still the one at verify (targetedPatchId; uncommitted never proves it).
+ * collected, and it was rendered on a committed diff (uncommitted never proves what was read). `head` names the commit it read:
+ * a later commit does not undo the read, and gstack-ship-receipt write binds the record only while that commit's product patch is
+ * the receipt's (GST-986: Step 11 fixes after the read printed status=failed findings=0 for a completed reader).
* R65's merged reader (an adversarial-native brief rendered with --classes) is a targeted dispatch too (design C7).
*/
-function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[]; rules: string; brief_sha: string; status: 'done' | 'failed'; findings: number; raised_by: string } | null {
+function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[]; rules: string; brief_sha: string; status: 'done' | 'failed'; findings: number; raised_by: string; head: string | null } | null {
const events = readLedger(ledger);
const render = events.filter(e => e.event === 'rendered' && (e.reader === 'targeted' || (e.reader === 'adversarial-native' && typeof e.brief_sha === 'string'))).at(-1);
if (!render) return null;
@@ -347,10 +349,11 @@ function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[];
const label = rows.filter(r => r.kind === 'BRIEF' && r.reader.split('@')[0] === key).at(-1)?.reader;
const brief = rows.filter(r => r.kind === 'BRIEF' && r.reader === label).at(-1);
const output = rows.filter(r => r.kind === 'OUTPUT' && r.reader === label).at(-1);
- const read = typeof render.patch_id === 'string' && !['uncommitted', 'none'].includes(render.patch_id) && typeof render.diff_base === 'string' && targetedPatchId(process.cwd(), render.diff_base) === render.patch_id;
- const done = brief?.status === 'MATCH' && output?.status === 'MATCH' && read;
+ const head = typeof render.head === 'string' && /^[0-9a-f]{40,64}$/.test(render.head) ? render.head : null;
+ const committed = typeof render.patch_id === 'string' && !['uncommitted', 'none'].includes(render.patch_id) && head !== null;
+ const done = brief?.status === 'MATCH' && output?.status === 'MATCH' && committed;
const ids = events.filter(e => e.event === 'output' && e.reader === key).at(-1)?.ids;
- return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: done && Array.isArray(ids) ? ids.length : 0, raised_by: key };
+ 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: key, head };
}
/**
@@ -401,10 +404,12 @@ function main(): number {
process.stdout.write(`COLLECT_UNAVAILABLE: reader=${reader} sha256=${r.sha} reason=${JSON.stringify(why)} (missing outside coverage: unavailable, never clean)\n`);
return EXIT.ok;
}
- const r = collect(ledger, reader, output, merged, path.resolve(outFile!), unavailable, tree);
+ const mf = flag('--manifest');
+ let plan: ReturnType | undefined;
+ try { plan = mf ? readManifest(mf) : undefined; } catch (e) { process.stderr.write(`gstack-review-dispatch collect: ${(e as Error).message}; native FIXABLE collects as CRITICAL\n`); }
+ const r = collect(ledger, reader, output, merged, path.resolve(outFile!), unavailable, tree, fixableRaises(plan && path.resolve(plan.ledger) === path.resolve(ledger) ? plan.plan_lines : undefined));
if (tree && !tree.ok) {
- const mf = flag('--manifest');
- if (mf) { const m = readManifest(mf); markReader(m, reader, 'failed', { output: outFile, error: `reviewed another tree: ${tree.reported}` }); writeManifest(mf, m); }
+ if (mf && plan) { markReader(plan, reader, 'failed', { output: outFile, error: `reviewed another tree: ${tree.reported}` }); writeManifest(mf, plan); }
process.stdout.write(wrongTree(reader, tree));
return EXIT.fail;
}
@@ -426,12 +431,13 @@ function main(): number {
return r.ok ? EXIT.ok : EXIT.fail;
}
if (cmd === 'verify') {
+ if (flag('--targeted-out')) fs.rmSync(flag('--targeted-out')!, { force: true });
const v = verify(ledger);
for (const r of v.rows) process.stdout.write(`${r.kind} ${r.reader} ${r.status} ${r.detail}\n`);
if (!v.briefs) { process.stdout.write('VERIFY: no brief records (this run\'s ledger kept no prompts; dispatched-vs-rendered cannot be checked)\n'); return EXIT.refused; }
const record = targetedRecord(ledger, v.rows);
if (record) {
- 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}\n`);
+ 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');
}
const sel = excludeRecord(ledger);
diff --git a/bin/gstack-review-scope b/bin/gstack-review-scope
index 402090911..bd8280e29 100755
--- a/bin/gstack-review-scope
+++ b/bin/gstack-review-scope
@@ -27,7 +27,7 @@ import { runScriptSync } from '../lib/script-invocation';
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
import { EXIT as POST_EXIT, runPost } from '../lib/gstack-post';
import {
- parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
+ ledgerPass, parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
ALREADY_FILED_REASON, DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding,
} from '../lib/review-scope';
import { ledgerBlockers, type LedgerBlocker } from '../lib/review-ledger';
@@ -269,7 +269,7 @@ function cmdSummary(rest: string[], json: boolean): number {
if (!noLedger && !files.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', 'name the review\'s dispatch ledgers (--run or --ledger ), or --no-ledger when no reader ran through gstack-review-dispatch');
let ledgers: LedgerBlocker[][];
try { ledgers = files.map(ledgerBlockers); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', e.message); }
- const summary = summarizeReviewLoop(rows, since, ledgers);
+ const summary = summarizeReviewLoop(rows, since, ledgers, files.map(ledgerPass));
const line = reviewLoopLine(summary);
// A /ship invocation always runs Step 9 once; zero matching rows means the wrong --since or a review log written elsewhere, never a loop to record.
if (summary.passes === 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `no review-log row of this branch is a via:ship review at or after ${since}; check REVIEW_SINCE (${line})`);
diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt
index 2a7674a3f..d6f46105c 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, platformReceiptDepth, receiptExclude, receiptReviewAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
+import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, platformReceiptDepth, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
import { RISK_CLASSES, type RiskClass } from '../lib/review-risk';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
@@ -105,9 +105,11 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate (NEXT: platform's PLATFORM_REVIEW: id): records review_depth platform with capy-ai[bot]'s review at HEAD;
refused (RECEIPT_INVALID) unless the base policy has review.platform on, HEAD needs light review of a docs or tests diff with
no numeric, results or prereg claims, and that review is on the PR at HEAD. review_depth.mode records lean, panel or platform.
- --review-targeted ({classes, rules, brief_sha, status, findings, raised_by}: gstack-review-dispatch verify
- --targeted-out); recorded as review_targeted bound to the receipt's product_patch_id. Without it the receipt has no
- review_targeted, which the queue reports as legacy.
+ --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
+ refuse a receipt whose head needs a targeted class (money, gate, auth, secret) that no done pass covers, as the queue's
+ targeted verdict does (#3859) (RECEIPT_INVALID, RECEIPT_CARRY_REFUSED).
--approval-run (repeatable; resolves plan-sha citations) (W2: a class declared in origin/'s
.gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING)
--out --body . Defaults: head=HEAD, base=origin/HEAD's branch, version=the
@@ -247,7 +249,7 @@ function cmdWrite(rest: string[], json: boolean): number {
const product = reviewedProduct(repoRoot, base, head, pregateFile);
let targeted: ReturnType | undefined;
if (flag(rest, '--review-targeted') !== undefined) {
- try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none'); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; }
+ try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none', read => reviewedProduct(repoRoot, base, read, pregateFile).product_patch_id, read => targetedClassesSince(repoRoot, read, headSha ?? head)); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; }
}
if (targeted && !targeted.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targeted.message}; nothing written`) + '\n'); return EXIT.fail; }
const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') });
@@ -293,6 +295,12 @@ function cmdWrite(rest: string[], json: boolean): number {
process.stderr.write('gstack-ship-receipt write: nothing written\n');
return EXIT.fail;
}
+ const targetedVerdict = receiptTargetedAtHead(repoRoot, `origin/${base.replace(/^origin\//, '')}`, receipt, receipt.head);
+ if (targetedVerdict.line.includes('(unchecked: ')) process.stderr.write(targetedVerdict.line + '\n');
+ if (!targetedVerdict.ok) {
+ process.stderr.write(`${targetedVerdict.line}\n${resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targetedVerdict.message}; run the targeted reader on HEAD and pass its record (--review-targeted @); nothing written`)}\n`);
+ return EXIT.fail;
+ }
const body = bodyFile;
const missing = body && receipt.issues ? missingIssueLines(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt.issues) : [];
if (missing.length) {
@@ -764,6 +772,8 @@ function cmdCarry(rest: string[], json: boolean): number {
const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch });
const errors = validateReceipt(carried);
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
+ const targetedVerdict = receiptTargetedAtHead(repoRoot, baseRef, carried, head);
+ if (!targetedVerdict.ok) return refused('RECEIPT_CARRY_REFUSED', `${targetedVerdict.line}: ${targetedVerdict.message}, so the queue refuses it too (#3859); run /ship on ${head.slice(0, 12)}`);
const newBody = placeReceiptFirst(body, carried);
const line = receiptSummaryLine(carried);
if (scopedDryRun) return refused('RECEIPT_CARRY_SCOPED_REVIEW', scopedDryRun);
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/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md
index 1e3263109..ad8c801bf 100644
--- a/capy/skill/gstack/SKILL.md
+++ b/capy/skill/gstack/SKILL.md
@@ -52,7 +52,7 @@ Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.
Every Capy thread that uses gstack follows these for new work, under a Captain or on its own. The rule in full is `docs/captain.md`, "Plan before build"; the lane side is section 3 of captain.md in this folder.
-- **Plan first.** Before building new functionality, run `/autoplan` on it as the first act after the install check, sized to the work (scope flags and `--max-rounds`; a one-file fix gets `--no-ui --no-dx --max-rounds 1` with `--estimate-usd` and a small `--spend-cap`, never no run).
+- **Plan first.** Run `/autoplan` on new functionality right after the install check. A bug fix needs none (`gstack-autoplan next --kind fix`), just a repro or failing test in the PR. A feature gets the lean run (the Capy default), one reviewer over plan and draft code; `--full` adds every phase.
- **ELI5, then the word.** Send the plan and gate to the person, or the Captain, in plain language: `gstack-owner-brief render --captain --out --captain-name ""`, its choices as lettered lines in the ask format (`docs/captain.md`) and "do it" taking every recommendation at full scope. Build only after their word is answered into the run and it reports `APPROVED`; then `gstack-approvals import --autoplan --by ""`.
- **/ship before ready.** Run /ship at the final pushed head before a PR is called ready; `SHIP_RECEIPT: pr= head=` must name that head, and a later push needs a new /ship. The review inside /ship, like the autoplan, is sized to the change.
- **Stop a finished /ship subagent.** When a /ship subagent opens its PR, the parent subscribes to that PR with Capy's subscribe tool and keeps that one subscription until the PR merges or closes, re-subscribing if the watch expires, then cancels it. Once the subscription is in place and READY arrives, the parent stops the subagent and owns the PR from then on, slot stamp, re-READY and receipt carry included. A stopped /ship subagent woken by a check, review or merge notice, not by its parent, reports one line naming what woke it, its pin and the PR head (`gh pr view --json headRefOid --jq .headRefOid`) and ends the turn without acting, whether or not the head moved. A message from its parent is an instruction it carries out.
diff --git a/changelog.d/capy-refuse-a-base-that.md b/changelog.d/capy-refuse-a-base-that.md
deleted file mode 100644
index cd9c3222c..000000000
--- a/changelog.d/capy-refuse-a-base-that.md
+++ /dev/null
@@ -1 +0,0 @@
-- 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.
diff --git a/changelog.d/gst-464-receipt-targeted-base.md b/changelog.d/gst-464-receipt-targeted-base.md
deleted file mode 100644
index 145394224..000000000
--- a/changelog.d/gst-464-receipt-targeted-base.md
+++ /dev/null
@@ -1,14 +0,0 @@
-**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.
diff --git a/changelog.d/gst-508-review-exclude-paths.md b/changelog.d/gst-508-review-exclude-paths.md
deleted file mode 100644
index addd6c7ca..000000000
--- a/changelog.d/gst-508-review-exclude-paths.md
+++ /dev/null
@@ -1,15 +0,0 @@
-**/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.
diff --git a/changelog.d/gst-544-consumer-scan-timeout.md b/changelog.d/gst-544-consumer-scan-timeout.md
deleted file mode 100644
index 6a0f9bfeb..000000000
--- a/changelog.d/gst-544-consumer-scan-timeout.md
+++ /dev/null
@@ -1,8 +0,0 @@
-**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.
diff --git a/changelog.d/gsta224-h1-review-pr-type.md b/changelog.d/gsta224-h1-review-pr-type.md
deleted file mode 100644
index dd1ff866f..000000000
--- a/changelog.d/gsta224-h1-review-pr-type.md
+++ /dev/null
@@ -1,10 +0,0 @@
-**/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.
diff --git a/changelog.d/gsta224-review-bots.md b/changelog.d/gsta224-review-bots.md
deleted file mode 100644
index 3c87c65d5..000000000
--- a/changelog.d/gsta224-review-bots.md
+++ /dev/null
@@ -1,13 +0,0 @@
-**/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.
diff --git a/codex/SKILL.md b/codex/SKILL.md
index e6130253f..8a914356d 100644
--- a/codex/SKILL.md
+++ b/codex/SKILL.md
@@ -550,7 +550,7 @@ codex >=0.144), the `-c` form explicitly overrides any top-level
web search regardless of configuration, so on the default Review path the flag is a
harmless no-op — only exec-based modes actually search.
-If the user specifies a model (e.g., `/codex review -m gpt-5.6-sol` or
+If the user specifies a model (e.g., `/codex review -m gpt-6.1-sol` or
`/codex challenge --model gpt-daybreak-blue-latest`), translate it to the same config
form and replace the default model flag with `-c "model=\"\""`. Native review
also requires `-c "review_model=\"\""`; replace both model values together.
diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl
index 9f55d3052..981013dab 100644
--- a/codex/SKILL.md.tmpl
+++ b/codex/SKILL.md.tmpl
@@ -271,7 +271,7 @@ codex >=0.144), the `-c` form explicitly overrides any top-level
web search regardless of configuration, so on the default Review path the flag is a
harmless no-op — only exec-based modes actually search.
-If the user specifies a model (e.g., `/codex review -m gpt-5.6-sol` or
+If the user specifies a model (e.g., `/codex review -m gpt-6.1-sol` or
`/codex challenge --model gpt-daybreak-blue-latest`), translate it to the same config
form and replace the default model flag with `-c "model=\"\""`. Native review
also requires `-c "review_model=\"\""`; replace both model values together.
diff --git a/docs/CHANGELOG_STYLE.md b/docs/CHANGELOG_STYLE.md
index 4a2194b7f..ce6723fe8 100644
--- a/docs/CHANGELOG_STYLE.md
+++ b/docs/CHANGELOG_STYLE.md
@@ -1,7 +1,9 @@
# CHANGELOG + VERSION style
Moved verbatim from CLAUDE.md (token-load reduction). Read this BEFORE
-writing any `## [X.Y.Z]` CHANGELOG entry.
+writing a CHANGELOG entry. In gstack the entry is a fragment,
+`changelog.d/.md`, and the merge step gives it its `## [X.Y.Z.W]`
+heading ([changelog fragments](ship-policy.md#changelog-fragments)).
## Versioning and branch-scoped entries
@@ -9,15 +11,15 @@ Moved verbatim from CLAUDE.md (#2096 size limit). The autonomous version
policy (choose versions without asking; default to PATCH) stays in CLAUDE.md
as the single source.
-**Versioning invariant (workspace-aware ship).** VERSION is a monotonic ordered
-release identifier, not a strict semver commitment. The bump level
-(major/minor/patch/micro) expresses intent at ship time. Queue-advancing past a
-claimed version within the same bump level is explicitly permitted — if branch A
-claims v1.7.0.0 as a MINOR and branch B is also a MINOR, B lands at v1.8.0.0
-(still a MINOR relative to main). Downstream consumers must NOT rely on
-"MINOR = feature-only, PATCH = fix-only" as a strict contract. This is why
-`bin/gstack-next-version` advances within the chosen bump level rather than
-repicking the level when collisions happen.
+**Versioning invariant.** VERSION is a monotonic ordered release identifier,
+not a strict semver commitment. The bump level (major/minor/patch/micro)
+expresses intent. Versions are assigned at merge, in merge order: each stamp
+takes the version before it plus the effective bump, which is the policy's
+PATCH raised by any folded fragment's `bump:` front matter. If PR A and PR B
+both ask for MINOR and land back to back on v1.6.0.0, A lands at v1.7.0.0 and
+B at v1.8.0.0 (still a MINOR relative to the version before it). Downstream
+consumers must NOT rely on "MINOR = feature-only, PATCH = fix-only" as a
+strict contract.
**package.json carries the npm-valid translation, not VERSION verbatim.**
VERSION stays the 4-digit source of truth (e.g. `1.67.0.0`); package.json and
@@ -26,18 +28,25 @@ translation (`1.67.0`), and lockfile `version` fields sync only when the
lockfile already exists. `bin/gstack-version-bump` (via `lib/version-source.ts`)
owns the translation and judges drift on translated forms — do NOT "fix" the
apparent mismatch by hand, and do not write a 4-digit version into
-package.json (npm rejects it). Rationale and translation rules live in the
+package.json (npm rejects it). A branch never writes either one: the merge
+stamp does, through `bin/gstack-version-bump`. Rationale and translation rules live in the
`lib/version-source.ts` header; `test/gstack-version-bump.test.ts` pins the
contract.
-**VERSION and CHANGELOG are branch-scoped.** Every feature branch that ships gets its
-own version bump and CHANGELOG entry. The entry describes what THIS branch adds —
-not what was already on main.
+**The CHANGELOG entry is branch-scoped; the version is not.** Every feature
+branch that ships writes one entry as `changelog.d/.md`: the entry
+text only, with no `## [x]` heading, and `bump: minor` or `bump: major` front
+matter only when the change warrants more than a PATCH. A branch never edits
+VERSION, package.json's version or a released CHANGELOG.md section. At merge,
+`train land` (or `gstack-ship-queue land`) assigns the version, folds the
+fragment into one `## [] - ` section at the top of CHANGELOG.md
+and deletes the file. The entry describes what THIS branch adds — not what was
+already on main.
**The CHANGELOG entry is the diff between main and the shipping branch — what users
get when they upgrade. NOT how the branch got there.** A reader landing on the entry
should learn what they can do now that they couldn't before; they should not learn
-about the branch's internal version bumps, the bugs we caught and fixed mid-branch,
+about versions the branch carried during development, the bugs we caught and fixed mid-branch,
the plan reviews we ran, or the commits we squashed. That is branch development
narrative. It belongs in PR descriptions and commit messages, not CHANGELOG.
@@ -62,29 +71,28 @@ the user gets; the path to that system is invisible to them.
**When to write the CHANGELOG entry:**
- At `/ship` time (Step 13), not during development or mid-branch.
- The entry covers ALL commits on this branch vs the base branch.
-- `/ship` lints the lines the branch added with `bin/gstack-prose-lint CHANGELOG.md --base origin/ --gate`,
- and the Quality gate workflow gates the `## [Unreleased]` block the same way. Rewrite each `PROSE:` line
- it names ([docs/troubleshooting.md](troubleshooting.md#prose-lint-found)).
-- Never fold new work into an existing CHANGELOG entry from a prior version that
- already landed on main. If main has v0.10.0.0 and your branch adds features,
- bump to v0.10.1.0 with a new entry — don't edit the v0.10.0.0 entry.
+- `/ship` lints the fragment with `bin/gstack-prose-lint changelog.d/.md --gate`.
+ Rewrite each `PROSE:` line it names ([docs/troubleshooting.md](troubleshooting.md#prose-lint-found)).
+- Never fold new work into a CHANGELOG.md section that already landed on main.
+ New work goes in this branch's fragment, and the merge stamp gives it its own
+ section at the next version.
**Key questions before writing:**
1. What branch am I on? What did THIS branch change?
-2. Is the base branch version already released? (If yes, bump and create new entry.)
-3. Does an existing entry on this branch already cover earlier work? (If yes, replace
- it with one unified entry for the final version.)
+2. Does this change warrant more than a PATCH? (If yes, open the fragment with
+ `bump: minor` or `bump: major` front matter; otherwise add none.)
+3. Does this branch's fragment already cover earlier work? (If yes, rewrite it as
+ one unified entry.)
-**Merging main does NOT mean adopting main's version.** When you merge origin/main into
-a feature branch, main may bring new CHANGELOG entries and a higher VERSION. Your branch
-still needs its OWN version bump on top. If main is at v0.13.8.0 and your branch adds
-features, bump to v0.13.9.0 with a new entry. Never jam your changes into an entry that
-already landed on main. Your entry goes on top because your branch lands next.
+**Merging main never gives your branch a version.** When you merge origin/main into
+a feature branch, main may bring new CHANGELOG sections and a higher VERSION. Take them
+exactly as main has them. Your branch still carries only its fragment, and never jams its
+changes into a section that already landed on main; the merge stamp puts its section on top.
**After merging main, always check:**
-- Does CHANGELOG have your branch's own entry separate from main's entries?
-- Is VERSION higher than main's VERSION?
-- Is your entry the topmost entry in CHANGELOG (above main's latest)?
+- Is your fragment still in `changelog.d/`, with its own text, separate from main's sections?
+- Are VERSION, package.json's version and every released CHANGELOG.md section exactly
+ main's? (The Version Gate check requires this of a head the merge step did not stamp.)
If any answer is no, fix it before continuing.
**After any CHANGELOG edit that moves, adds, or removes entries,** immediately run
@@ -93,13 +101,9 @@ order. Gaps between version numbers are fine. A branch that ships at v1.6.4.0 wi
a prior v1.5.2.0 or v1.5.3.0 entry on main is correct — those were branch-internal
version numbers that never landed. Do not back-fill gaps with placeholder entries.
-**Never orphan branch-internal versions.** If your branch bumped VERSION several times
-during development (v1.5.1.0 → v1.5.2.0 → v1.6.4.0, say) and those earlier entries were
-never released to main, the final ship consolidates ALL of them into a single entry at
-the final version (v1.6.4.0). Collapse them — delete the old entries and move their
-content into the final entry, re-version table columns accordingly. Readers see one
-release, not a branch diary. Gaps are fine (v1.6.3.0 → v1.6.4.0 with no v1.5.x
-in between on main is correct).
+**Keep one entry per branch.** When the branch's work grows after its fragment was
+written, rewrite that fragment as one unified entry instead of adding a second file
+beside it. Readers see one release, not a branch diary.
CHANGELOG.md is **for users**, not contributors. Write it like product release notes:
@@ -122,9 +126,9 @@ we got here. Keep out of the CHANGELOG, always:
documents what DID ship, not what MIGHT ship.
- Version-bump housekeeping when no user-facing work actually landed.
-If the diff between the base branch version and this version has no user-facing change
-(only merges, only CHANGELOG edits, only placeholder work), the honest entry is one
-sentence: "Version bump for branch-ahead discipline. No user-facing changes yet." Stop
+If the diff between the base branch and this branch has no user-facing change
+(only merges, only CHANGELOG edits, only placeholder work), the honest fragment is one
+sentence: "No user-facing changes yet." Stop
there. Do not pad. Do not explain the plan that will ship eventually. Do not narrate
the branch's history. When real work lands, the entry will replace this at /ship time.
diff --git a/docs/hosts.md b/docs/hosts.md
index 3482674db..ecabfbf76 100644
--- a/docs/hosts.md
+++ b/docs/hosts.md
@@ -188,11 +188,14 @@ digest's first line shows its gstack version; re-copy it after upgrading.
For Codex, setup reads the top-level `model` from
`${CODEX_HOME:-~/.codex}/config.toml` and generates the matching behavioral
profile, falling back to `gpt-6-astra` when no usable model is configured.
-`gpt-5.6-sol` automatically receives bounded-scope instructions that
-finish the requested lake without expanding into adjacent cleanup or speculative
-hardening. The Sol profile is exact-match only: dated snapshots and other 5.6
-variants get the generic GPT profile, and setup warns on near-misses like
-`gpt-5.6-sol-2026-08-01`. Override detection with `./setup --host codex --model ` — the
+The current OpenAI tiers are `gpt-6-astra` (frontier), `gpt-6.1-sol` (smart, the
+default eval tier) and `gpt-6-luna` (lowest cost); `gpt-6.1-sol`
+and `gpt-6-luna` get the generic GPT profile. Only `gpt-5.6-sol` receives the
+bounded-scope instructions that finish the requested lake without expanding into
+adjacent cleanup or speculative hardening, because that patch was measured on 5.6
+Sol; extending it to `gpt-6.1-sol` waits for its own scope eval. The Sol profile is
+exact-match only: dated snapshots and other 5.6 variants get the generic GPT
+profile, and setup warns on near-misses like `gpt-5.6-sol-2026-08-01`. Override detection with `./setup --host codex --model ` — the
override applies to that run only; set `model` in your Codex `config.toml` to
make it stick across upgrades. After changing your Codex model, rerun
`./setup --host codex` to regenerate the skills.
diff --git a/docs/pregate.md b/docs/pregate.md
index 09d1afe7d..4abfcd628 100644
--- a/docs/pregate.md
+++ b/docs/pregate.md
@@ -85,11 +85,11 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) |
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
-| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
+| `stall_timeout_s` | seconds a test command may print nothing while using under a tenth of one CPU before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
-| `remote_gate_runs` | `{ profiles, inventory }`: the run profiles that together clear `pregate.json/remote_gate`, and the command that prints the remote gate's test files, one per line, which their runs must cover (see [run evidence](#run-evidence)) |
+| `remote_gate_runs` | `{ profiles, inventory, skipped? }`: the run profiles that together clear `pregate.json/remote_gate`, the command that prints the remote gate's test files, one per line, which their runs must cover, and an optional command that prints which selected files the gate itself never runs (see [run evidence](#run-evidence)) |
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
Only the `origin/` copy or the user-owned file below is honoured, so a
@@ -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
@@ -742,6 +761,16 @@ Then, for the lane:
while the working tree is pregate.json's tree (checked before and after it
runs); an empty or failing inventory refuses, and it runs under `pipefail`,
so a pipeline whose first command fails refuses too.
+- When `remote_gate_runs` declares `skipped`, that command runs the same way,
+ with `PREGATE_FILES_FILE` naming a file of the obligation's selected test
+ files, one per line, and prints the ones the remote gate itself never runs
+ (gstack's gate leaves paid test files to the paid PR gate). Those files need
+ no run, and the receipt counts them as `skipped=`. A file the inventory
+ prints stays required even when `skipped` names it, and a failing `skipped`
+ command refuses (`skipped_failed`). When `skipped` names every selected file,
+ no selected test ran, so the clear refuses (`paid_only`), just as the gate's
+ own `tests=0` report never clears. Without `skipped`, every selected file
+ must appear in a run's `files`.
- Every run is read once more after the inventory: a run that moved to a new
attempt or stopped being a success meanwhile refuses (`run_changed`).
@@ -753,13 +782,14 @@ verification is named with its own reason (`fork_run`, `event_mismatch`,
`os_mismatch`, `api_error`, …); the clear-level codes are `base_mismatch`,
`no_verified_run`, `tree_mismatch` (runs on different trees),
`tree_not_stamp_only`, `no_tests`, `inventory_failed`, `inventory_empty`,
-`inventory_uncovered` (the remote gate) and `tests_uncovered` (a lane's own
+`skipped_failed`, `paid_only`, `inventory_uncovered` (the remote gate) and `tests_uncovered` (a lane's own
selected tests). A wrong run count, a repeated run or a lane with no
profile is a usage error (exit 2), not a REUSE refusal.
Accepted, `clear` prints `REUSE: accepted `, and the receipt
reads `remote-gate tree= tests= runs=/,… profiles=