diff --git a/CHANGELOG.md b/CHANGELOG.md
index 98816baf1..9db9954ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,298 @@
### Documentation
- **`docs/capy-install.md`**: the workflow-lease paragraph now says the team-mode SessionStart hook (`bin/gstack-session-update`) honors the same leases. It logs `SKIP leased pid=
/m` to `analytics/session-update.log` and tries again at the next session start, without writing its hourly throttle stamp.
+## [1.91.207.0] - 2026-10-25
+
+**Per-repository session counts include sessions from removed Claude Code worktrees (ported from #3334 by @Shweta-Mishra-ai).** Claude Code creates worktrees at `/.claude/worktrees/` and removes them once their branch merges, but their session history stays. `gstack-global-discover` resolved each session's recorded directory with git, so a removed worktree resolved to nothing: its sessions counted in the tool total and in no repository. Reproduced on main: 2 sessions in a repo plus 3 in a removed worktree gave `total_sessions 5` but 2 for the repo.
+
+### Fixed
+- **`bin/gstack-global-discover.ts`**: when a session's directory no longer exists and sits inside `/.claude/worktrees/` (or a subdirectory of it), the session counts with ``. Any other missing directory, and a worktree whose repository is gone too, still count in no repository. A live worktree keeps its own path, as before.
+
+### Tests
+- `test/global-discover.test.ts` gains six end-to-end cases built with real `git worktree add` and `remove` (removed worktree, a subdirectory of one, a repo with no remote, plus the live-worktree, gone-repo and plain-missing-directory controls) and table tests for the path matcher. The three positive cases and the matcher tests fail on main.
+
+## [1.91.206.0] - 2026-10-25
+
+**A repo that keeps `.gstack/` out of git can give pregate a policy, and name the CI lanes a local run satisfies (#3286).** On a shared repo where gstack is one contributor's tool, pregate fell back to defaults, so every platform lane the machine could not run (an `ubuntu-latest` suite on a Mac, say) became a `requires-remote` obligation that never cleared, even when that suite runs the same here. Ported from #3286 by arlophoenix, whose 13 commits carry the feature (merged with their history kept); our commits on top close two findings from a security-focused review of the port.
+
+### Added
+- **`lib/pregate/context.ts`, `lib/pregate/config.ts`** (arlophoenix): when the base branch carries no `.gstack/pregate.json`, `gstack-pregate run` reads `/projects//pregate.json`. Never under `--policy-from head`, never a relative path or one inside the checkout, and never chosen by a checkout's `.env` (the bin ignores it, and a bare `bun gstack-pregate` beside one refuses). An unreadable file, a dangling link on its path, `sealed` or `price_file` refuses with `PREGATE_CONFIG_INVALID`; a base copy git cannot read refuses with `POLICY_SOURCE_UNAVAILABLE` unless `git ls-tree` confirms it is absent.
+- **`local`** (arlophoenix): `/` lanes a local run satisfies run here instead of becoming `requires-remote`. Under 8 cores with a `remote_gate` or remote `test_backend`, the small-machine obligation still stands.
+- **`lib/pregate/index.ts`** (arlophoenix): the record names the user file and its digest, and `verify` goes `PREGATE_STALE` when it is edited, deleted, or appears after a run under the defaults. Fix texts for missing lanes, strays, stalls and `remote_gate` name the active policy file.
+
+### Fixed
+- **`lib/pregate/context.ts`**: a user policy whose path reaches the checkout through any symlink is ignored. Before, only the lexical path and the final target were checked, so a state root linked into the checkout, holding a link back out to another existing policy, let a branch choose a more permissive file without changing its bytes.
+- **`lib/pregate/index.ts`**: `verify` goes `PREGATE_STALE` when a link on the way to the absent user path starts pointing nowhere. A fresh run refuses that state, but verify looked only at the leaf, so an old defaults pass stayed publishable. The loader and verify now share one absence check.
+
+### Tests
+- `test/pregate-user-policy.test.ts` (new, arlophoenix) covers the user file, `local`, the refusals, `.env` isolation and stale receipts. New cases: a link chain through the checkout is refused for both a linked state root and a single in-repo hop, while a direct outside link is still read; and verify goes stale on a dangling parent, with the plain-absent path as the control.
+- `test/pregate-rust.test.ts`: the two cargo-lane cases skipped on Windows carry a `TODO(#3380)` naming the gap.
+
+### Gate integrity
+- No threshold, budget, timeout, trial count, case kind or skip changed. The user file can relax lanes only where the base has no policy at all, and only from outside the repository; the two fixes make that containment stricter.
+- No new dependency, no network call, no workflow change.
+
+## [1.91.205.0] - 2026-10-25
+
+**/ship's Step 9 can re-review just the fixes after a fixing pass, and a narrowed pass can never be the review that ships (#3339).** Before, every fix cycle reran every specialist and the Red Team over the whole branch. On a large branch each full pass tends to find something new in code earlier passes already read, so the loop often ran to the three-cycle stop without converging. Now the pass after a fixing pass can review only what changed since that pass. One full pass still has to come last: a fix-range record is never CURRENT, and Step 11.5 can't bind it. Garry approved this on 2026-10-24 ("only with a mandatory final full pass").
+
+### Added
+- **`bin/gstack-review-log --start review --fix-range`** prints the start token, then `FIX_BASE=`. The base comes from the review log, never from the caller or the working tree, so `gstack-wtree`'s fingerprint is untouched. It is the commit of the latest pass of the same skill, and that commit's tree must be exactly the content the pass started from, so `git diff ` holds every fix, committed or not. The start refuses (exit 1, "run a full pass") in each of these cases: there is no earlier pass, that pass didn't complete (restoring coverage always takes a full pass), it made no fixes, it has no captured start, it ran on another branch, a Step 11 review followed it, it started from uncommitted or untracked content, or its commit no longer resolves or is no longer an ancestor of HEAD (after a rebase). On the start-work-then-ship flow, where Step 9 first reviews uncommitted work, the first repeat therefore stays full.
+- **`lib/review-evidence.ts`**: the start token carries `scope: "fix-range"` and `fix_base`. The logged row takes `scope` and `fix_base` only from the token (a caller's values are dropped either way), and a token that names any fix base stays narrowed even if its scope is rewritten. On unchanged content its `review_binding` is `{state: "fix-range", range_start_wtree, range_end_wtree, …}`, with no `start_wtree`/`end_wtree` and no root `wtree`. `reviewFreshness` returns UNVERIFIED for any fix-range row ("fix-range pass; only a later full pass can clear review"), even one with a verified-looking binding.
+- **`bin/gstack-specialist-stats`** skips fix-range rows, so narrowed dispatches don't push specialists toward `[GATE_CANDIDATE]`.
+
+### Changed
+- **`ship/sections/review-army.md.tmpl`**: the core checklist explains the fix-range start, and that `FIX_BASE` stands in for `origin/` and `` for that pass. Decision 3 allows the repeat to be a fix-range pass. Decision 4 continues to Step 10 only from a full pass. Nearby prose was tightened to pay for the growth, so ship's skeleton plus sections is 313,246 bytes against its 313,281 cap (35 bytes below main).
+
+### Tests
+- `test/review-fix-range.test.ts` (20 cases) follows Step 9.4's real order (fixes applied before the row is logged). It covers the base with committed and uncommitted fixes, chained fix-range passes, the eight refusals, a rewritten start token, a caller claiming `scope: "full"` on a fix-range token (it is still recorded as fix-range) or `"fix-range"` on a full one (it is dropped), Step 11.5's missing binding fields, UNVERIFIED freshness followed by CURRENT after a full pass, shared-libs reuse refusing a fix-range row, the stats exclusion, and the rendered Step 9 rules.
+
+### Gate integrity
+- One gate change to review: Step 9 may narrow a repeat pass. Verdict: not a weakening of what ships, because the narrowed pass can't be the final review. Three things enforce that: its row is never CURRENT, Step 11.5 has no `start_wtree`/`end_wtree` to bind, and decision 4 requires a full pass before Step 10. Mutation checks, each caught by the suite: dropping the freshness check, giving a narrowed row the normal binding, allowing a fix-range start after a clean, an incomplete or a Step 11-followed pass, accepting a non-ancestor base, accepting a start from uncommitted content, trusting the token's scope alone, and counting fix-range rows in stats. No threshold, budget, timeout, trial count or case kind changed.
+
+## [1.91.204.0] - 2026-10-25
+
+**An unattended run is told to end with `GSTACK_RESULT` only when its skill can write that line.** The unattended block `gstack-skill-start` prints told every unattended skill to log choices in `decisions.jsonl`, write a gate list and end with `GSTACK_RESULT: skill= status=gate_pending run=`. Only `/autoplan` has helpers that write a run directory, so `/ship`, `/review`, `/qa` and the rest were asked to produce files and a result line nothing writes, and a parent that trusted the block would wait for them. `docs/unattended.md` already names which skills write the line and what a parent reads for the others (#3281); the runtime text now says the same thing.
+
+### Fixed
+- **`bin/gstack-skill-start`**: the decision rules in the unattended block are the same for every skill: take the recommended option, leave unrecommended questions, consents, destructive options and the final gate pending, and never approve, publish or push. Only the sentence about the run's record changes by skill. `/autoplan` keeps its run directory, its `decisions.jsonl` rows, the gate list and `GSTACK_RESULT: skill=autoplan status=gate_pending run=`. `/eval-plan` ends at its spend approval with `GSTACK_RESULT: skill=eval-plan status=gate_pending run=`, and the block says no helper writes a `gate.json` for it yet. Every other skill is told it writes no run directory and prints no result line, to stop at its first pending item and list it in its final report, and which record the parent reads: the ship receipt (`gstack-ship-receipt read --pr --json`) for `/ship`, the review-log rows (`gstack-review-read`) for `/review`, the `qa-report-*.md` file for `/qa`, and the skill's own record in `docs/unattended.md` otherwise.
+
+### Tests
+- `test/gstack-skill-start.test.ts` renders the unattended block for `autoplan`, `eval-plan`, `ship`, `review`, `qa` and `investigate`. It checks that each keeps the shared decision rules, that only `autoplan` and `eval-plan` get a result line (with their own skill name, never ``), and that the other four get no result line, gate list, `decisions.jsonl` or `run.json` and name their record. Against the old block it fails, and so does the existing unattended test, which now expects `skill=autoplan`.
+
+### Gate integrity
+- One test expectation changed: the existing unattended test expects `GSTACK_RESULT: skill=autoplan …` instead of the `skill=` placeholder, which is stricter. No threshold, budget, timeout, trial count or case kind changed.
+
+## [1.91.203.0] - 2026-10-25
+
+**Asking gstack to fix a failure no longer always opens /investigate (#3157, #3158).** The router sent every bug report straight to the full /investigate workflow, even when the user had named the cause or one look at the failing code showed it. Now the router checks first. When the user names a cause and a narrow fix, it makes one read-only check of the evidence. When that confirms the cause, it makes the fix and runs the focused test. When the cause is unknown, it makes one bounded, read-only triage pass over the supplied evidence and the directly implicated test and code, with no reproduction, hypothesis testing or wider search, and fixes the failure directly only when that pass establishes the root cause. Anything still unclear, including a cause the check contradicts, goes to /investigate before any edit, so the root-cause bar holds. A failing test or stack trace no longer counts as proof of its cause.
+
+### Changed
+- **`SKILL.md.tmpl`** (root router): the bug-report rule points to a new **Failures** block that carries the two paths. Its text comes from `generateFailureRouting()` in `scripts/resolvers/preamble/generate-test-failure-triage.ts`.
+- **`bin/gstack-skill-start`** and `CLAUDE.md`: the routing section gstack offers for a project's CLAUDE.md now reads "Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test)", so it agrees with the router.
+- **`/ship` Test Failure Ownership Triage**, "Investigate and fix now": "Switch to /investigate mindset" becomes "One read-only root-cause pass, then `/investigate` if unclear."
+
+### Tests
+- New paid eval `test/skill-e2e-failure-routing.test.ts` (gate, `rule`): four fixture repos, two per issue. `router-failure-verified-cause` and `router-failure-triage-resolves` must fix the bug without /investigate and leave the suite green. `router-failure-contradicted-cause` (a user-supplied cause the code contradicts) and `router-failure-triage-unclear` (an intermittent CI-only failure) must invoke /investigate, and an edit before it is a contract violation. Every case needs one to four read-only calls before it routes. `GSTACK_ROUTER_ARM=before` runs the same cases against the base branch's router.
+- `test/skill-validation.test.ts` checks that the rendered router carries the rule and no longer sends bug reports straight to /investigate, that both paths keep the /investigate fallback, that the shipped CLAUDE.md routing line matches this repo's own, and that /ship's fix-now step uses the bounded pass.
+
+### Gate integrity
+- No threshold, budget, timeout, trial count or existing case kind changed. The ship skill's parity size stays under its cap (313,260 of 313,281 bytes; 313,276 with #3372 merged). The new eval's negative controls are the two /investigate cases, and on the base router the triage cases go red.
+- Registries and pins the new gate-tier file needs, each with a stated reason: `test/eng-finding-retry-budget.test.ts` counts 110 paid test files (was 109). `test/helpers/e2e-hosts.ts` lists the four cases as `skill-tool` Claude-only, because they assert on the Claude Skill tool call. `test/codex-host.test.ts` counts 33 Claude-specific gate cases (was 29).
+- Each train gate now runs the four new cases, one trial each, for about $0.37 (measured at this head: $0.06, $0.06, $0.11 and $0.14; about 67 s wall when they run in parallel).
+
+## [1.91.202.0] - 2026-10-25
+
+**Team-mode session auto-update waits for live gstack sessions instead of moving the checkout under them (#3363).** `bin/gstack-upgrade` already refuses with `UPGRADE_BUSY` while another live session holds a workflow lease on the installation (`bin/gstack-lease`), but the SessionStart hook `bin/gstack-session-update` never read the leases. A shared `./setup --team` install could fast-forward and re-run `./setup` while another session was mid-workflow. Reproduced on main: with a live lease held, the hook logged `UPDATING` and moved the install's HEAD.
+
+### Fixed
+- **`bin/gstack-session-update`**: after taking its setup lock and before fetching, the background updater asks `gstack-lease live --root --exclude-ancestry-of `. Any live lease from another session logs `SKIP leased pid=/` and exits without fetching, fast-forwarding, retrying a pending setup stage or writing the hourly throttle stamp, so the next session start tries again. `GSTACK_UPGRADE_FORCE=1` overrides, as it does for `gstack-upgrade`. A dead or stale lease never blocks.
+
+### Tests
+- `test/session-update-stages.test.ts` holds a lease from a live process: the install's HEAD, setup runs and the throttle stamp are unchanged, and the first session start after the holder exits updates. A second case shows a lease also holds a pending setup retry. Both fail on main. Controls: `GSTACK_UPGRADE_FORCE=1` updates under a live lease, and a lease whose process is dead does not block.
+
+## [1.91.201.0] - 2026-10-25
+
+**/ship's review army and /plan-eng-review now ask for one severity scale, CRITICAL or INFORMATIONAL, the same scale /review, the specialist JSON and the review checklist already use (#3349).** The shared confidence rubric those two skills render told the model to label findings `[P1]` and `[P2]` and to report a 1-2 confidence finding "only if severity would be P0", while the rest of /ship's Step 9 (Pass 1 CRITICAL, Pass 2 INFORMATIONAL, `"severity":"CRITICAL|INFORMATIONAL"`) used the other scale. A finding from one skill couldn't be compared, deduplicated or gated against a finding from another. Garry picked CRITICAL/INFORMATIONAL for plan-eng-review on 2026-10-24.
+
+### Fixed
+- **`scripts/resolvers/confidence.ts`** (default branch, rendered into `ship/sections/review-army.md` and `plan-eng-review/sections/review-sections.md`): every finding carries a severity, CRITICAL (blocks shipping) or INFORMATIONAL (lower severity, still actioned). The format is `[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description` with CRITICAL and INFORMATIONAL examples. A 1-2 score is reported only for a suspected release-blocking catastrophe, at CRITICAL severity and marked explicitly speculative, the rule /review already uses. Repeated sentences in the verification gate and the calibration-learning note were shortened, so ship's skeleton-plus-sections size falls by 77 bytes (313,204 against its 313,281 cap).
+
+### Tests
+- `test/eng-first-review.test.ts`: the two captured plan-eng-review first-review questions (`eng-annotated-cache-au`, `eng-declarative-as`) still open review when their `[P1]` label becomes `[CRITICAL]` or `[INFORMATIONAL]`. They stay closed for `[HIGH]`, `[critical]`, `[Critical]`, `[P4]`, `[CRITICAL|INFORMATIONAL]` and no label.
+
+### Gate integrity
+- One accepted-input change to review: the paid-test detectors `test/helpers/eng-cache-writer-decision.ts` and `test/helpers/pty/boundaries.ts` accept `[CRITICAL]` and `[INFORMATIONAL]` alongside `[P0]`-`[P3]` in a plan finding's annotation, because the skill now asks for those labels. Without it, both captures relabelled `[CRITICAL]` are rejected. Verdict: not a weakening. The label is metadata, and every substantive check (decision, assessment, actors, options) is unchanged. Mutation checks: restoring `P[0-3]` alone fails the positive cases; `[^\]]+`, `P[0-9]` or a case-insensitive CRITICAL each fail the negative controls. `scripts/harness-version.json` records `test/helpers/pty/boundaries.ts` as non-behavioral at HARNESS_VERSION 6: only the changed plan-eng-review prompt asks for the new labels, and the cases that run it already start their own series. The 11 plan-eng-review size pins in `test/fixtures/skill-md-size-allowlist.json` come down by 75 characters. No threshold, budget, timeout, trial count or case kind changed.
+
+## [1.91.200.0] - 2026-10-25
+
+**/cso doctor no longer calls a Go repository "stack: python".** A repository with a root `go.mod` and no Node, Bun, Rails or JVM build matched no supported stack, so preparation fell back to Python and doctor reported `stack: python` with `MISSING_METADATA requirements.txt`, which sent Go users looking for a Python file they never needed (#3263). Go runtime support itself is not planned for now (Garry, 2026-10-24), so /cso keeps running static assessment on Go repositories; the runtime catalog and the /cso release contract are unchanged.
+
+### Fixed
+- **`lib/cso/preparation.ts`**: a root `go.mod` with no other supported stack is reported as `stack: go` with `UNSUPPORTED_STACK` ("Go (go.mod) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails."), the way a Maven or Gradle build is already reported as Java. Python helper metadata beside `go.mod` (a docs `requirements.txt`, say) no longer turns the repository into Python. A `go.mod` beside a Node, Bun or Rails lockfile leaves that stack's detection as it was.
+
+### Tests
+- `test/cso-preparation.test.ts` checks that a Go module is `go` with exactly that prerequisite and never mentions `requirements.txt`, and that a hashed `requirements.txt` beside `go.mod` stays Go. Both fail on main. Controls: `go.mod` beside a Node lockfile stays a ready Node plan, a Python project without `go.mod` stays Python, and a repository with no stack metadata keeps the Python fallback.
+- `test/helpers/cso-runtime-catalog.ts` leaves `go` out of its qualified-runtime fixture list, as it already did `java`.
+
+### Gate integrity
+- No gate edit. No threshold, budget, timeout, trial count or case kind changed.
+
+## [1.91.199.0] - 2026-10-25
+
+**When /sync-gbrain refuses because a gbrain autopilot is running, it now says which process it found and what to do.** The guard against syncing while `gbrain autopilot` runs is machine-wide on purpose: two syncs sharing one brain or working tree can race and delete files (#1734). But the refusal only said `(process:gbrain autopilot)`, so someone whose autopilot served a different brain could not tell what was blocking them (#3360, part 1). Garry decided on 2026-10-24 to keep the check machine-wide and improve the message.
+
+### Changed
+- **`lib/gbrain-guards.ts`**: the live-process probe still runs `pgrep -f "gbrain autopilot"` across the whole machine, then reads each match's command line with `ps`. The refusal names every match as `pid \`\``, says the check counts any process on the machine whose command line contains "gbrain autopilot" (an autopilot serving another brain or repo included), and says to stop that autopilot or its `gbrain autopilot --install` service, or end a process that only mentions it. Commands are cut at 200 characters and the user and password part of a URL is masked. A pid that `ps` cannot describe still refuses, as `command unavailable`. The header comment now gives the machine-wide reason in place of the stale gbrain #1226 note.
+
+### Tests
+- `test/gbrain-guards.test.ts` pins the full refusal for one matched process, lists two matches, keeps an empty match inactive, and runs the real probe against a live process whose command line carries "gbrain autopilot" and a database URL with a password: the refusal names its pid and masks the password. All four fail on main. The existing probe, lock and stale-lock tests are unchanged.
+
+### Gate integrity
+- No gate edit: the guard refuses in exactly the cases it did before, and only its message changed. No threshold, budget, timeout, trial count or case kind changed.
+
+## [1.91.198.0] - 2026-10-25
+
+**A second-opinion reviewer that says "not ready" is no longer reported as clean.** When the outside reviewer (Codex or Claude Code) ended its reply with `Verdict: not ready` but listed only advisory P2 or P3 findings, `lib/outside-review-result.ts` printed `VERDICT: clean` and exited 0, so /ship and /codex carried on as if the reviewer had approved. The not-ready check only ran when no severity parsed at all, so a parsed P2 or P3 switched it off (#3329). Ported from #3364 by @mvanhorn.
+
+### Fixed
+- **`lib/outside-review-result.ts`**: when the reply's last recognized `Verdict:` line says `not ready`, the result is `findings` (exit 3) whatever severities parsed, and `FINDINGS:` still names the highest one. Advisory P2/P3 findings with a ready verdict, or with no verdict, stay `clean`. A `Verdict:` line quoted in a code fence or inline code still does not count, and the proposal and spec gates ignore it as before.
+- **`docs/troubleshooting.md`**: the "Every review gate" rule under *How the verdict is read from a review* now says a not-ready reply blocks even when P2 or P3 parsed.
+
+### Tests
+- `test/outside-review-unparsed-severity.test.ts` covers the issue's reply (numbered bold P2 plus P3, then `Verdict: not ready`) on the execution, structured and review gates, P3-only and unlabeled not-ready replies, the last verdict winning in both directions, bold, bulleted and heading verdict lines, and an unparsed label beside a parsed P2. Five of its tests fail on main's classifier and pass with the fix. Controls that stay `clean`: P2/P3 with `Verdict: ready` or no verdict, a not-ready verdict inside a code fence or inline code, and `ready-one-p3.txt`. Missing review markers, a failed execution, and the proposal and spec gates keep their old results.
+
+### Gate integrity
+- One gate edit, and it strengthens the gate: the pinned `bare-dash-p2.txt` fixture (two P2 lines, then `Verdict: not ready (2 P2)`) moves from `VERDICT: clean`, exit 0 to `VERDICT: findings`, exit 3. Garry approved this reversal on 2026-10-24. No threshold, budget, timeout, trial count or case kind changed.
+
+## [1.91.197.0] - 2026-10-25
+
+**Pregate gives touched Rust files a test lane, and runs a nested pnpm or vitest package with its own runner (#3344).** In a mixed Cargo and JavaScript repo, every touched `.rs` file failed `lanes` with "has no test lane", so /ship could not get a receipt, and a nested package's vitest tests ran under `bun test` from the repo root (about 98 false failures in the reporter's repo). Ported from #3346 by vilano93, whose two commits carry the feature; our commits on top close four findings from a security-focused review of the port and make the new fixtures hermetic.
+
+### Fixed
+- **`lib/pregate/rust.ts`** (new, vilano93): a crate is the nearest Cargo.toml with `[package]`. Its `.rs` files under `tests/` or holding a `#[cfg(test)]` are tests, and every `.rs` file of a crate and its Cargo.toml select that crate's tests and those of every crate depending on it. Manifests are parsed as TOML, so renamed, inherited, dotted, table-form and target-specific dependencies all count, and a crate runs from the workspace it is a member of under Cargo's rules (members, exclude, `package.workspace`). Nothing runs cargo to build the graph.
+- **`lib/pregate/checks/lanes.ts`** (vilano93): selected Rust tests run per crate as `cargo test -p ` from the workspace root and are judged on the summed `test result:` lines; zero tests is ZERO-RUN and a missing cargo is incomplete. A JS/TS test owned by a nested package.json that declares vitest or jest runs from that package through the package manager its `packageManager` field or nearest lockfile names (`pnpm exec`, `yarn`, `bun x`, else `npx`).
+- **`lib/pregate/checks/lanes.ts`**: the crate name comes from the repo's Cargo.toml and reaches the lane's shell. Before, a crate named `evil;touch pwned` ran `touch pwned` in the workspace, and a name containing `{files}` spliced the file list into the command. A name outside Cargo's grammar (letters, digits, `_`, `-`) now gets no runner, so its files are unclaimed and the lane is incomplete, as Cargo would refuse the manifest anyway; a valid name is quoted like every file and cwd. The lane only runs with `--allow-repo-commands`, under which the repo's tests run anyway, but no other repo input reached the shell unquoted.
+- **`lib/pregate/rust.ts`**: Cargo `members` globs are matched per path component and by Unicode code point, in one pass. The first cut used the policy glob matcher, which matched `[ab]` literally (a member listed that way lost its inherited workspace dependencies, so its tests were never selected) and backtracked for seconds on a hostile glob. Past 4096 characters of `members` globs, membership is undecided: every crate under the workspace counts as a member for dependency edges (more tests, never fewer), but its own tests are unclaimed and the lane is incomplete, because running `cargo test -p ` from the wrong root can test a same-named member instead.
+- **`lib/pregate/rust.ts`**: a plain `#[test]` or `#[tokio::test]` function in `src/` makes the file a test, so a dependent crate tested that way runs when its dependency changes. The `#[cfg(...)]` scan reads each attribute once, up to its closing `]`, so a file of unclosed attributes no longer takes seconds, and an attribute it cannot close counts as a test (more tests selected, never fewer).
+- **`lib/pregate/init-draft.ts`** (vilano93): `init` notes that Rust tests run per crate.
+
+### Tests
+- `test/pregate-rust.test.ts` (new, vilano93) covers the crate model, the dependency forms, workspace membership, the cargo summary parser and three end-to-end lane runs. New cases: crates named `evil;touch pwned-by-crate-name` and `evil{files}` never reach the shell (nothing is created, both are unclaimed) while `naïve_crate-2` still runs quoted; `crates/[ab]`, `nested/**/leaf` and `unicode/?` (a 📦 directory) members inherit workspace dependencies; a plain `#[test]` dependent and a `cfg(any(test, feature = "<300 chars>"))` are tests; and a same-named non-member past the size cap is unclaimed rather than run as the member; a hostile members glob, a million unmatched `[` and 100,000 unclosed `#[cfg(` run in a supervised Bun that kills a super-linear version at 30 s. Each failed before its fix.
+- The cargo stand-in is hermetic on POSIX: where Bun and cargo share a directory, the lane puts Bun's directory first and the real cargo ran against the fixture, so the shim PATH now uses the platform delimiter and the lane's bash puts the shim first again through `BASH_ENV`. On windows-free-shard (5) the two cases that run the stand-in failed deterministically (`local lane exited 1` with an empty log) and still did after the delimiter and a `Path`-key repair, so they are skipped on Windows (`test.skipIf(process.platform === 'win32')`, as other POSIX-shim pregate fixtures are); the selection, planning and hostile-name cases still run there.
+- `test/pregate-selection.test.ts` (vilano93) covers nested pnpm/yarn/vitest/jest packages. Its `pnpm` stand-in gets the same `BASH_ENV` fix: on a host with a real pnpm beside Bun, the end-to-end case installed vitest from the network and came back incomplete.
+
+### Gate integrity
+- One coverage change to review: the two new end-to-end cargo-lane cases in `test/pregate-rust.test.ts` are skipped on Windows (they never passed there); Linux and macOS run them, and the rest of the file runs on Windows.
+- No threshold, budget, timeout, trial count or case kind changed. Pregate's lanes now map and run tests for `.rs` files that previously failed closed with "no test lane", and nested vitest/jest packages run under their own runner instead of `bun test`. A `.rs` file outside any crate, a crate with no tests, and a missing cargo or package manager still fail closed or stay incomplete; the tests above cover each.
+- No new dependency (Cargo.toml is parsed with Bun's built-in TOML parser), no network call in pregate itself, no workflow change. As with the root runner detection on main (`npx vitest run`), a nested package's detected runner (`npx`, `bun x`) may fetch a missing tool when it runs under `--allow-repo-commands`.
+
+## [1.91.196.0] - 2026-10-25
+
+**`/land-and-deploy` no longer stops parallel PRs with a false "VERSION drift detected".** Step 3.4 compared the branch VERSION with the next free patch slot (`gstack-next-version --bump patch --exclude-pr`) under every ship policy. So it stopped a stamp-at-merge PR whenever a sibling shared its tentative stamp, a claim-at-open PR whenever a sibling had claimed a higher slot, and every MICRO bump even with no other PR open. Reported with a full reproduction by Evgenii Lopatin (@exGeni) in #3318.
+
+### Fixed
+- **`land-and-deploy/SKILL.md.tmpl` Step 3.4**: reads `gstack-ship-policy show-effective` and prints one `VERSION_CHECK:` verdict. Under `stamp-at-merge` it ignores claims and checks the exact head stamp: VERSION is the base's version plus one bump, and the changelog's top two headings are that version and then the base's top entry. Otherwise it stops with `STOP stamp stale` and names the `gstack-restamp --next` command. Under `claim-at-open`, and with no policy file, it stops only on a real collision: another open PR holds the same VERSION, or the base is already at or above it. MICRO bumps and versions below a sibling's claim pass.
+
+### Documentation
+- `docs/ship-policy.md` gains "What `/land-and-deploy` reads from it", which gives Step 3.4's stop and pass conditions for each `queue_mode`.
+
+### Tests
+- `test/land-and-deploy-version-check.test.ts` runs the template's own Step 3.4 block against real git fixtures with the real `gstack-ship-policy` and a stubbed claim list, under bash and, when installed, zsh. It covers the three false stops from #3318 (all pass now) and the real stops: a same-VERSION claim, a base at or above the branch, a stale stamp after a sibling landed, a base that moved by MICRO under a stamp, an `[Unreleased]` entry, and a two-bump stamp. A missing VERSION, an invalid policy and unread claims are `unavailable`.
+
+### Gate integrity
+- Step 3.4 now stops on fewer inputs: a unique VERSION below a sibling's claim, a MICRO bump, and a stamp-at-merge stamp that siblings share no longer stop. Every real collision still stops, and six mutation controls of the block (drop the collision check, the base check, the changelog binding, the one-bump rule, the policy read, or the unread-claims rule) each turn the new tests red. One new `unavailable` path: an invalid ship policy. It mirrors the existing offline path, and a base at or above the branch still stops there.
+- Owner-approved budget raise (Garry, 2026-10-24: "do it"): land-and-deploy's exact size pins in `test/fixtures/skill-md-size-allowlist.json` move up by 1,084 to 1,111 characters on 11 hosts, and its parity `maxSizeRatio` in `test/helpers/carve-guards.ts` goes from 1.112 to 1.125 (measured 1.1201). No threshold, timeout, trial count or case kind changed.
+
+## [1.91.195.0] - 2026-10-25
+
+**Four skill prompts stop contradicting themselves or their code.** dgrant's issues #3347, #3350, #3351 and #3352 each found a skill telling the model something wrong or ambiguous: /office-hours read a master-based repo as "no changes", /plan-design-review named a vision model the design binary no longer uses, /health gave two different GBrain scores for the same readings, and /ios-qa's demo mode claimed to override every other rule in the skill.
+
+### Fixed
+- **`office-hours/SKILL.md.tmpl`** (#3347): Phase 1 runs `git diff origin/HEAD --stat`, the remote's default branch, instead of `git diff origin/main --stat 2>/dev/null`. On a clone whose default branch is `master`, the old command exited 128 with its error hidden, so the model read the empty output as "no changes"; the new one shows the diff, and a repo without `origin/HEAD` now shows its error instead of passing for "no diff". The line is the same length, so office-hours stays at its pinned size on every host (the shared `{{BASE_BRANCH_DETECT}}` block would have added 1,727 chars, past the size-guard pins and the parity skeleton cap).
+- **`plan-design-review/SKILL.md.tmpl`** (#3350): the design binary's `check` command is described as "OpenAI vision" instead of "GPT-4o vision", so the prose no longer pins a model. The binary defaults to gpt-5.5 (`design/src/models.ts`), `GSTACK_DESIGN_MODEL` overrides it, and gpt-4o cannot drive gpt-image-2 at all. The swap is the same length, so the pinned host sizes don't move.
+- **`health/SKILL.md.tmpl`** (#3351): the GBrain score is computed by a `bun -e` snippet from the doctor component (10 for `ok`, 7 for `warnings`, else 0), the queue length and the hours since the last push, like the composite already was. The snippet takes only numeric `` inputs, the placeholder form the generated-bash lint already allows. The rubric row's 10/7/4 bands, which disagreed with the weighted formula (doctor ok, queue 50, pushed 2h: 7 by the rubric, 9.1 by the formula) and overlapped (a queue of 3 matched both 10 and 7), now point at that computation. The bands are half-open (<10, <100 lines; <24, <72 hours), and with sync off the doctor component carries the whole score. History records `gbrain` with one decimal.
+- **`ios-qa/SKILL.md.tmpl`** (#3352): "DEMO MODE OVERRIDES ALL OTHER RULES" becomes the narrow rule it meant: in demo mode drive every action through visible UI rather than `POST /state/*` writes, and every other rule in the skill (the device lock, no replay of a lost tap, never hand-editing DebugBridge) still applies.
+
+### Tests
+- `test/qa-health-rubric.test.ts` checks that the GBrain rubric row defers to the computation, then runs the template's own snippet: the issue's two inputs score 9.1 and 8.5, the band edges (9/10/99/100 lines, 23.9/24/71.9/72 hours) land on the right side, a missing doctor reading counts as 0, and sync off gives 10, 7 and 0. All four new tests fail on the old template.
+
+- `test/helpers/safety-rules.ts`: the `ios-qa-demo-override` row's anchor moves from `/demo mode overrides/i` to `/drive every action through visible UI/i`, and its hash to the reworded paragraph, in the #3352 commit as the registry requires. `test/safety-rule-registry.test.ts` passes, including its unique-anchor and rule-removed-arm checks.
+
+### Gate integrity
+- One gate edit to review: the safety-rule registry row for `safety-ios-demo-ui-only` now anchors on the reworded paragraph. Verdict: not weaker. The paragraph still requires visible UI (`/tap`, `/swipe`, `/type`) over `POST /state/*` writes, and the only text removed is the "overrides all other rules" claim. The case's rule-removed arm deletes the same paragraph as before. No threshold, budget, timeout, trial count or case kind changed.
+
+## [1.91.194.0] - 2026-10-25
+
+**A read-only repo can now opt in to Memorable recall, one remote at a time, and a corrupt policy store no longer turns the recall veto off.** #3359 (exGeni) asked for a way to keep a repo out of the brain while still letting its prompts reach Memorable: today the recall hook skips every `deny` and `read-only` repo, and `read-only` is also the tier people pick just to stop code ingest. Garry approved a per-remote flag on 2026-10-24: off by default, and a `deny` always wins. While building the negative controls we found that the hook's policy lookup went through `get`, which quarantines a corrupt store and answers "unset", so a truncated `gbrain-repo-policy.json` let recall run in every repo, read-only and deny included, and reset the store on the way.
+
+### Added
+- **`bin/gstack-gbrain-repo-policy memorable set `** stores the opt-in under a reserved `_memorable_recall` map in the policy store. It is refused unless the remote is read-only (deny vetoes recall; read-write never needed it). `memorable get []` prints `\t`, and `list` adds a `memorable=allow` column. Changing a remote's tier clears its opt-in, and the two metadata keys (`_schema_version`, `_memorable_recall`) are refused as remotes.
+- **`lib/gbrain-repo-policy-client.ts`**: `memorableRecallPolicy(url)` returns the tier and the opt-in in one spawn. Anything but exactly `\t` reads as `unreadable`.
+
+### Changed
+- **`hosts/claude/hooks/memorable-user-prompt-hook.ts`**: a `read-only` remote is recalled only when it is opted in; `deny` is always skipped. The skip line in `hook-errors.log` names the opt-in command.
+
+### Fixed
+- The recall hook's policy lookup is now a pure read that fails closed in either state root: a corrupt, empty or non-object store, a present key with an unknown tier, a path that is not a readable file (a dangling symlink included) and a path whose existence an unsearchable ancestor directory hides are all errors, never "no policy", and nothing is migrated, quarantined or rewritten. The opt-in counts only when it is exactly `"allow"` and every store that makes the remote read-only (including `~/.gstack` under a custom state root) opts it in.
+- The hook looks policy up by every configured `remote.origin.url`, as `get` and `/sync-gbrain` read it, instead of the first URL after `url.*.insteadOf` rewriting. A rewrite to another host, or a second origin URL, no longer dodges a deny: any denied or not-opted-in read-only URL skips recall. `hasRepoPolicyStore()` treats only confirmed absence as "no store", so a dangling symlink at or above the store, a file in its path, or an unreadable directory reaches the script's fail-closed checks instead of the no-policy fast path. Origin URLs are read NUL-delimited and used verbatim, so a valid local path containing a newline or edge whitespace is looked up whole.
+
+### Docs
+- `docs/memorable-workflow-memory.md` has a short "Recall in a read-only repo" section and updated refusal and troubleshooting lines.
+
+### Tests
+- `test/memorable-user-prompt-hook.test.ts` drives the real hook: read-only stays blocked by default, the opt-in recalls on that remote and not on a second read-only remote, `deny` with a hand-written opt-in is skipped, and a corrupt store skips recall and is left in place.
+- `test/gbrain-repo-policy.test.ts` covers the CLI: per-remote keying (a different remote, a prefix-sharing remote and an unset remote stay off; the SSH spelling of the same remote matches), refusal on deny, read-write and unset, clearing on a tier change, malformed opt-ins reading off, corrupt and non-file stores in both roots failing with exit 2, and the second-store rule.
+- `test/gbrain-repo-policy-client.test.ts` covers `memorableRecallPolicy`: fast path, per-remote opt-in, deny, corrupt store, an unknown tier, a dangling-symlink store and a timeout.
+- Review hardening, each test red at the pre-review head: empty, whitespace, `null`, array, number and two-document stores and `false`/`null`/unknown tier values fail with exit 2 in either root; dangling symlinks and an unsearchable directory or ancestor fail closed; the lookup leaves a schema-less store byte-for-byte unchanged; and the hook refuses a deny remote whose origin is rewritten to another host or carries a second, unlisted URL in either order, or whose local-path URL contains a newline or edge whitespace; and a dangling symlink above the store or a file in its path skips the no-store fast path.
+
+### Gate integrity
+- One deliberate widening of a privacy veto, approved by Garry: a read-only remote with an explicit opt-in is recalled. Nothing else is loosened, and the corrupt-store path is tightened. Seventeen mutations of the new gate were run against the focused tests. Sixteen turn at least one test red: the hook ignoring the opt-in, the hook dropping deny, the CLI reporting the opt-in on any tier, an unkeyed opt-in, a corrupt store read as no policy, the second store ignored, an opt-in accepted on deny, the hook using the rewritten URL, an existsSync fast path, any JSON document accepted, a non-file path read as absent, the hook checking only the last origin URL, a missing path trusted without checking its ancestors, origin URLs split on newlines or trimmed, and the client fast path ignoring a dangling ancestor symlink. The seventeenth, the client reading unknown script output as no policy, stays green because the script can no longer produce such output. That check is a second layer and is left in place. No threshold, budget, timeout, trial count or case kind changed.
+- Known limit, unchanged by this PR: remotes that the store already treats as one key (`host:8443/x` and `host/8443/x`, or paths that differ only in case) share the opt-in exactly as they already share a tier.
+
+## [1.91.193.0] - 2026-10-25
+
+**Windows: the browse terminal agent stops respawning every 60 seconds and stops flashing a console when Bun came from npm (#2295; ported from #3357 by @adammartin0087).** The agent was spawned as a bare `bun`. With Bun installed through `npm install -g bun`, that name resolves to npm's `bun.cmd` shim and runs as `cmd.exe /c bun …`, so `windowsHide` hid only `cmd.exe` while `bun.exe` opened its own visible console, and the recorded pid was `cmd.exe`'s. The agent's startup check never matched its record, it exited after 2 seconds, and the watchdog respawned it.
+
+### Fixed
+- **`browse/src/terminal-agent-control.ts`**: `spawnTerminalAgent` spawns `resolveBunExecutable()`, which on Windows is the real `bun.exe`: the running `bun.exe` itself, then `BUN_INSTALL\bin`, each absolute `PATH` entry (and, next to an npm `bun.cmd` shim, the `node_modules\bun\bin\bun.exe` it forwards to), then `USERPROFILE\.bun\bin`. With none found it falls back to `bun`, as before. macOS and Linux are unchanged.
+- **`browse/src/bun-child-config.ts`**, **`browse/src/cookie-import-native.ts`**: that lookup is `findWindowsBunExe()`, and the Windows native cookie-import worker now uses it instead of its own copy, so it also finds an npm-global Bun. It lives in `bun-child-config.ts` because that file is already in the native import's source-bound qualification set (`NATIVE_CODE_INPUTS`). The edit changes the hash of `cookie-import-native.ts` and `bun-child-config.ts`; the qualification list is empty today, so no receipt is invalidated.
+
+### Tests
+- `browse/test/terminal-agent-bun-exe.test.ts` (from the PR) covers POSIX, the running `bun.exe`, a compiled `browse.exe`, the npm shim, lookup order and the fallback, plus a source check that the spawn site never passes a bare `'bun'`. New: the shared lookup returns null when nothing is installed, finds the npm-global `bun.exe` for the cookie worker, and `cookie-import-native.ts` has no candidate list of its own.
+- Not verified here: real Windows behaviour. The PR author tested on Windows 11; the `windows-free-tests` lane runs the new unit tests.
+
+## [1.91.192.0] - 2026-10-25
+
+**Merge trains stop refusing correct heads and stop passing damaged CHANGELOGs.** This week's trains hit five defects in the ship-queue tooling. #3331's READY was invisible to the coordinator because its lane's origin was `https://git.capy.ai/...`. #3345's pin replaced the released `## [1.91.179.0]` heading, and three things then failed. The Version Gate passed the pin. `mergeChangelogs` copied 179's body into `[Unreleased]`. `verify --train` refused the correct hand-resolved head at 593e61c. Separately, the train slotted the /cso docs PR (#3337) ahead of the catalog PR it describes (#3343). Each defect now has a regression test built from the real case.
+
+### Fixed
+- **`lib/version-gate-train.ts`**: the unstamped-head check read CHANGELOG.md through `spawnSync`'s default 1 MiB buffer. gstack's CHANGELOG is 1.67 MB, so both reads failed and the check compared two empty strings and passed. That is how #3345's 1eb7092 got through: `classifyTrainHead(base a59b5e4, head 1eb7092)` returned `unstamped`. The buffer is now 64 MiB, a CHANGELOG that can't be read is refused, and a dropped released heading is named in the error ("CHANGELOG.md drops released heading 1.91.179.0"). `gstack-ship-queue ready` runs the same check and refuses with `QUEUE_NOT_READY`.
+- **`lib/changelog-check.ts`**: `mergeChangelogs(ours, theirs, base?)` returns null, meaning resolve by hand, when either side damaged released history. Damage means one of two things: a released heading the merge base carries is missing from that side (dropped or renamed), or a section only that side carries holds a paragraph of a released entry the other side has. The train merge, the gate-ahead recompute and `gstack-restamp --after` all pass the merge base. A damaged PR is now `QUEUE_TRAIN_CONFLICT` on CHANGELOG.md with the reason. Before, the damage went into T.
+- **`lib/ship-queue-train-verify.ts`**: `verify --train` refused #3345's correct stamped head because T1's CHANGELOG was the damaged mechanical merge. The refusal printed the diff against the pin (main's files), not the diff against T1. A merged-in head is now accepted when it contains main and equals T modulo stamps, with its CHANGELOG judged against main: main's header and sections must be unchanged, with only lines from the pin's own CHANGELOG diff above them. The refusal now names the real difference.
+- **`lib/ship-queue-train-record.ts`**: READY receipts, train records and freezes are keyed on origin normalized to `https://github.com//`, whatever URL form the lane uses (github.com, git.capy.ai, scp, ssh, a token URL, with or without `.git`). The canonical key equals the github.com key existing records already use. Readers also accept the keys older versions wrote for the common URL forms, so #3331's `1fb5dd69740a8cf8` receipt is found. The stored `repo` URL never carries credentials.
+- **`lib/restamp.ts`**: its git reads use a 64 MiB buffer too. Before, `gstack-restamp --after` read each CHANGELOG conflict stage through the 1 MiB default.
+
+### Added
+- **`gstack-ship-queue train build --order ,...`**: an explicit slot order naming every pinned PR once. The default order (screened first, then READY order) is unchanged, and the train's later `NEXT:` rebuild lines keep the explicit order.
+
+### Tests
+- `test/changelog-merge-damage.test.ts`: the GSTA-64 repro, plus #3345's real CHANGELOGs (pin 1eb7092, base a59b5e4, main 4a98e40, train T1 6755ab8, stamped de23f5d, under `test/fixtures/merge-train-3345/`). Negative controls: a correct hand resolve, a shared tentative stamp, and two `[Unreleased]` blocks are not damage. An invented line, an edited main section, a changed header and a duplicate heading are still refused.
+- `test/compare-pr-version.test.ts`: on a CHANGELOG over 1 MiB, #3345's edit is refused naming 1.91.179.0, an additive entry passes, and a corrupt CHANGELOG object is refused. All three failed before the fix.
+- `test/ship-queue-train.test.ts`: a git.capy.ai lane writes the owner/repo key, and a legacy-key receipt is admitted. A damaged PR is a CHANGELOG conflict, and ready refuses it. `--order` sets and records the order. A hand-resolved merged-in head verifies, while an invented CHANGELOG line, a head without main and a stray file are refused. `test/ship-queue-train-logic.test.ts`: URL normalization, #3331's real keys, `orderSlots` and `parseOrder`.
+
+### Gate integrity
+- Version Gate unstamped check: **stronger**. It refuses a head it can't read and names dropped headings. Additive heads still pass at full size.
+- `mergeChangelogs` and the train CHANGELOG merge: **stronger**. Damage is now a conflict.
+- `verify --train` merged-in path: **narrower in one place, stronger elsewhere**. The head must now contain main. A CHANGELOG that differs from T is accepted only as main's CHANGELOG plus lines the pin itself added. Every other file must still equal T modulo stamps, and the paid gate never reads CHANGELOG text.
+- READY receipt lookup: **unchanged strength**. The head and CI binding are the same; only the repository key is normalized.
+
+## [1.91.191.0] - 2026-10-25
+
+**`gstack-version-bump write --changelog` moves the branch's `[Unreleased]` entry under the new version, and /ship Step 12 stops treating a provisional queue pick as a verified slot.** Stamping by hand meant writing VERSION with `gstack-version-bump write` and then renaming the `## [Unreleased]` heading yourself, because only `gstack-restamp` re-headed the CHANGELOG. Separately, when `gstack-next-version` could not read some open PRs' VERSION it returned `complete:false` and a provisional pick, and Step 12 printed the warning and then wrote that version as if the queue had been checked (left over from #3258, noted in GSTA-45).
+
+### Fixed
+- **`bin/gstack-version-bump`**: `write` takes an opt-in `--changelog `. It re-heads an existing `## [Unreleased]` section as `## [] - ` at the top, using the same `reheadChangelog` that `gstack-restamp` uses, so the result is byte-for-byte what a restamp produces. Only the heading changes; every entry is kept. A file with no `[Unreleased]` section, or no file, is left alone, so a released heading is never renamed. A move that would duplicate a version heading exits 2 before anything is written. The output gains a `changelog` field only when the flag is passed. `gstack-restamp` doesn't pass the flag, so under stamp-at-merge it still owns the re-heading, and a later restamp moves an entry this command headed as the branch's own stamp.
+- **`ship/SKILL.md.tmpl` Step 12**: an incomplete queue (`complete:false`) reruns the queue check once, then asks whether to use the provisional candidate (recommended, because Version Gate CI rereads the queue on every push) or stop, naming the PRs in `unknown_claims`. The write in item 4 passes `--changelog CHANGELOG.md`, so an `[Unreleased]` entry written early sits under the new version before Step 13 extends it.
+
+### Tests
+- `test/gstack-version-bump.test.ts`: eight cases for `--changelog`. They cover the re-heading (checked with `normalizeChangelog` and `checkChangelog`), an entry below the predecessor after a main merge, reruns, a file with no `[Unreleased]`, a missing file, the duplicate-heading refusal, bad paths, and a real `gstack-restamp` run after the write (`already headed`, then `1.2.4.0 -> 1.2.5.0`). Seven of them failed before the fix.
+- `test/ship-version-sync.test.ts`: the rendered Step 12 has to handle `complete:false`, using the field names `gstack-next-version` emits. Its write command, run verbatim, has to re-head `[Unreleased]` and leave a released heading alone. Both tests failed before the fix.
+
+## [1.91.190.0] - 2026-10-25
+
+**The weekly model-policy freshness check reads OpenAI's deprecations page again (#3155).** It has been red since the 2026-10-19 run: OpenAI's page now writes some deprecated models as a display name followed by the backticked ids, for example ``GPT-4o (`gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`)``, and the parser refused any model cell that did not start with a backtick, so every run stopped with `openai-lifecycle:unrecognized-deprecated-model-cell`. Left alone, the stored pinned-model evidence would have gone stale around 2026-11-11.
+
+### Fixed
+- **`lib/model-policy-freshness-sources.ts`**: an OpenAI deprecated-model cell is accepted when it contains backticked model ids, wherever they sit in the cell, and each id becomes its own lifecycle row. A cell with no backticked id still fails with `unrecognized-deprecated-model-cell`. The live page (42,211 bytes, fetched 2026-10-24) now parses to 177 rows, including the five ids from its four display-name rows.
+
+### Tests
+- `test/fixtures/model-policy-freshness/openai-lifecycle.md` gains a display-name row, and `test/model-policy-freshness.test.ts` checks that both of its ids come out with the row's shutdown date. With the old parser, 16 of the file's 21 tests fail on the new fixture.
+- Negative controls: the existing cell with its backticks removed, and the new display-name row with unticked ids, both still fail with `unrecognized-deprecated-model-cell`.
+
+### Gate integrity
+- One accepted-input change to review: the OpenAI lifecycle parser accepts a model cell with text before its backticked ids. It is a weekly advisory check, not a required check. The negative controls above still fail closed, and no threshold, budget, timeout, trial count or case kind changed.
+
## [1.91.189.0] - 2026-10-25
**A stalled apt mirror can no longer hold a Free Tests shard for 20 minutes, PR runs now see main's branch layout, and gstack's install default moves to Bun 1.4.4.** On 2026-10-21, free-suite (12) in run 39641712129 sat in "Install Playwright Chromium" until the job timeout cancelled it. The browser had already come from cache: `--with-deps` was waiting on apt's fetch of noble-security from archive.ubuntu.com. No tests ran, and a whole merge train had to be rebuilt. Separately, a push to main checks out a local `main` while PR runs check out a detached merge ref, so a fixture that created its own `main` passed its PR and then failed every main push until #3237 (run 38999168852). And Capy images and gbrain now run Bun 1.4.4, which no CI lane tested, while the installer still downloaded 1.4.3.
diff --git a/CLAUDE.md b/CLAUDE.md
index a3ed96bc2..095a8d7b9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -544,7 +544,7 @@ Key routing rules:
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
-- Bugs/errors → invoke /investigate
+- Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test)
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
diff --git a/SKILL.md b/SKILL.md
index 526670028..7ab37f435 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -189,7 +189,7 @@ quality gates that produce better results than answering inline.
- User asks to review design of a plan → invoke `/plan-design-review`
- User asks about developer experience of a plan, API/CLI/SDK design → invoke `/plan-devex-review`
- User wants all reviews done automatically, "review everything" → invoke `/autoplan`
-- User reports a bug, error, broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → invoke `/investigate`
+- User reports a bug, error, failing test or broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → check the cause by **Failures** below, then fix it or invoke `/investigate`
- User asks to test the site, find bugs, QA, "does this work", "check the deploy" → invoke `/qa`
- User asks to just report bugs without fixing → invoke `/qa-only`
- User asks to review code, check the diff, pre-landing review, "look at my changes" → invoke `/review`
@@ -244,6 +244,11 @@ Generic “second opinion”, “outside review”, or “cross-model review”
- Voice trigger "quality check", "test the app", "run QA" → invoke `/qa`
- Voice trigger "bug report", "just check for bugs" → invoke `/qa-only`
+**Failures: check the cause, then route.** A failing test, error or stack trace shows where a failure surfaces, not its root cause.
+
+- **Cause supplied** (the user names a specific cause and a narrow fix): make one read-only check of the evidence it rests on, such as the named file, test or log. If that confirms the cause, make the fix and run the focused test that covers it. If it does not, invoke `/investigate` before any further tracing or edit.
+- **Cause unknown:** make one read-only triage pass over the supplied evidence and the directly implicated test and code. Do not reproduce, test hypotheses or search wider. If that pass establishes the root cause and a narrow fix, make the fix and run the focused test that covers it. If the cause, scope or intended behavior is still unclear, invoke `/investigate` before any broader diagnosis or edit.
+
**Route only to skills in your available-skills list.** A rule above whose skill is
not listed there is turned off on this install; skip it, never try to invoke it.
diff --git a/SKILL.md.tmpl b/SKILL.md.tmpl
index 3326854f2..c230671ce 100644
--- a/SKILL.md.tmpl
+++ b/SKILL.md.tmpl
@@ -65,7 +65,7 @@ quality gates that produce better results than answering inline.
- User asks to review design of a plan → invoke `/plan-design-review`
- User asks about developer experience of a plan, API/CLI/SDK design → invoke `/plan-devex-review`
- User wants all reviews done automatically, "review everything" → invoke `/autoplan`
-- User reports a bug, error, broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → invoke `/investigate`
+- User reports a bug, error, failing test or broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → check the cause by **Failures** below, then fix it or invoke `/investigate`
- User asks to test the site, find bugs, QA, "does this work", "check the deploy" → invoke `/qa`
- User asks to just report bugs without fixing → invoke `/qa-only`
- User asks to review code, check the diff, pre-landing review, "look at my changes" → invoke `/review`
@@ -99,6 +99,11 @@ quality gates that produce better results than answering inline.
- User asks to tune question sensitivity, "stop asking me that" → invoke `/plan-tune`
- User asks for code quality dashboard, "health check" → invoke `/health`{{VOICE_TRIGGER_ROUTING}}
+**Failures: check the cause, then route.** A failing test, error or stack trace shows where a failure surfaces, not its root cause.
+
+- **Cause supplied** (the user names a specific cause and a narrow fix): make one read-only check of the evidence it rests on, such as the named file, test or log. If that confirms the cause, make the fix and run the focused test that covers it. If it does not, invoke `/investigate` before any further tracing or edit.
+- **Cause unknown:** make one read-only triage pass over the supplied evidence and the directly implicated test and code. Do not reproduce, test hypotheses or search wider. If that pass establishes the root cause and a narrow fix, make the fix and run the focused test that covers it. If the cause, scope or intended behavior is still unclear, invoke `/investigate` before any broader diagnosis or edit.
+
**Route only to skills in your available-skills list.** A rule above whose skill is
not listed there is turned off on this install; skip it, never try to invoke it.
diff --git a/VERSION b/VERSION
index 46844378b..c5d5571bc 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.91.189.0
+1.91.207.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index b343b2739..2ce846c8e 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.91.189.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.91.207.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
diff --git a/bin/gstack-gbrain-repo-policy b/bin/gstack-gbrain-repo-policy
index 5b23caa9a..f98c39e19 100755
--- a/bin/gstack-gbrain-repo-policy
+++ b/bin/gstack-gbrain-repo-policy
@@ -15,10 +15,22 @@
# rather than bypass a set policy.
#
# gstack-gbrain-repo-policy set
-# Persist a tier for the given remote. Exits 0 on success.
+# Persist a tier for the given remote. Exits 0 on success. Changing a
+# remote's tier clears its Memorable recall opt-in.
+#
+# gstack-gbrain-repo-policy memorable get []
+# Print "\t": the tier (as `get` prints it) and whether
+# Memorable recall is opted in for this remote. Read-only lookup for the
+# recall hook: a corrupt store is a hard error (exit 2), never quarantined.
+#
+# gstack-gbrain-repo-policy memorable set
+# Opt a read-only remote in to (or back out of) Memorable recall. Off by
+# default. Refused unless the remote's tier is read-only: a deny tier
+# always vetoes recall, and read-write never needed an opt-in.
#
# gstack-gbrain-repo-policy list
-# Print every entry as "\t", sorted by key.
+# Print every entry as "\t", sorted by key, with a third
+# column "memorable=allow" on remotes opted in to Memorable recall.
#
# gstack-gbrain-repo-policy normalize
# Print the normalized (canonical) key for a given remote URL.
@@ -33,8 +45,11 @@
# {
# "_schema_version": 2,
# "github.com/foo/bar": "read-write",
-# "github.com/baz/qux": "deny"
+# "github.com/baz/qux": "deny",
+# "_memorable_recall": { "github.com/foo/bar": "allow" }
# }
+# The keys _schema_version and _memorable_recall are reserved; other keys
+# name remotes (including the _unattributed sentinel memory-ingest uses).
#
# Tier semantics:
# read-write — agent may search AND write new pages from this repo.
@@ -43,6 +58,11 @@
# decision.)
# deny — no gbrain interaction at all.
#
+# Memorable recall opt-in: the recall hook skips deny and read-only remotes.
+# A read-only remote listed in _memorable_recall with exactly "allow" is
+# recalled anyway; any other value or shape counts as off. Every store that
+# makes the remote read-only (see the merge below) must opt it in.
+#
# Legacy migration:
# On any read of a file missing `_schema_version` (or with version < 2),
# legacy `allow` values are atomically rewritten to `read-write`, and
@@ -187,6 +207,24 @@ ensure_file() {
fi
}
+# memorable_optin FILE KEY — "allow" when FILE's _memorable_recall map holds
+# exactly the string "allow" for KEY; any other value or shape is "off".
+memorable_optin() {
+ jq -r --arg key "$2" '(._memorable_recall | if type == "object" then .[$key] else null end) as $v
+ | if $v == "allow" then "allow" else "off" end' "$1"
+}
+
+# jq filter that removes $key's opt-in (and an emptied map).
+DROP_MEMORABLE_OPTIN='if (._memorable_recall | type) == "object" then (._memorable_recall |= del(.[$key])) | (if ._memorable_recall == {} then del(._memorable_recall) else . end) else . end'
+
+# _schema_version and _memorable_recall hold store metadata; a remote that
+# normalizes to one of them cannot be stored.
+reject_reserved_key() {
+ case "$1" in
+ _schema_version|_memorable_recall) die "'$1' is a reserved key in the policy store, not a remote" ;;
+ esac
+}
+
_tier_rank() {
case "$1" in deny) echo 0 ;; read-only) echo 1 ;; read-write) echo 2 ;; *) echo 3 ;; esac
}
@@ -295,13 +333,146 @@ cmd_set() {
local key
key=$(normalize "$url")
[ -z "$key" ] && die "cannot normalize remote URL: $url"
+ reject_reserved_key "$key"
ensure_file
- local tmp
+ local previous opted tmp
+ previous=$(jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE")
+ opted=$(memorable_optin "$POLICY_FILE" "$key")
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
- jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp"
+ if [ "$previous" != "$tier" ]; then
+ jq --arg key "$key" --arg tier "$tier" ".[\$key] = \$tier | $DROP_MEMORABLE_OPTIN" "$POLICY_FILE" > "$tmp"
+ else
+ jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp"
+ fi
mv "$tmp" "$POLICY_FILE"
chmod 0600 "$POLICY_FILE"
echo "Set $key → $tier"
+ if [ "$previous" != "$tier" ] && [ "$opted" = "allow" ]; then
+ echo "Cleared the Memorable recall opt-in for $key (the tier changed; opt in again with: gstack-gbrain-repo-policy memorable set $key allow)"
+ fi
+}
+
+# memorable_store_answer FILE KEY — " " for KEY in one store,
+# read from a single snapshot of the file with no write, migration or
+# quarantine (tier is none when the file or the key is absent; a legacy
+# "allow" reads as read-write in memory). Dies (exit 2) on anything else: a
+# path that is present but not a readable regular file, a path whose
+# existence cannot be confirmed (an unsearchable ancestor), content that is not exactly one JSON object, or a
+# present key whose value is not a known tier.
+memorable_store_answer() {
+ local store="$1" key="$2" up raw
+ if ! [ -e "$store" ] && ! [ -L "$store" ]; then
+ # Absent only if the nearest existing ancestor is a searchable directory:
+ # an unsearchable one hides an existing store from every test above.
+ up=$(dirname "$store")
+ while ! [ -e "$up" ] && ! [ -L "$up" ] && [ "$up" != "/" ] && [ "$up" != "." ]; do up=$(dirname "$up"); done
+ if [ -d "$up" ] && [ -x "$up" ]; then
+ printf 'none off\n'
+ return 0
+ fi
+ die "cannot confirm whether $store exists ($up is not a searchable directory) — refusing the Memorable recall lookup."
+ fi
+ if ! [ -f "$store" ] || ! [ -r "$store" ]; then
+ die "policy store $store exists but is not a readable file — refusing the Memorable recall lookup. Inspect or remove it."
+ fi
+ raw=$(cat -- "$store") || die "cannot read policy store $store — refusing the Memorable recall lookup."
+ printf '%s' "$raw" | jq -e -s -r --arg key "$key" '
+ if length == 1 and (.[0] | type) == "object" then .[0] else error("not one JSON object") end
+ | (if has($key) then
+ (.[$key] | if . == "allow" then "read-write"
+ elif . == "deny" or . == "read-only" or . == "read-write" then .
+ else error("invalid tier") end)
+ else "none" end) as $tier
+ | ((._memorable_recall | if type == "object" then .[$key] else null end) == "allow") as $opt
+ | "\($tier) \(if $opt then "allow" else "off" end)"' 2>/dev/null \
+ || die "policy store $store is corrupt (not one JSON object, or an invalid tier for $key) — refusing the Memorable recall lookup. Inspect with: gstack-gbrain-repo-policy list; re-run /setup-gbrain to rebuild the store."
+}
+
+# memorable get — tier plus the effective Memorable recall opt-in, for the
+# recall hook. Pure read: each store is read once, never migrated, never
+# quarantined, and any doubt about a present store is a hard error (exit 2),
+# because reading it as "no policy" would silently lift a deny or read-only
+# veto. The tier merges as get does (the ~/.gstack store contributes only deny
+# or read-only). The opt-in counts only when the merged tier is read-only and
+# every store that restricts the remote (deny or read-only) opts it in, so an
+# opt-in can never relax a restriction another store imposed.
+cmd_memorable_get() {
+ local url="${1:-}"
+ require_jq
+ if [ -z "$url" ]; then
+ url=$(git config --get remote.origin.url 2>/dev/null || true)
+ fi
+ local key
+ key=$(normalize "$url")
+ if [ -z "$key" ]; then
+ printf 'unset\toff\n'
+ return 0
+ fi
+ local answer p_tier p_opt l_tier="none" l_opt="off"
+ answer=$(memorable_store_answer "$POLICY_FILE" "$key") || exit 2
+ read -r p_tier p_opt <<<"$answer"
+ if [ -n "$_gstack_legacy_root" ]; then
+ answer=$(memorable_store_answer "$_gstack_legacy_root/gbrain-repo-policy.json" "$key") || exit 2
+ read -r l_tier l_opt <<<"$answer"
+ fi
+ local merged="$p_tier"
+ case "$l_tier" in
+ deny|read-only)
+ if [ "$(_tier_rank "$l_tier")" -lt "$(_tier_rank "$merged")" ]; then merged="$l_tier"; fi ;;
+ esac
+ if [ "$merged" != "read-only" ]; then
+ [ "$merged" = "none" ] && merged="unset"
+ printf '%s\toff\n' "$merged"
+ return 0
+ fi
+ local optin="allow"
+ case "$p_tier" in deny|read-only) [ "$p_opt" = "allow" ] || optin="off" ;; esac
+ case "$l_tier" in deny|read-only) [ "$l_opt" = "allow" ] || optin="off" ;; esac
+ printf 'read-only\t%s\n' "$optin"
+}
+
+cmd_memorable_set() {
+ local url="${1:-}" value="${2:-}"
+ if [ -z "$url" ] || [ -z "$value" ]; then die "usage: memorable set "; fi
+ case "$value" in
+ allow|off) ;;
+ *) die "invalid value '$value' (must be allow or off)" ;;
+ esac
+ local key
+ key=$(normalize "$url")
+ [ -z "$key" ] && die "cannot normalize remote URL: $url"
+ reject_reserved_key "$key"
+ if [ "$value" = "allow" ]; then
+ local current
+ current=$(cmd_memorable_get "$url" | cut -f1)
+ case "$current" in
+ read-only) ;;
+ deny) die "$key is deny: a deny tier always vetoes Memorable recall, so an opt-in is refused. Change the tier first if you mean it: gstack-gbrain-repo-policy set $key read-only" ;;
+ *) die "$key is $current, not read-only: the opt-in only applies to read-only remotes (read-write is already recalled; an unset remote has no veto)" ;;
+ esac
+ fi
+ ensure_file
+ local tmp
+ tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
+ if [ "$value" = "allow" ]; then
+ jq --arg key "$key" '._memorable_recall = ((._memorable_recall | if type == "object" then . else {} end) + {($key): "allow"})' "$POLICY_FILE" > "$tmp"
+ else
+ jq --arg key "$key" "$DROP_MEMORABLE_OPTIN" "$POLICY_FILE" > "$tmp"
+ fi
+ mv "$tmp" "$POLICY_FILE"
+ chmod 0600 "$POLICY_FILE"
+ echo "Set $key memorable recall → $value"
+ if [ "$value" = "allow" ] && [ "$(cmd_memorable_get "$url" | cut -f2)" != "allow" ]; then
+ echo "gstack-gbrain-repo-policy: $LEGACY_POLICY_FILE also makes $key read-only and does not opt it in, so recall stays off. Opt in there too: GSTACK_STATE_ROOT=$_gstack_legacy_root gstack-gbrain-repo-policy memorable set $key allow" >&2
+ fi
+}
+
+cmd_memorable() {
+ case "${1:-}" in
+ get) shift; cmd_memorable_get "$@" ;;
+ set) shift; cmd_memorable_set "$@" ;;
+ *) die "usage: memorable {get []|set }" ;;
+ esac
}
cmd_list() {
@@ -310,7 +481,9 @@ cmd_list() {
return 0
fi
ensure_file
- jq -r 'to_entries[] | select(.key != "_schema_version") | "\(.key)\t\(.value)"' "$POLICY_FILE" | sort
+ jq -r '(._memorable_recall | if type == "object" then . else {} end) as $m
+ | to_entries[] | select(.key != "_schema_version" and .key != "_memorable_recall")
+ | "\(.key)\t\(.value)" + (if $m[.key] == "allow" then "\tmemorable=allow" else "" end)' "$POLICY_FILE" | sort
}
cmd_normalize() {
@@ -323,8 +496,9 @@ case "${1:-}" in
get) shift; cmd_get "$@" ;;
set) shift; cmd_set "$@" ;;
list) shift; cmd_list "$@" ;;
+ memorable) shift; cmd_memorable "$@" ;;
normalize) shift; cmd_normalize "$@" ;;
- --help|-h|help) sed -n '2,54p' "$0" | sed 's/^# \{0,1\}//' ;;
- "") die "usage: gstack-gbrain-repo-policy {get|set|list|normalize|--help}" ;;
+ --help|-h|help) sed -n '2,73p' "$0" | sed 's/^# \{0,1\}//' ;;
+ "") die "usage: gstack-gbrain-repo-policy {get|set|memorable|list|normalize|--help}" ;;
*) die "unknown subcommand: $1" ;;
esac
diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts
index d8d8d0e9f..88cfbfdbe 100644
--- a/bin/gstack-global-discover.ts
+++ b/bin/gstack-global-discover.ts
@@ -186,11 +186,26 @@ function gitProject(cwd: string): GitProject | null {
const up = prefix.split("/").filter(Boolean).map(() => "..");
project = { root: resolve(cwd, ...up), main: basename(common) === ".git" ? dirname(common) : common };
}
+ } else {
+ // A merged branch's worktree is removed but its session history stays:
+ // count those sessions with the repository the worktree was created from.
+ const parent = parentRepoOfClaudeWorktree(cwd);
+ if (parent) project = gitProject(parent);
}
PROJECT_MEMO.set(cwd, project);
return project;
}
+/**
+ * Claude Code creates worktrees at `/.claude/worktrees/`. Given a
+ * cwd inside one (the worktree root or any subdirectory), the repository root
+ * it was created from; null for any other path.
+ */
+export function parentRepoOfClaudeWorktree(cwd: string): string | null {
+ const m = cwd.match(/^(.+?)[\\/]\.claude[\\/]worktrees[\\/][^\\/]+(?:[\\/].*)?$/);
+ return m ? m[1] : null;
+}
+
function getGitRemote(root: string): string | null {
const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], {
encoding: "utf-8",
diff --git a/bin/gstack-pregate b/bin/gstack-pregate
index 9a6db5752..8ce0a2083 100755
--- a/bin/gstack-pregate
+++ b/bin/gstack-pregate
@@ -29,7 +29,7 @@ import * as path from 'node:path';
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
-import { buildContext, describeRefusal } from '../lib/pregate/context';
+import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
@@ -140,13 +140,14 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; }
remote = { spendCapUsd: cap };
} else if (flag(rest, '--spend-cap') !== undefined) { process.stderr.write('gstack-pregate: --spend-cap applies only with --remote\n'); return EXIT.usage; }
const base = resolveBaseBranch(root, flag(rest, '--base'));
- const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2] });
+ const userConfigPath = defaultUserConfigPath(root);
+ const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2], userConfigPath });
if ('refused' in loaded) {
const refusal = describeRefusal(loaded.refused, base);
process.stderr.write(refusal.line + '\n');
@@ -281,6 +282,8 @@ async function main(argv: string[]): Promise {
if (bad) { process.stderr.write(`gstack-pregate: ${bad}; see gstack-pregate --help\n`); return EXIT.usage; }
const root = repoRoot();
if (!root) { process.stderr.write('gstack-pregate: not inside a git repository\n'); return EXIT.usage; }
+ const dotenv = loadedDotenvFiles(process.cwd(), process.execArgv);
+ if (dotenv.length) { process.stderr.write(resultLine('gstack-pregate', 'POLICY_SOURCE_UNAVAILABLE', `bun loaded ${dotenv.join(', ')} from the working directory, and a checkout's .env must not choose the state root or slug behind the policy; run gstack-pregate directly (its shebang passes --no-env-file) or as bun --no-env-file`) + '\n'); return EXIT.refused; }
if (cmd === 'run') return await cmdRun(root, rest, json);
if (cmd === 'verify') return cmdVerify(root, rest, json);
if (cmd === 'clear') return cmdClear(root, rest);
diff --git a/bin/gstack-review-log b/bin/gstack-review-log
index 8a4dd2288..102fb1dc1 100755
--- a/bin/gstack-review-log
+++ b/bin/gstack-review-log
@@ -3,6 +3,10 @@
# Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}'
#
# Before reading a diff: gstack-review-log --start review
+# Fix-range repeat (#3339): gstack-review-log --start review --fix-range
+# prints the token, then FIX_BASE=; refuses (exit 1) unless the latest
+# pass completed with fixes on unchanged content and its commit is an
+# ancestor of HEAD. Its row is never CURRENT; a later full pass must follow.
# After that pass: gstack-review-log '{...,"completed":true,"converged":true}' --finish TOKEN
# With a findings file: gstack-review-log '{...}' --findings findings.jsonl
# status/unresolved/issues_found/critical_gaps are DERIVED from the file (B7);
@@ -72,9 +76,11 @@ fi
SESSION_KIND=$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive)
export GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" GSTACK_STAMP_SESSION_KIND="$SESSION_KIND"
if [ "$INPUT" = --start ]; then
- GSTACK_REVIEW_SKILL="${2:-}" bun --no-env-file --config=/dev/null -e '
+ case "${3:-}" in ''|--fix-range) ;; *) echo 'Usage: gstack-review-log --start SKILL [--fix-range]' >&2; exit 2 ;; esac
+ GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config=/dev/null -e '
const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB);
- console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL));
+ try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); }
+ catch (e) { console.error(e.message); process.exit(1); }
'
exit $?
fi
@@ -86,7 +92,7 @@ if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then
exit $?
fi
usage() {
- echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL | --check-shared-libs TOKEN < finding.json' >&2
+ echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] | --check-shared-libs TOKEN < finding.json' >&2
echo 'Exit codes: 0 written · 1 rejected · 2 usage' >&2
exit 2
}
diff --git a/bin/gstack-session-update b/bin/gstack-session-update
index 7166abc6d..2ae01297f 100755
--- a/bin/gstack-session-update
+++ b/bin/gstack-session-update
@@ -53,6 +53,7 @@ LOG_FILE="$STATE_DIR/analytics/session-update.log"
PENDING_FILE="$STATE_DIR/session-update-pending"
HOOK_CHECK="$(cd "$(dirname "$0")" && pwd)/gstack-hook-check"
FF_UPDATE="$(cd "$(dirname "$0")" && pwd)/gstack-ff-update"
+LEASE="$(cd "$(dirname "$0")" && pwd)/gstack-lease"
RESCUE_FILE="$STATE_DIR/session-update-rescues"
# Path spelling (Git Bash /c/x vs native C:/x) for the held-reason prefix strip.
. "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" 2>/dev/null || { gstack_path_shell() { printf '%s\n' "$1"; }; gstack_path_native() { printf '%s\n' "$1"; }; }
@@ -362,6 +363,16 @@ fi
) &
HB_PID=$!
+ # ── Workflow leases (#3363): a live gstack session on this installation
+ # (bin/gstack-lease) means neither the fast-forward nor a pending setup
+ # retry runs under it, the rule bin/gstack-upgrade enforces as UPGRADE_BUSY.
+ # The throttle file is not written, so the next session start tries again.
+ if [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$LEASE" ] \
+ && BUSY=$("$LEASE" live --root "$GSTACK_DIR" --exclude-ancestry-of $$ 2>/dev/null); then
+ log_entry "SKIP leased $(printf '%s\n' "$BUSY" | awk 'NF { printf "%spid=%s/%s", n++ ? " " : "", $1, $2 }')"
+ exit 0
+ fi
+
# ── Fetch, check the incoming release's Bun floor, then fast-forward (E1) ──
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "")
diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start
index 6592d5746..680a645b9 100755
--- a/bin/gstack-skill-start
+++ b/bin/gstack-skill-start
@@ -604,7 +604,7 @@ Key routing rules:
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
-- Bugs/errors → invoke /investigate
+- Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test)
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
@@ -639,10 +639,21 @@ fi # end interactive-onboarding guard (kind != spawned/unattended)
# final gate is written and never approved, nothing is published or synced.
# It also carries the host adaptation a harness without a Skill tool, slash
# commands or AskUserQuestion needs, printed here so every host's render has it
-# without growing any skill's prose.
+# without growing any skill's prose. The record sentence is per skill: only
+# skills with steps that write GSTACK_RESULT are told to end with it, and the
+# rest name the record the parent reads instead (docs/unattended.md, #3281).
if [ "$_SESSION_KIND" = "unattended" ]; then
+ _UA_NONE="This skill writes no run directory and prints no GSTACK_RESULT line, so do not invent either; end at its first pending item and list that item, with every recommended option you took, in your final report."
+ case "$SKILL_NAME" in
+ autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=\`." ;;
+ eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=\`, where is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in ." ;;
+ ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt in the PR body (\`gstack-ship-receipt read --pr --json\`)." ;;
+ review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;;
+ qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;;
+ *) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;;
+ esac
_emit_block unattended-session < status=gate_pending run=\`; you never approve it, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
+You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
EOI
fi
diff --git a/bin/gstack-specialist-stats b/bin/gstack-specialist-stats
index 3162e3033..af0b3d15e 100755
--- a/bin/gstack-specialist-stats
+++ b/bin/gstack-specialist-stats
@@ -5,6 +5,8 @@
# Reads all *-reviews.jsonl files across branches, parses specialist fields,
# and outputs hit rates. Tags specialists as GATE_CANDIDATE (0 findings in 10+
# dispatches) or NEVER_GATE (security, data-migration, state-machines — insurance policy).
+# Fix-range passes (scope "fix-range", #3339) are skipped: a narrowed dispatch
+# says nothing about a specialist's hit rate on a whole branch.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
@@ -40,7 +42,7 @@ let reviewed = 0;
for (const line of lines) {
try {
const e = JSON.parse(line);
- if (!e.specialists) continue;
+ if (!e.specialists || e.scope === 'fix-range') continue;
reviewed++;
for (const [name, info] of Object.entries(e.specialists)) {
if (!stats[name]) stats[name] = { dispatched: 0, findings: 0 };
diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump
index 0d470c351..ba6a51254 100755
--- a/bin/gstack-version-bump
+++ b/bin/gstack-version-bump
@@ -25,8 +25,16 @@
// the caller must handle), exit 2 on bad args / unresolvable base /
// broken version source.
//
-// write --version [--version-path ] [--regen-digest]
+// write --version [--version-path ] [--regen-digest] [--changelog
]
// Validates the 4-digit pattern, writes VERSION + package.json.version.
+// --changelog
(opt-in): re-head the branch's `## [Unreleased]`
+// entry in
as `## [] - ` at the top, with
+// lib/changelog-check's reheadChangelog — the same function
+// gstack-restamp uses, so a later restamp finds the heading already in
+// place (or moves it again as the branch's own stamp). No [Unreleased]
+// section, or an absent file, is a no-op. Duplicate headings refuse with
+// exit 2 before anything is written. restamp never passes the flag: under
+// stamp-at-merge it stays the only owner of the re-heading.
// --regen-digest (opt-in): additionally run the target repo's own
// scripts/gen-agents-digest.ts when present, so the version-stamped
// committed digest stays freshness-green through the bump. Executes
@@ -67,6 +75,7 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { bunIsolationArgs } from "../lib/bun-isolation";
+import { checkChangelog, parseChangelog, reheadChangelog } from "../lib/changelog-check";
import {
extractVersion,
isJsonVersionPath,
@@ -404,6 +413,42 @@ function regenAgentsDigest(versionPath: string, optedIn: boolean): boolean | nul
}
}
+interface ChangelogPlan { rel: string; text: string | null; detail: string }
+
+/**
+ * --changelog: computed before any write, so a CHANGELOG the re-heading
+ * cannot handle (duplicate headings) refuses with nothing written. Only an
+ * existing [Unreleased] section moves; released sections are never renamed.
+ */
+function planChangelog(cwd: string, args: string[], version: string): ChangelogPlan | null {
+ if (!args.includes("--changelog")) return null;
+ const rel = argVal(args, "--changelog");
+ if (!rel || rel.startsWith("--")) fail("write: --changelog needs a path (for example --changelog CHANGELOG.md)", 2);
+ assertRepoContained(cwd, rel, "--changelog");
+ const p = join(cwd, rel);
+ if (!existsSync(p)) return { rel, text: null, detail: `${rel} absent` };
+ const text = readFileSync(p, "utf-8");
+ if (!parseChangelog(text).sections.some((s) => s.unreleased)) return { rel, text: null, detail: "no [Unreleased] section" };
+ const r = reheadChangelog(text, { from: null, to: version, date: new Date().toISOString().slice(0, 10), file: rel });
+ if (r.error) fail(`write: ${r.error.path}: ${r.error.message}. Nothing written.`, 2);
+ if (!r.moved) return { rel, text: null, detail: `already headed ${version}` };
+ const errors = checkChangelog(r.text, version, rel);
+ if (errors.length) fail(`write: ${errors[0]!.path}: ${errors[0]!.message}. Nothing written.`, 2);
+ return { rel, text: r.text, detail: `Unreleased -> ${version}` };
+}
+
+function writeChangelog(cwd: string, plan: ChangelogPlan | null): { changelog: { path: string; moved: boolean; detail: string } } | Record {
+ if (!plan) return {};
+ if (plan.text !== null) {
+ try {
+ writeFileSync(join(cwd, plan.rel), plan.text);
+ } catch {
+ fail(`write: the version files were written but ${plan.rel} was not. Re-run write with the same --version and --changelog.`, 3);
+ }
+ }
+ return { changelog: { path: plan.rel, moved: plan.text !== null, detail: plan.detail } };
+}
+
function cmdWrite(args: string[], cwd: string): void {
const version = argVal(args, "--version");
if (!version) fail("write requires --version ", 2);
@@ -431,6 +476,7 @@ function cmdWrite(args: string[], cwd: string): void {
// npm rejects forever — with no drift state to catch it (a JSON source is
// self-consistent by construction).
const jsonV = npmVersion(version!);
+ const changelogPlan = planChangelog(cwd, args, jsonV);
let manifestWritten = false;
let lockSynced: string[] = [];
try {
@@ -466,6 +512,7 @@ function cmdWrite(args: string[], cwd: string): void {
// here: a JSON-manifest version source implies no plain VERSION file
// for the digest generator to stamp.
agentsDigest: null,
+ ...writeChangelog(cwd, changelogPlan),
}) + "\n",
);
return;
@@ -473,6 +520,7 @@ function cmdWrite(args: string[], cwd: string): void {
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
const hasPkg = existsSync(pkgPath);
+ const changelogPlan = planChangelog(cwd, args, version!);
writeFileSync(versionPath, version + "\n");
const digestRegen = regenAgentsDigest(versionPath, args.includes("--regen-digest"));
let lockSynced: string[] = [];
@@ -511,6 +559,7 @@ function cmdWrite(args: string[], cwd: string): void {
packageLock: lockSynced.length > 0,
// null = not the gstack repo (no digest to regen); true/false = regen outcome
agentsDigest: digestRegen,
+ ...writeChangelog(cwd, changelogPlan),
}) + "\n",
);
}
diff --git a/browse/src/bun-child-config.ts b/browse/src/bun-child-config.ts
index 045a97571..7c808c02b 100644
--- a/browse/src/bun-child-config.ts
+++ b/browse/src/bun-child-config.ts
@@ -33,3 +33,41 @@ export function emptyBunConfig(platform: NodeJS.Platform = process.platform): st
emptyConfig = file;
return file;
}
+
+export interface BunExeLookup {
+ env?: NodeJS.ProcessEnv;
+ execPath?: string;
+ isBun?: boolean;
+ isFile?: (p: string) => boolean;
+}
+
+/**
+ * The bun.exe a Windows parent spawns its bun children with, or null when
+ * none is installed: the running bun.exe itself, then BUN_INSTALL\bin, each
+ * absolute PATH entry (plus, beside npm's bun.cmd shim, the bun.exe it
+ * forwards to), then USERPROFILE\.bun\bin. The terminal agent and the native
+ * cookie-import worker (cookie-import-native.ts) both resolve through it; it
+ * lives here because this file is part of the native import's source-bound
+ * qualification (NATIVE_CODE_INPUTS), and terminal-agent-control.ts is not.
+ */
+export function findWindowsBunExe(opts: BunExeLookup = {}): string | null {
+ const win = path.win32;
+ const env = opts.env ?? process.env;
+ const execPath = opts.execPath ?? process.execPath;
+ const isBun = opts.isBun ?? !!process.versions.bun;
+ const isFile = opts.isFile ?? ((p: string) => { try { return fs.statSync(p).isFile(); } catch { return false; } });
+ // A compiled browse.exe also reports versions.bun, so check the name too.
+ if (isBun && /^bun\.exe$/i.test(win.basename(execPath))) return execPath;
+ const dirs = (env.PATH || env.Path || '').split(';').filter(d => d && win.isAbsolute(d));
+ const candidates = [
+ ...(env.BUN_INSTALL ? [win.join(env.BUN_INSTALL, 'bin', 'bun.exe')] : []),
+ ...dirs.flatMap(d => [
+ win.join(d, 'bun.exe'),
+ // npm global install: PATH holds only the bun.cmd shim; the real
+ // binary it forwards to lives under node_modules.
+ ...(isFile(win.join(d, 'bun.cmd')) ? [win.join(d, 'node_modules', 'bun', 'bin', 'bun.exe')] : []),
+ ]),
+ ...(env.USERPROFILE ? [win.join(env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []),
+ ];
+ return candidates.find(isFile) ?? null;
+}
diff --git a/browse/src/cookie-import-native.ts b/browse/src/cookie-import-native.ts
index 6fc0c41cb..7632300a6 100644
--- a/browse/src/cookie-import-native.ts
+++ b/browse/src/cookie-import-native.ts
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
import { release } from 'node:os';
import path from 'node:path';
import { CookieImportError, normalizeCookieDomain, type PlaywrightCookie } from './cookie-import-browser';
-import { emptyBunConfig } from './bun-child-config';
+import { emptyBunConfig, findWindowsBunExe } from './bun-child-config';
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity';
type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge';
@@ -107,14 +107,7 @@ export async function importNativeCookies(options: {
} catch (error) {
throw new CookieImportError('The native browser build could not be checked safely. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified');
}
- const bunCandidates = [
- ...(process.env.BUN_INSTALL ? [path.join(process.env.BUN_INSTALL, 'bin', 'bun.exe')] : []),
- ...(process.env.PATH || process.env.Path || '').split(path.delimiter).filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, 'bun.exe')),
- ...(process.env.USERPROFILE ? [path.join(process.env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []),
- ];
- const bunExecutable = bunCandidates.find(candidate => {
- try { return statSync(candidate).isFile(); } catch { return false; }
- });
+ const bunExecutable = findWindowsBunExe();
if (!bunExecutable || !qualified.some(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion)) {
throw new CookieImportError('This browser build or supervisor runtime has not passed native qualification. Sign in manually in the gstack browser.', 'native_unqualified');
}
diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts
index 1b7247838..bda034539 100644
--- a/browse/src/terminal-agent-control.ts
+++ b/browse/src/terminal-agent-control.ts
@@ -22,7 +22,7 @@ import { safeUnlink, isProcessAlive } from './error-handling';
import { restrictFilePermissions, mkdirSecure } from './file-permissions';
import { atomicWriteSync } from '../../lib/fs-atomic';
import { readPidCmdline, readPidStartTime } from './xvfb';
-import { emptyBunConfig } from './bun-child-config';
+import { emptyBunConfig, findWindowsBunExe, type BunExeLookup } from './bun-child-config';
function agentProcessInfo(pid: number): { startTime: string; commandLine: string } {
if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' };
@@ -149,6 +149,22 @@ export function bunChildFlags(): string[] {
return ['--no-env-file', `--config=${emptyBunConfig()}`];
}
+/**
+ * The bun executable to spawn the agent with. On Windows a bare 'bun' under
+ * the Node polyfill resolves (via cross-spawn) to npm's bun.cmd shim, run as
+ * `cmd.exe /c bun ...`. That breaks two ways: windowsHide only hides cmd.exe,
+ * and the detached cmd.exe has no console, so bun.exe allocates a fresh
+ * visible one (a window that steals focus on every spawn); and proc.pid is
+ * cmd.exe's, so the agent's startup check (record.pid === process.pid) never
+ * matches and it exits after 2s, which the 60s watchdog then respawns.
+ * Spawning bun.exe directly fixes both. Falls back to 'bun' when no .exe is
+ * found, which is the old behavior.
+ */
+export function resolveBunExecutable(opts: BunExeLookup & { platform?: string } = {}): string {
+ if ((opts.platform ?? process.platform) !== 'win32') return 'bun';
+ return findWindowsBunExe(opts) ?? 'bun';
+}
+
export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null {
const meta = searchHints.metaDir || __dirname;
const exec = searchHints.execPath || process.execPath;
@@ -208,7 +224,7 @@ export function spawnTerminalAgent(opts: {
writeAgentRecord(stateDir, reservation);
let proc: any;
try {
- proc = (Bun as any).spawn(['bun', 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], {
+ proc = (Bun as any).spawn([resolveBunExecutable(), 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], {
cwd: opts.cwd || process.cwd(),
env: {
...process.env,
diff --git a/browse/test/sidebar-tabs.test.ts b/browse/test/sidebar-tabs.test.ts
index a8ece8f68..3e0eed513 100644
--- a/browse/test/sidebar-tabs.test.ts
+++ b/browse/test/sidebar-tabs.test.ts
@@ -233,7 +233,7 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => {
'utf-8',
);
expect(CONTROL_SRC).toContain('terminal-agent.ts');
- expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/);
+ expect(CONTROL_SRC).toMatch(/\.spawn\(\[resolveBunExecutable\(\),\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/);
expect(CONTROL_SRC).toContain('BROWSE_OWNER_PID: String(opts.ownerPid)');
});
});
diff --git a/browse/test/terminal-agent-bun-exe.test.ts b/browse/test/terminal-agent-bun-exe.test.ts
new file mode 100644
index 000000000..5be1b0abf
--- /dev/null
+++ b/browse/test/terminal-agent-bun-exe.test.ts
@@ -0,0 +1,85 @@
+/**
+ * Windows terminal-agent console flash: under the Node polyfill a bare 'bun'
+ * resolved to npm's bun.cmd shim and ran as `cmd.exe /c bun ...`. The detached
+ * cmd.exe had no console, so bun.exe opened a visible one on every spawn, and
+ * proc.pid was cmd.exe's, so the agent failed its startup record check and
+ * exited, and the 60s watchdog respawned it. spawnTerminalAgent now spawns
+ * bun.exe directly.
+ */
+import { describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { findWindowsBunExe } from '../src/bun-child-config';
+import { resolveBunExecutable } from '../src/terminal-agent-control';
+
+const files = (...paths: string[]) => (p: string) => paths.some(f => f.toLowerCase() === p.toLowerCase());
+
+describe('resolveBunExecutable', () => {
+ test('POSIX keeps bare bun', () => {
+ expect(resolveBunExecutable({ platform: 'linux', isFile: () => true })).toBe('bun');
+ expect(resolveBunExecutable({ platform: 'darwin', isFile: () => true })).toBe('bun');
+ });
+
+ test('Windows under bun.exe reuses its own executable', () => {
+ expect(resolveBunExecutable({
+ platform: 'win32', isBun: true, execPath: 'C:\\Tools\\bun.exe', env: {}, isFile: () => false,
+ })).toBe('C:\\Tools\\bun.exe');
+ });
+
+ test('a compiled browse.exe is not mistaken for bun', () => {
+ expect(resolveBunExecutable({
+ platform: 'win32', isBun: true, execPath: 'C:\\gstack\\browse\\dist\\browse.exe',
+ env: { PATH: 'C:\\bun\\bin' }, isFile: files('C:\\bun\\bin\\bun.exe'),
+ })).toBe('C:\\bun\\bin\\bun.exe');
+ });
+
+ test('npm global install resolves through the bun.cmd shim to the real bun.exe', () => {
+ const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm';
+ expect(resolveBunExecutable({
+ platform: 'win32', isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe',
+ env: { PATH: `C:\\Windows\\System32;${npm}` },
+ isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`),
+ })).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`);
+ });
+
+ test('BUN_INSTALL wins over PATH, and USERPROFILE\\.bun is the last resort', () => {
+ const isFile = files('C:\\bi\\bin\\bun.exe', 'C:\\p\\bun.exe', 'C:\\Users\\u\\.bun\\bin\\bun.exe');
+ const base = { platform: 'win32', isBun: false, execPath: 'node.exe', isFile };
+ expect(resolveBunExecutable({ ...base, env: { BUN_INSTALL: 'C:\\bi', PATH: 'C:\\p' } })).toBe('C:\\bi\\bin\\bun.exe');
+ expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\p' } })).toBe('C:\\p\\bun.exe');
+ expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\none', USERPROFILE: 'C:\\Users\\u' } })).toBe('C:\\Users\\u\\.bun\\bin\\bun.exe');
+ });
+
+ test('falls back to bare bun when no bun.exe exists', () => {
+ expect(resolveBunExecutable({
+ platform: 'win32', isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false,
+ })).toBe('bun');
+ });
+
+ test('spawnTerminalAgent spawns through the resolver, not a bare bun', () => {
+ const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/terminal-agent-control.ts'), 'utf8');
+ const spawnSite = source.slice(source.indexOf('export function spawnTerminalAgent'));
+ expect(spawnSite).toContain('spawn([resolveBunExecutable(), \'run\'');
+ expect(spawnSite).not.toMatch(/spawn\(\[\s*'bun'/);
+ });
+});
+
+describe('findWindowsBunExe is the one bun.exe lookup', () => {
+ test('returns null, not a bare bun, when no bun.exe is installed', () => {
+ expect(findWindowsBunExe({ isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false })).toBeNull();
+ });
+
+ test('the native cookie-import worker finds an npm-global bun.exe through the same lookup', () => {
+ const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm';
+ expect(findWindowsBunExe({
+ isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe', env: { PATH: npm },
+ isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`),
+ })).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`);
+ });
+
+ test('cookie-import-native has no bun.exe candidate list of its own', () => {
+ const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/cookie-import-native.ts'), 'utf8');
+ expect(source).toContain('const bunExecutable = findWindowsBunExe();');
+ expect(source).not.toContain("'bun.exe'");
+ });
+});
diff --git a/docs/memorable-workflow-memory.md b/docs/memorable-workflow-memory.md
index 0fe7021c8..9cf8ba715 100644
--- a/docs/memorable-workflow-memory.md
+++ b/docs/memorable-workflow-memory.md
@@ -94,10 +94,11 @@ It refuses the hand-off, with one rate-limited line in
- the prompt carries a HIGH-tier credential shape (checked on the raw bytes
and on the decoded string values, so a JSON-escaped key does not slip by),
or is larger than 1 MiB;
-- the repo's per-remote trust policy is `deny` or `read-only` (judged by the
- session's working directory, so a session that touches other repositories
- is not covered), or that policy could not be looked up at all (git could
- not read the repository, the store is unreadable): the lookup fails closed;
+- the repo's per-remote trust policy is `deny`, or `read-only` without the
+ recall opt-in below (judged by the session's working directory, so a
+ session that touches other repositories is not covered), or that policy
+ could not be looked up at all (git could not read the repository, the store
+ is unreadable or corrupt): the lookup fails closed;
- the receipt cannot be written, or the hook's 4.5 s budget cannot afford the
next step (the secret scan of a very large prompt, or the vendor spawn).
@@ -163,6 +164,34 @@ whatever it stored are untouched: `memorable disable` stops capture,
The entry also comes out with `gstack-uninstall` (named in its summary) and
survives `./setup --no-team`, which only tears down team-mode hooks.
+## Recall in a read-only repo (per-remote opt-in)
+
+`read-only` keeps a repo's content out of the brain, and by default it also
+keeps that repo's prompts away from Memorable. If you chose `read-only` only
+to stop code ingest, and you have no objection to Memorable seeing prompts
+from that repo, opt that one remote in:
+
+```bash
+gstack-gbrain-repo-policy memorable set github.com/acme/client-a allow
+gstack-gbrain-repo-policy memorable get github.com/acme/client-a # read-only allow
+gstack-gbrain-repo-policy memorable set github.com/acme/client-a off
+```
+
+The opt-in is off by default and covers only the remote you name (the SSH and
+HTTPS spellings of it are one key). It is refused on a `deny` remote, and a
+`deny` vetoes recall even if an opt-in was written into the store by hand.
+Changing the remote's tier clears it, so moving a repo back to `read-only`
+later never silently re-opens recall. When `~/.gstack` also makes the remote
+read-only (see [state roots](state-root.md)), that store has to opt it in too.
+A corrupt, empty or unreadable policy store (or a dangling symlink in its
+place) skips recall for every repo until you fix it; the hook never resets it
+to "no policy" and never rewrites it. The lookup checks every configured
+`remote.origin.url`, not the URL after `url.*.insteadOf` rewriting, and any
+denied or not-opted-in read-only one skips recall. `gstack-gbrain-repo-policy list` shows
+opted-in remotes with a `memorable=allow` column. Nothing about gbrain
+ingest changes: the repo stays `read-only` for `/sync-gbrain` and every
+other gbrain writer.
+
## If you run gbrain
Memorable can keep its procedures in your own gbrain database instead of its
@@ -189,7 +218,7 @@ and `/sync-gbrain` are unrelated to it.
(roughly 4 seconds after gstack's own work). That is usually network.
- **`hook-errors.log` names a refusal.** `refused:redaction-high` means a
credential shape was in the prompt; `trust policy ... deny or read-only`
- means the repo is protected; `receipt-unwritable` means
+ means the repo is protected (see the per-remote opt-in above); `receipt-unwritable` means
`~/.gstack/security` is not writable.
- **`enable` says the stable install predates this bridge.** Run `./setup`
(or `/gstack-upgrade`) so the hook registered at `~/.claude/skills/gstack`
diff --git a/docs/pregate.md b/docs/pregate.md
index 22a661b98..216baf97c 100644
--- a/docs/pregate.md
+++ b/docs/pregate.md
@@ -19,7 +19,8 @@ Three bins, all over `lib/`:
A pin file names commands. Its presence never authorizes execution: both tools
read the committed copy on `origin/` (a PR cannot smuggle a command into
-its own gate) and run commands only with `--allow-repo-commands`, which `/ship`
+its own gate; `gstack-pregate run` alone also reads a user-owned file outside
+the repository when the base has none, see [pregate v1](#pregate-v1)) and run commands only with `--allow-repo-commands`, which `/ship`
passes. Standalone, `gstack-regen check` lists the commands and exits 3
(`REPO_COMMANDS_NOT_ALLOWED`); `gstack-pregate` marks `regen` and `lanes`
`incomplete`, never `pass`. `--policy-from head` (a PR that introduces or
@@ -59,12 +60,13 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| Key | Meaning |
|---|---|
-| `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH); nothing detected, a Dart SDK missing from PATH, or a `.dart` file with no `pubspec.yaml` above it is `incomplete` (the runnable groups still run, so a real failure still fails) |
+| `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH; `cargo test -p ` per Rust crate, run from the root of the workspace the crate is a member of (Cargo's `members` globs, `exclude` and `package.workspace`), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its `test` script) runs from that package, with package-relative paths, through the package manager its `packageManager` field or the nearest lockfile up to the repo root names: `pnpm exec`, `yarn`, `bun x`, else `npx`; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a `.dart` file with no `pubspec.yaml` above it, a `.rs` file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, `_` and `-`), is `incomplete` (the runnable groups still run, so a real failure still fails) |
| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences) |
| `empty_lanes` | extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a `join()` of path segments, an import, or a `dependencies` entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without `.md`, and so does licence text under any vendored name (`roboto-LICENSE.txt`, `LICENSE-MIT`, `licenses/*.txt`). Source code and files under a test directory never qualify. A test that reads the file still selects that test |
| `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) |
| `warn` | check ids downgraded to warn; `secrets` and required lanes never |
| `strays.allow` | intentional root files or new top-level directories |
+| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no workflow lane |
| `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) |
| `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)) |
@@ -72,8 +74,36 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides (see [supervision](#supervision)) |
| `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)) |
-Only the `origin/` copy is honoured, so a downgrade is reviewed before it
-takes effect. `gstack-pregate validate` / `init` cover the working-tree file.
+Only the `origin/` copy or the user-owned file below is honoured, so a
+downgrade is reviewed (or is the user's own) before it takes effect. A repo that
+keeps `.gstack/` out of git (a shared repo where gstack is one contributor's
+tool) can hold the same file at `/projects//pregate.json`. The
+state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`,
+else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `` is what
+`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository
+(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none
+(never under `--policy-from head`), it lives outside the repository so a PR still
+cannot reach it, and the record names it (`policy user `) and fingerprints
+its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
+ship receipt treat an edited or deleted user file, or one that appears (or a
+link on its path that starts pointing nowhere) after a run under the defaults,
+as `PREGATE_STALE`. A path that is relative, resolves inside the repository, or
+reaches it through any symlink on the way (a state root linked into the
+checkout, or a link hop inside it, even when the last hop points back outside)
+is ignored, since a PR could commit it or choose the file; `--explain` says
+so, or says `(absent)` when no file is there. The slug and state root come from
+the caller's environment, never the checkout's `.env`: the bin's shebang passes
+`--no-env-file`, and a bare `bun gstack-pregate` launched beside any `.env` file
+refuses (`POLICY_SOURCE_UNAVAILABLE`). `verify` reads both paths from the
+record, not from its own environment.
+It covers `gstack-pregate run` only: `sealed` and `price_file` are read from the
+base branch's copy, so the user file refuses them (`PREGATE_CONFIG_INVALID`)
+rather than holding a policy nothing reads. A user file that exists but cannot
+be read, or a symlink on its path that points nowhere, refuses the same way
+instead of falling back to defaults. A base copy git cannot read (and
+`git ls-tree` cannot confirm is absent) refuses with `POLICY_SOURCE_UNAVAILABLE`
+instead of handing the run to the user file.
+`gstack-pregate validate` / `init` cover the working-tree file.
`gstack-pregate init` writes a draft built from the repo, not a fixed example.
It prints the languages it found and the runner each language's tests would
@@ -115,13 +145,15 @@ Two stages with separate clocks, both printed:
`TIMING_ASSERTIONS: found= escaped= files=` summary. It warns
(`PREGATE_TIMING_ASSERTION`); it does not block. See [timing](#timing).
-- `guards`: each guard in `.gstack/pregate.json` whose `when` globs match a
+- `guards`: each guard in the active policy (`.gstack/pregate.json`, or the
+ user-owned file) whose `when` globs match a
touched file (renames included) runs its `command` in its own process, under
the verified launch Bun, with its own `timeout_s` (default `--timeout`). A
non-zero exit fails pregate naming the guard and its `fix`
(`PREGATE_GUARD_FAILED`); a timeout is `incomplete`; without
`--allow-repo-commands` the selected guards are listed and the check is
- `incomplete`. Guards are read from the base branch's copy and cannot be
+ `incomplete`. Guards are read from the base branch's copy (or the
+ user-owned file when the base carries none) and cannot be
downgraded to warn. See [guards](#guards).
**Stage 2, test execution:**
@@ -135,7 +167,13 @@ Two stages with separate clocks, both printed:
one has no lane until you declare `"**/integration_test/**": []` (and
`"**/test_driver/**": []`) under `dependencies` and let CI run them; Dart files, device tests
included, are source code, so a `dependencies` `[]` declaration clears them and `empty_lanes`
- does not), transitively through
+ does not); in a Rust crate (the nearest Cargo.toml with a `[package]` name) a `.rs` file is a
+ test when it sits under `/tests/`, holds a `#[cfg(test)]` (any cfg predicate naming
+ `test`) or a test attribute (`#[test]`, `#[tokio::test]`), and every `.rs` file of the crate and its Cargo.toml select the crate's tests and
+ those of every crate whose manifest depends on it, directly or through other crates (cargo
+ runs a crate's tests, not a file, so the lane is the crate; a crate with no tests still has
+ no lane, and a workspace-only Cargo.toml or Cargo.lock needs a `dependencies` entry),
+ transitively through
non-test files; deleted and renamed files select the tests that named them.
An extensionless JS/TS import (relative, a tsconfig/jsconfig `paths` alias or
`baseUrl`) tries the exact path, then `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`,
@@ -172,9 +210,10 @@ Two stages with separate clocks, both printed:
that step's `working-directory`, else the job's or workflow's
`defaults.run.working-directory`): local ones run through the runner under
`set -o pipefail`, judged on the runner's summary line (zero tests ran is a
- failure, a `| tail`-hidden exit is still an exit); a platform lane this
+ failure, a `| tail`-hidden exit is still an exit; cargo's `test result:` lines, one per
+ test binary and doc-tests included, are summed); a platform lane this
machine cannot run becomes a `requires-remote` obligation naming
- `workflow.yml/job`. `--explain` prints each test's provenance and every
+ `workflow.yml/job`, unless `local` names that lane. `--explain` prints each test's provenance and every
uncertain mapping (a planner-computed matrix without a `selection` manifest
selects conservatively). Test files matching `serial_patterns` (default
`**/*.serial.test.*`) run one process per file after the unit files. The
@@ -187,7 +226,7 @@ Two stages with separate clocks, both printed:
runner process (the unit-file group, each serial file, each guard without
its own `timeout_s`), not their total; the stall timeout catches a hung or
silent one sooner.
-- Under 8 cores, a base-branch `remote_gate` or a remote `test_backend`
+- Under 8 cores, a `remote_gate` in the active policy or a remote `test_backend`
(`gstack-config set test_backend ubicloud`) records the local test tier as a
`requires-remote` obligation instead of running it: see [remote gate](#remote-gate).
@@ -234,7 +273,7 @@ test-tier command:
Per-file lines come from a progress adapter where the runner's reporter
provides them: `bun test` (its `:` headers) is the only adapter today;
a one-file run (each serial file) reports its file from the launch for any
- runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test) get heartbeats
+ runner. Other runners (node --test, vitest, jest, pytest, 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
@@ -253,8 +292,8 @@ misses a descendant that was reparented or started detached.
## Small machines and the remote gate
-Under 8 cores (`os.availableParallelism()`), when the base branch's
-`.gstack/pregate.json` declares `remote_gate` or `gstack-config get
+Under 8 cores (`os.availableParallelism()`), when the active policy (the
+base branch's `.gstack/pregate.json`, or the user-owned file) declares `remote_gate` or `gstack-config get
test_backend` is not `local`, the lanes check runs nothing locally: it records
the selected local files as a `requires-remote` obligation with the command to
run, `pregate.json/remote_gate` (the declared command) or
diff --git a/docs/ship-policy.md b/docs/ship-policy.md
index 316018092..7901da94a 100644
--- a/docs/ship-policy.md
+++ b/docs/ship-policy.md
@@ -337,7 +337,9 @@ gstack-ship-queue train build --prs 41@,42@,43@
It prints `ORDER:` (the slot order to announce: screened PRs first in READY
order, then unscreened; when `max_size` or more unscreened PRs wait they form
-their own train and `HELD:` lists them), one `PREFIX: T
+their own train and `HELD:` lists them; `--order ,,...` naming every
+pinned PR once replaces that rule, for a PR that must merge before another,
+and the train's later `NEXT:` rebuilds keep it), one `PREFIX: T
tree=` per prefix and one `SLOT:` per PR. `--dry-run` adds the
`ESTIMATE:` line and writes, pushes and dispatches nothing. Otherwise it
writes `train.json`, pushes `train/`, dispatches `free_workflow` there
@@ -355,7 +357,11 @@ merging #,#; nothing dispatched, no branch pushed; fix: …` line names
the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red,
missing, cancelled or on another SHA). In a merge, version files take the
prefix's side and the CHANGELOG merges by section, since the stamp at merge
-rewrites both.
+rewrites both; a PR whose CHANGELOG drops or renames a released heading (or
+carries a released entry's text in a section of its own) is a CHANGELOG
+conflict, never merged with the damage. READY receipts and train records are
+keyed on origin's GitHub owner/repo, so lanes whose origin uses another URL
+form (git.capy.ai, ssh, a token URL) share the coordinator's queue.
**3. Coordinator: status and verdict.**
@@ -406,13 +412,13 @@ EVAL_POLICY records it. Bisect results never turn a train green.
```bash
git fetch origin main
git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only
-bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
+bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3), s(1)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
git add CHANGELOG.md && git commit --no-edit
gstack-restamp --next --base main --pr 42 --expect-base --expect-order --allow-repo-commands
git add CHANGELOG.md VERSION package.json && git commit -m "v stamp" && git push
```
-A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. `verify` accepts the resulting head because its tree equals T modulo stamps; the `head:` line says "T with main merged in".
+A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. When the helper says the CHANGELOG needs a hand merge, take main's CHANGELOG unchanged and put your own entry above it. `verify` accepts the resulting head because it contains main and its tree equals T modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the `head:` line says "T with main merged in".
**5. Coordinator: verify, merge, verify again.** For each slot in order:
@@ -423,8 +429,9 @@ gstack-ship-queue verify --pr 41 --train # VERIFY_TRAIN: #41 merged; m
```
Before a merge, main's tree must equal the prefix before the PR modulo stamps
-and the PR must be at its pin (stamps aside) or at T modulo stamps (its pin
-with main merged in); after it, main's tree
+and the PR must be at its pin (stamps aside) or contain main and be T
+modulo stamps (its pin with main merged in; its CHANGELOG must be main's with
+only lines from the pin's own CHANGELOG diff above it); after it, main's tree
must equal the prefix after it. Anything else is `QUEUE_TRAIN_TREE_MISMATCH`
(or `QUEUE_TRAIN_MOVED`). When nothing of the train has merged, that is a plain
refusal: build a new train on current main. When earlier slots already
@@ -471,6 +478,20 @@ which carry the workflow consequences the skill prose would otherwise spell out:
The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`),
written by the pr-body section of `/ship` after the body is composed.
+## What `/land-and-deploy` reads from it
+
+Step 3.4 runs `show-effective --base ` before the merge and prints one
+`VERSION_CHECK:` verdict for the PR head:
+
+| Policy | Stops when | Passes |
+|---|---|---|
+| `queue_mode: stamp-at-merge` | the head's VERSION is not the base's version plus one bump, or the changelog's top two headings at the head are not that version and then the base's top entry (`STOP stamp stale`: run `gstack-restamp --next` on the branch, push the stamp, rerun) | an exact stamp; other open PRs' tentative stamps are ignored |
+| `queue_mode: claim-at-open` (and no policy file) | another open PR holds the same VERSION, or the base is already at or above it (`VERSION drift detected`: rerun `/ship`) | any unique VERSION above the base, including a MICRO bump or one below a sibling's claim |
+
+An invalid policy, an unreadable claim list or a missing VERSION is `unavailable`:
+the skill reports it and CI's version gate is the backstop. A base at or above the
+branch VERSION still stops.
+
## `queue_mode: stamp-at-merge`
diff --git a/docs/state-root.md b/docs/state-root.md
index 5cd662ee5..45c246ec6 100644
--- a/docs/state-root.md
+++ b/docs/state-root.md
@@ -104,6 +104,9 @@ lives in `projects//`. `bin/gstack-slug` picks the slug from the git
and the ssh, https and ssh-with-port spellings of one repository share one.
- Without a remote, the project root's directory name is used.
+A bucket can also hold a user-owned `pregate.json` policy, for a repo that keeps
+`.gstack/` out of git (see [pregate v1](pregate.md#pregate-v1)).
+
The first slug gstack resolves in a directory sticks (it is cached under
`slug-cache/`), so a project keeps its bucket when it adds a remote later.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 48d90fcf2..f9ca4cef2 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -170,8 +170,10 @@ the readiness dashboard and the PR body, and never count it as a pass.
findings.
- **Every review gate.** These rules apply to every gate that summarises a
review, including the `execution` gate behind `/codex` consult and challenge.
- A reply whose last `Verdict:` line says `not ready` but carries no readable
- severity prints `FINDINGS: unspecified` and exits 3 like a blocking finding.
+ A reply whose last `Verdict:` line says `not ready` is `findings` and exits
+ 3 even when readable advisory severities (P2 or P3) were parsed; `FINDINGS:`
+ names the highest one. A not-ready reply with no readable severity still
+ prints `FINDINGS: unspecified` and exits 3.
- **No findings.** A review with no tag and no label is `clean` only when its
conclusion says so: the last sentence (or its last ", and" clause), or the
`because` clause of its `Recommendation:` line, opens with `NO_FINDINGS`,
@@ -2472,7 +2474,13 @@ file is described in [docs/ship-policy.md](ship-policy.md); the queue flow in
**Meaning.** The policy is read from the committed copy on `origin/`,
and that ref does not resolve here (a shallow or detached gate checkout, or a
fetch that never happened). The tool exits 3 and never falls back to the
-working-tree file silently.
+working-tree file silently. For `gstack-pregate` the same refusal covers a base
+`.gstack/pregate.json` git cannot read (a timeout, an oversized blob, a missing
+or corrupt object) that `git ls-tree` cannot confirm is absent; it never falls
+through to the user-owned file. It also refuses when Bun loaded a `.env` file
+from the working directory (a bare `bun gstack-pregate`), since a branch could
+then pick the user policy: run `gstack-pregate` directly or as
+`bun --no-env-file`.
**Fix.** `git fetch origin --depth=1`, then rerun. To run against the
branch's own file deliberately, pass `--policy-from head --allow-repo-commands`;
@@ -2875,7 +2883,11 @@ or `REALITY: surface-check finding bun upgrade --version absent on 1.3.14 bin/gs
Both tools read their pin file (`.gstack/generated.json`, `.gstack/pregate.json`)
from `origin/`, never the working tree, and run repo-declared commands only
-under `--allow-repo-commands` or inside `/ship`. Details: docs/pregate.md.
+under `--allow-repo-commands` or inside `/ship`. When the base carries no
+`.gstack/pregate.json`, `gstack-pregate run` reads
+`/projects//pregate.json` instead (outside the repository; see
+docs/pregate.md for the state root and slug).
+Details: docs/pregate.md.
### `REGEN_REGISTRY_INVALID`
@@ -2930,10 +2942,16 @@ repository's lockfile), then rerun `gstack-regen check` or the pre-gate.
**Meaning.** `.gstack/pregate.json` has an unknown key, a wrong type, a glob
that escapes the repository, a runner without `{files}`, or `secrets` in `warn`.
+The user-owned `/projects//pregate.json` also refuses when it
+sets `sealed` or `price_file` (both are read only from the base branch's copy)
+or exists but cannot be read (a dangling symlink on its path counts).
**Fix.** `gstack-pregate validate`. `secrets` and required-lane obligations
cannot be downgraded; every other check id in `warn` is honoured only from the
-base branch's copy.
+base branch's copy or the user-owned file. For the user-owned file, edit it
+directly at the path the message names. A user file inside the repository, or
+reached through a symlink into it, is ignored rather than read (`--explain`
+names it).
### `PREGATE_SECRETS`
@@ -2978,7 +2996,7 @@ allowlisted tool config no test names, are the assets-and-tool-config empty lane
not this error.
**Fix.** Add a test that names the file, or declare its lane (an explicit `[]`
-for files with no behaviour) under `dependencies` in `.gstack/pregate.json`.
+for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or the user-owned file the message names).
Other non-Markdown documentation goes in `empty_lanes` on the base branch.
Dart files under `integration_test/` or `test_driver/` need a device, so pregate
never runs them: declare `"**/integration_test/**": []` (and
@@ -3017,7 +3035,7 @@ declare `runner`), raise `--timeout`, or keep the tree still, then rerun.
**Meaning.** A test command the lanes check (or a guard, or the `--remote`
gate) launched wrote nothing to its log for the stall timeout (default 300 s,
-`stall_timeout_s` in the base branch's `.gstack/pregate.json`, or
+`stall_timeout_s` in the base branch's `.gstack/pregate.json` or the user-owned file, or
`--stall-timeout`). pregate killed its process group and names the last test
file started. The check is incomplete, never pass.
@@ -3045,7 +3063,10 @@ file with `--receipt`, or let `gstack-pregate --stage tests --remote --spend-cap
### `PREGATE_STALE`
**Meaning.** `pregate.json` was recorded against a different working-tree
-fingerprint than the one being published (the final stamp, a regen, any edit).
+fingerprint than the one being published (the final stamp, a regen, any edit),
+or the user-owned policy it ran under was edited or deleted (or one appeared,
+or a symlink on its path started pointing nowhere, after a run under the
+defaults).
**Fix.** Rerun `gstack-pregate` after the final generation and stamp; Step 18
refuses to write the ship receipt otherwise.
@@ -3274,8 +3295,9 @@ next `./setup` prints no CRLF warning.
### `PREGATE_GUARD_FAILED`
-**Meaning.** A guard from the base branch's `.gstack/pregate.json` (a repo
-command such as a serial-test manifest check or a CLI golden test) matched a
+**Meaning.** A guard (a repo command such as a serial-test manifest check or a
+CLI golden test) from the base branch's `.gstack/pregate.json`, or from the
+user-owned file when the base carries none, matched a
file the branch touched and exited non-zero. Guards cover what the import graph
cannot see: manifests, subprocess goldens, registries. They cannot be
downgraded to warn.
diff --git a/health/SKILL.md b/health/SKILL.md
index 8eb500741..1078ec6d5 100644
--- a/health/SKILL.md
+++ b/health/SKILL.md
@@ -500,7 +500,7 @@ Score each category on a 0-10 scale using this rubric:
| Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass |
| Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused |
| Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) |
-| GBrain | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) |
+| GBrain | 10% | Computed in code: see **GBrain sub-score computation** below | — | — | N/A (gbrain not installed) |
**Parsing tool output for counts:**
Use the complete captured output, not the displayed tail. A zero match count cannot
@@ -534,17 +534,28 @@ a numeric composite, and skip numeric history persistence and trend calculation.
**GBrain sub-score computation:**
-```
-doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok";
- 7 if "warnings"; 0 otherwise (or command times out after 5s).
-queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines;
- 7 if 10-100; 0 if >=100 (suggests secret-scan rejections
- piling up). N/A if artifacts_sync_mode == off.
-push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h;
- 7 if <72h; 0 if >=72h. N/A if artifacts_sync_mode == off.
-gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component
- (redistribute 0.3 + 0.2 into doctor when sync_mode is off:
- gbrain_score = doctor_component in that case)
+Compute the GBrain score in code, not by hand, and pass the printed
+`GBRAIN_SCORE` as `gbrain` to the composite above. Gather three readings:
+
+- **doctor:** 10 when `gbrain doctor --json` reports `status` `ok`, 7 for
+ `warnings`, 0 for anything else or when the call times out after 5s.
+- **queue:** the line count of `~/.gstack/.brain-queue.jsonl` (0 when absent).
+ A long queue suggests secret-scan rejections piling up.
+- **pushHours:** hours since the mtime of `~/.gstack/.brain-last-push`.
+
+Pass `null` for both queue and pushHours when `artifacts_sync_mode` is `off`;
+the doctor component then carries the whole score:
+
+```bash
+bun --no-env-file --config=/dev/null -e '
+const doctor = ;
+const queue = ;
+const pushHours = ;
+const d = doctor ?? 0;
+const band = (v, ten, seven) => (v < ten ? 10 : v < seven ? 7 : 0);
+const score = queue === null || pushHours === null ? d : 0.5 * d + 0.3 * band(queue, 10, 100) + 0.2 * band(pushHours, 24, 72);
+console.log("GBRAIN_SCORE: " + score.toFixed(1));
+'
```
The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung
@@ -625,7 +636,8 @@ Fields:
- `ts` -- ISO 8601 timestamp
- `branch` -- current git branch
- `score` -- composite score (one decimal)
-- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10)
+- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10)
+- `gbrain` -- the printed `GBRAIN_SCORE` (0-10, one decimal)
- `duration_s` -- total time for all tools in seconds
If a category was skipped, set its value to `null`. Treat a history entry
diff --git a/health/SKILL.md.tmpl b/health/SKILL.md.tmpl
index c16434508..47f83551e 100644
--- a/health/SKILL.md.tmpl
+++ b/health/SKILL.md.tmpl
@@ -177,7 +177,7 @@ Score each category on a 0-10 scale using this rubric:
| Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass |
| Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused |
| Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) |
-| GBrain | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) |
+| GBrain | 10% | Computed in code: see **GBrain sub-score computation** below | — | — | N/A (gbrain not installed) |
**Parsing tool output for counts:**
Use the complete captured output, not the displayed tail. A zero match count cannot
@@ -211,17 +211,28 @@ a numeric composite, and skip numeric history persistence and trend calculation.
**GBrain sub-score computation:**
-```
-doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok";
- 7 if "warnings"; 0 otherwise (or command times out after 5s).
-queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines;
- 7 if 10-100; 0 if >=100 (suggests secret-scan rejections
- piling up). N/A if artifacts_sync_mode == off.
-push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h;
- 7 if <72h; 0 if >=72h. N/A if artifacts_sync_mode == off.
-gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component
- (redistribute 0.3 + 0.2 into doctor when sync_mode is off:
- gbrain_score = doctor_component in that case)
+Compute the GBrain score in code, not by hand, and pass the printed
+`GBRAIN_SCORE` as `gbrain` to the composite above. Gather three readings:
+
+- **doctor:** 10 when `gbrain doctor --json` reports `status` `ok`, 7 for
+ `warnings`, 0 for anything else or when the call times out after 5s.
+- **queue:** the line count of `~/.gstack/.brain-queue.jsonl` (0 when absent).
+ A long queue suggests secret-scan rejections piling up.
+- **pushHours:** hours since the mtime of `~/.gstack/.brain-last-push`.
+
+Pass `null` for both queue and pushHours when `artifacts_sync_mode` is `off`;
+the doctor component then carries the whole score:
+
+```bash
+bun --no-env-file --config=/dev/null -e '
+const doctor = ;
+const queue = ;
+const pushHours = ;
+const d = doctor ?? 0;
+const band = (v, ten, seven) => (v < ten ? 10 : v < seven ? 7 : 0);
+const score = queue === null || pushHours === null ? d : 0.5 * d + 0.3 * band(queue, 10, 100) + 0.2 * band(pushHours, 24, 72);
+console.log("GBRAIN_SCORE: " + score.toFixed(1));
+'
```
The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung
@@ -301,7 +312,8 @@ Fields:
- `ts` -- ISO 8601 timestamp
- `branch` -- current git branch
- `score` -- composite score (one decimal)
-- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10)
+- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10)
+- `gbrain` -- the printed `GBRAIN_SCORE` (0-10, one decimal)
- `duration_s` -- total time for all tools in seconds
If a category was skipped, set its value to `null`. Treat a history entry
diff --git a/hosts/claude/hooks/memorable-user-prompt-hook.ts b/hosts/claude/hooks/memorable-user-prompt-hook.ts
index 26cd1925a..3f01d7d01 100644
--- a/hosts/claude/hooks/memorable-user-prompt-hook.ts
+++ b/hosts/claude/hooks/memorable-user-prompt-hook.ts
@@ -10,7 +10,8 @@
* and clean removal. This file is that mediation.
*
* stdin JSON -> cap 1 MiB -> parse -> MEMORABLE=0? -> gate memorable_recall == on?
- * -> win32? -> trust policy (deny / read-only veto, by session cwd; fail-closed)
+ * -> win32? -> trust policy (deny / read-only veto, by session cwd; fail-closed;
+ * a read-only remote's explicit per-remote opt-in lifts it, deny never)
* -> HIGH-tier secret scan (raw bytes AND decoded string leaves, each admitted by the clock)
* -> resolve vendor -> budget >= 500 ms? -> gate re-check
* -> receipt (fail-closed: no receipt, no send)
@@ -57,7 +58,7 @@ import {
} from '../../../lib/egress-receipt';
import { wrapUntrustedTrackerContent } from '../../../lib/tracker-guard';
import { scan } from '../../../lib/redact-engine';
-import { hasRepoPolicyStore, repoPolicyTier } from '../../../lib/gbrain-repo-policy-client';
+import { hasRepoPolicyStore, memorableRecallPolicy } from '../../../lib/gbrain-repo-policy-client';
import { LOG_RATE_LIMIT_MS, logHookError as sharedLogHookError } from './hook-log';
export { LOG_RATE_LIMIT_MS };
@@ -393,13 +394,30 @@ async function policyVeto(cwd: string, timeoutMs: number, remaining: () => numbe
if (git.status === GIT_NO_REMOTE) return 'ok';
if (git.status === GIT_FATAL && NOT_A_REPO_RE.test(git.stderrTail.trim())) return 'ok';
if (git.status !== 0) return 'error';
- const url = git.stdout.toString('utf8').trim();
- if (!url) return 'ok';
- const res = repoPolicyTier(url, process.env, Math.max(1, Math.min(STAGE_CAP_MS, remaining())));
- if (res.error) return 'error';
- // `deny` and `read-only` are the tiers a user picks so a repo's content
- // never lands in a shared store; a third-party memory service is one.
- return res.tier === 'deny' || res.tier === 'read-only' ? 'skip' : 'ok';
+ if (!git.stdout.toString('utf8').trim()) return 'ok';
+ // The store is keyed by the configured URLs (what `gstack-gbrain-repo-policy
+ // get` and /sync-gbrain read), not the url.*.insteadOf rewrite that
+ // `remote get-url` applies, and every configured origin URL is checked:
+ // neither a rewrite nor an added URL may dodge a deny.
+ const raw = await runExternal('git', ['config', '--null', '--get-all', 'remote.origin.url'], {
+ cwd, timeoutMs: Math.max(1, Math.min(timeoutMs, remaining())), maxBuffer: GIT_MAX_BUFFER, env: gitEnv(process.env),
+ onSpawn: (kill) => { killInflight = kill; },
+ });
+ killInflight = null;
+ if (raw.timedOut || raw.error || raw.status !== 0) return 'error';
+ const urls = raw.stdout.toString('utf8').split('\0').filter((u) => u !== '');
+ if (urls.length === 0) return 'error';
+ for (const url of urls) {
+ const res = memorableRecallPolicy(url, process.env, Math.max(1, Math.min(STAGE_CAP_MS, remaining())));
+ if (res.error) return 'error';
+ // `deny` and `read-only` are the tiers a user picks so a repo's content
+ // never lands in a shared store; a third-party memory service is one.
+ // Only an explicit per-remote opt-in (`gstack-gbrain-repo-policy memorable
+ // set allow`) lifts the read-only veto; nothing lifts deny.
+ if (res.tier === 'deny') return 'skip';
+ if (res.tier === 'read-only' && !res.memorableOptIn) return 'skip';
+ }
+ return 'ok';
}
function writeStdout(text: string): Promise {
@@ -446,7 +464,7 @@ export async function main(): Promise {
const payloadCwd = (payload as { cwd?: unknown }).cwd;
const cwd = typeof payloadCwd === 'string' && isDirectory(payloadCwd) ? payloadCwd : process.cwd();
const veto = await policyVeto(cwd, Math.min(STAGE_CAP_MS, remaining()), remaining);
- if (veto === 'skip') { logHookError(`trust policy for ${cwd} is deny or read-only, recall skipped`, Date.now(), 'trust policy skip'); return; }
+ if (veto === 'skip') { logHookError(`trust policy for ${cwd} is deny or read-only, recall skipped (a read-only remote can opt in: gstack-gbrain-repo-policy memorable set allow)`, Date.now(), 'trust policy skip'); return; }
if (veto === 'error') { logHookError('trust policy lookup failed (store or repository unreadable), recall skipped (fail-closed)'); return; }
// HIGH-tier pre-scan over the raw text and the decoded string leaves (a JSON
diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md
index dfd43a37a..c985f5975 100644
--- a/ios-qa/SKILL.md
+++ b/ios-qa/SKILL.md
@@ -593,10 +593,10 @@ If the user says "demo", "demo mode", "show me", or "I want to see it
working", run in **DEMO MODE**. This changes how the agent interacts with
the app:
-**DEMO MODE OVERRIDES ALL OTHER RULES.** When demo mode is active, the
-agent MUST drive every action through visible UI (`/tap`, `/swipe`, `/type`)
-and NEVER use `POST /state/*` writes to skip steps. Viewers see the agent
-type every key, tap every button. The on-device DebugOverlay attribution
+In demo mode, drive every action through visible UI (`/tap`, `/swipe`,
+`/type`) rather than `POST /state/*` writes, because viewers watch each
+step: they see the agent type every key and tap every button. All other
+rules in this skill still apply. The on-device DebugOverlay attribution
chip shows "Driven by Claude Code (demo)" or the remote agent identity.
In demo mode, the screencap rate is bumped to 4fps so the recording feels
diff --git a/ios-qa/SKILL.md.tmpl b/ios-qa/SKILL.md.tmpl
index 4c7b4a2c5..3c957a051 100644
--- a/ios-qa/SKILL.md.tmpl
+++ b/ios-qa/SKILL.md.tmpl
@@ -249,10 +249,10 @@ If the user says "demo", "demo mode", "show me", or "I want to see it
working", run in **DEMO MODE**. This changes how the agent interacts with
the app:
-**DEMO MODE OVERRIDES ALL OTHER RULES.** When demo mode is active, the
-agent MUST drive every action through visible UI (`/tap`, `/swipe`, `/type`)
-and NEVER use `POST /state/*` writes to skip steps. Viewers see the agent
-type every key, tap every button. The on-device DebugOverlay attribution
+In demo mode, drive every action through visible UI (`/tap`, `/swipe`,
+`/type`) rather than `POST /state/*` writes, because viewers watch each
+step: they see the agent type every key and tap every button. All other
+rules in this skill still apply. The on-device DebugOverlay attribution
chip shows "Driven by Claude Code (demo)" or the remote agent identity.
In demo mode, the screencap rate is bumped to 4fps so the recording feels
diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md
index 62d201db9..9c6571b01 100644
--- a/land-and-deploy/SKILL.md
+++ b/land-and-deploy/SKILL.md
@@ -708,35 +708,45 @@ approves each), or C) stop.
## Step 3.4: VERSION drift detection (workspace-aware ship)
-Check that another workspace has not claimed this PR's VERSION since `/ship`.
-
```bash
BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
-QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \
- --base "$BASE_BRANCH" \
- --exclude-pr "$PR_NUMBER" \
- --bump patch \
- --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
-NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
-OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
-NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false')
+git fetch -q origin "$BASE_BRANCH"
+read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json |
+ jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"')
+Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \
+ --exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
+Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version
+ then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not)
+ then "unavailable: claims unread" else "ok" end')
+cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; }
+H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl)
+V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$"
+ if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit }
+ for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit }
+ s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }')
+if [ "$Q" = NO_VERSION ]; then R=$Q
+elif [ "$V" = bad ]; then R="unavailable: bad version"
+elif [ "$MODE" = stamp-at-merge ]; then
+ [ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale"
+elif [ "$V" = low ]; then R="STOP base is at or above it"
+elif [ -z "$MODE" ]; then R="unavailable: no policy"
+else R=$Q; fi
+echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)"
```
-Use the existing conservative patch-level allocation; compare numeric version
-components, not lexical strings. A missing/unparseable version on only one side is
-unavailable, not green.
-
-0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads):
- print `VERSION check: not applicable ()` and continue to
- Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag.
-1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable
- with the reason; continue to Step 3.5. CI's version gate is the backstop.
-2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue.
-3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions
- and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path
- reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT
- auto-bump or merge here: duplicate versions can overwrite another branch's release notes.
+- `NO_VERSION`: print `VERSION check: not applicable ()`; never
+ expect a version-prefixed title, CHANGELOG version header or tag.
+- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop).
+- `ok`: continue; a higher sibling claim or a MICRO bump is not drift.
+- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings
+ are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base
+ --pr --expect-base --allow-repo-commands`, push the stamp diff,
+ wait for CI, rerun.
+- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship`
+ from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json,
+ CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can
+ overwrite another branch's release notes.
---
diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl
index 8b292abb2..6243389b1 100644
--- a/land-and-deploy/SKILL.md.tmpl
+++ b/land-and-deploy/SKILL.md.tmpl
@@ -191,35 +191,45 @@ approves each), or C) stop.
## Step 3.4: VERSION drift detection (workspace-aware ship)
-Check that another workspace has not claimed this PR's VERSION since `/ship`.
-
```bash
BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
-QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \
- --base "$BASE_BRANCH" \
- --exclude-pr "$PR_NUMBER" \
- --bump patch \
- --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
-NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
-OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
-NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false')
+git fetch -q origin "$BASE_BRANCH"
+read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json |
+ jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"')
+Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \
+ --exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
+Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version
+ then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not)
+ then "unavailable: claims unread" else "ok" end')
+cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; }
+H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl)
+V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$"
+ if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit }
+ for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit }
+ s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }')
+if [ "$Q" = NO_VERSION ]; then R=$Q
+elif [ "$V" = bad ]; then R="unavailable: bad version"
+elif [ "$MODE" = stamp-at-merge ]; then
+ [ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale"
+elif [ "$V" = low ]; then R="STOP base is at or above it"
+elif [ -z "$MODE" ]; then R="unavailable: no policy"
+else R=$Q; fi
+echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)"
```
-Use the existing conservative patch-level allocation; compare numeric version
-components, not lexical strings. A missing/unparseable version on only one side is
-unavailable, not green.
-
-0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads):
- print `VERSION check: not applicable ()` and continue to
- Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag.
-1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable
- with the reason; continue to Step 3.5. CI's version gate is the backstop.
-2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue.
-3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions
- and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path
- reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT
- auto-bump or merge here: duplicate versions can overwrite another branch's release notes.
+- `NO_VERSION`: print `VERSION check: not applicable ()`; never
+ expect a version-prefixed title, CHANGELOG version header or tag.
+- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop).
+- `ok`: continue; a higher sibling claim or a MICRO bump is not drift.
+- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings
+ are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base
+ --pr --expect-base --allow-repo-commands`, push the stamp diff,
+ wait for CI, rerun.
+- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship`
+ from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json,
+ CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can
+ overwrite another branch's release notes.
---
diff --git a/lib/changelog-check.ts b/lib/changelog-check.ts
index 03ce9a4e9..ae2d81333 100644
--- a/lib/changelog-check.ts
+++ b/lib/changelog-check.ts
@@ -115,6 +115,76 @@ export function normalizeChangelog(text: string): string {
return [parsed.header.join('\n').replace(/\n+$/, ''), ...sections].join('\n\u0000');
}
+/**
+ * A merged-in head's CHANGELOG, however its conflict was resolved: why it is
+ * not main's CHANGELOG with the PR's own entry above it, or null. Main's
+ * header and every main section must follow unchanged and in order, and the
+ * sections above them may hold only lines the PR itself added (`authored`,
+ * the + lines of its pin's CHANGELOG diff), so a hand resolve can drop
+ * damage but never add text the train gate did not carry.
+ */
+export function ownEntryAboveMain(head: string, main: string, authored: ReadonlySet): string | null {
+ const h = parseChangelog(head);
+ const m = parseChangelog(main);
+ const trim = (lines: string[]) => { const out = [...lines]; while (out.length && !out[out.length - 1]!.trim()) out.pop(); return out.join('\n'); };
+ if (trim(h.header) !== trim(m.header)) return 'the header differs from main';
+ const own = h.sections.length - m.sections.length;
+ if (own < 0) return `it has ${h.sections.length} sections, main has ${m.sections.length}`;
+ const moved = m.sections.findIndex((s, i) => trim(s.lines) !== trim(h.sections[own + i]!.lines));
+ if (moved >= 0) return `main's ${m.sections[moved]!.heading.trim()} is not carried unchanged below the PR's entry`;
+ const mainKeys = new Set(m.sections.map(s => s.version ?? 'Unreleased'));
+ const clash = h.sections.slice(0, own).find(s => mainKeys.has(s.version ?? 'Unreleased'));
+ if (clash) return `${clash.heading.trim()} is headed twice`;
+ const added = h.sections.slice(0, own).flatMap(s => s.lines.slice(1)).find(l => l.trim() && !authored.has(l));
+ return added === undefined ? null : `the PR's entry holds a line its pin never added: ${JSON.stringify(added.slice(0, 80))}`;
+}
+
+const MIN_BLOCK_CHARS = 12;
+
+/** Paragraph blocks of a section's entry text (heading lines dropped), long enough to identify one entry. */
+function entryBlocks(s: ChangelogSection): string[] {
+ const blocks: string[] = [];
+ let run: string[] = [];
+ const flush = () => { const text = run.join('\n').trim(); if (text.length >= MIN_BLOCK_CHARS) blocks.push(text); run = []; };
+ for (const line of s.lines.slice(1)) {
+ if (!line.trim() || line.startsWith('#')) flush();
+ else run.push(line);
+ }
+ flush();
+ return blocks;
+}
+
+/**
+ * Why one side of a CHANGELOG merge damaged released history, or null. A
+ * side damages it when it lacks a released heading the merge base carries
+ * (dropped, or renamed), or when a section only that side carries holds a
+ * paragraph of a released entry the other side carries (a released heading
+ * replaced, so its body reads as part of the section above it; #3345).
+ */
+export function changelogDamage(ours: string, theirs: string, base?: string): string | null {
+ const sides = [{ name: 'ours', parsed: parseChangelog(ours) }, { name: 'theirs', parsed: parseChangelog(theirs) }];
+ const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
+ if (base !== undefined) {
+ const released = parseChangelog(base).sections.filter(s => !s.unreleased).map(key);
+ for (const side of sides) {
+ const has = new Set(side.parsed.sections.map(key));
+ const missing = released.find(v => !has.has(v));
+ if (missing) return `${side.name} drops released heading ${missing} that the merge base carries`;
+ }
+ }
+ for (const [i, side] of sides.entries()) {
+ const other = sides[1 - i]!.parsed.sections;
+ const otherKeys = new Set(other.map(key));
+ const releasedText = new Map();
+ for (const s of other.filter(s => !s.unreleased)) for (const b of entryBlocks(s)) releasedText.set(b, s.version!);
+ for (const s of side.parsed.sections.filter(s => !otherKeys.has(key(s)))) {
+ const hit = entryBlocks(s).find(b => releasedText.has(b));
+ if (hit) return `${side.name}'s ${key(s)} section holds released ${releasedText.get(hit)} text (${JSON.stringify(hit.slice(0, 60))}); a released heading was dropped or renamed`;
+ }
+ }
+ return null;
+}
+
/**
* Gate-ahead merge of two CHANGELOGs that conflict textually but not
* semantically: the predecessor's header and sections, with the branch's own
@@ -122,9 +192,12 @@ export function normalizeChangelog(text: string): string {
* branch's top section carries the same version heading as a predecessor
* section with a different body (both took the same tentative stamp), the
* branch's entry is kept under `[Unreleased]` so the restamp re-heads it.
- * Both sides carrying `[Unreleased]` is a real conflict and returns null.
+ * Both sides carrying `[Unreleased]` is a real conflict and returns null, and
+ * so is damage to released history (`changelogDamage`): null means a hand
+ * resolve, never a merge that carries the damage through.
*/
-export function mergeChangelogs(ours: string, theirs: string): string | null {
+export function mergeChangelogs(ours: string, theirs: string, base?: string): string | null {
+ if (changelogDamage(ours, theirs, base)) return null;
const a = parseChangelog(ours);
const b = parseChangelog(theirs);
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
diff --git a/lib/cso/preparation.ts b/lib/cso/preparation.ts
index 26a68bc73..35612e9ee 100644
--- a/lib/cso/preparation.ts
+++ b/lib/cso/preparation.ts
@@ -5,8 +5,8 @@ import { createHash } from 'node:crypto';
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import { isAbsolute, relative, resolve, sep } from 'node:path';
-/** 'java' (Maven or Gradle) is detected so it is reported as itself; no runtime prepares it. */
-export type CsoStack = 'node' | 'bun' | 'python' | 'rails' | 'java';
+/** 'java' (Maven or Gradle) and 'go' (go.mod) are detected so they are reported as themselves; no runtime prepares them. */
+export type CsoStack = 'node' | 'bun' | 'python' | 'rails' | 'java' | 'go';
export interface PreparationPrerequisite {
code: string;
message: string;
@@ -1205,12 +1205,15 @@ export function inspectPreparation(snapshotPath: string, stack?: CsoStack): Prep
detectedStacks.push('node');
if (read(root, 'Gemfile.lock', true) !== undefined || read(root, 'Gemfile', true) !== undefined)
detectedStacks.push('rails');
- // A root Maven or Gradle build names the repository's language; Python
- // metadata beside it is helper tooling, not a second application stack.
+ // A root Maven or Gradle build, or a root go.mod with no other supported
+ // stack, names the repository's language; Python metadata beside it is
+ // helper tooling, not a second application stack.
const jvmBuild = ['pom.xml', 'build.gradle', 'build.gradle.kts'].find(
(file) => read(root, file, true) !== undefined,
);
if (jvmBuild) detectedStacks.push('java');
+ else if (detectedStacks.length === 0 && read(root, 'go.mod', true) !== undefined)
+ detectedStacks.push('go');
else if (
read(root, 'uv.lock', true) !== undefined ||
read(root, 'requirements.txt', true) !== undefined ||
@@ -1231,10 +1234,16 @@ export function inspectPreparation(snapshotPath: string, stack?: CsoStack): Prep
`Java (${jvmBuild ?? 'Maven or Gradle'}) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails.`,
jvmBuild,
);
+ if (detected === 'go')
+ fail(
+ 'UNSUPPORTED_STACK',
+ 'Go (go.mod) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails.',
+ 'go.mod',
+ );
if (!['node', 'bun', 'python', 'rails'].includes(detected))
fail('UNSUPPORTED_STACK', 'Supported runtime stacks are Node, Bun, Python, and Rails.');
({ node: inspectNode, bun: inspectBun, python: inspectPython, rails: inspectRails })[
- detected as Exclude
+ detected as Exclude
](root, plan);
} catch (error) {
plan.status = 'prerequisites';
diff --git a/lib/gbrain-guards.ts b/lib/gbrain-guards.ts
index 1c46b4ad4..0df4576e7 100644
--- a/lib/gbrain-guards.ts
+++ b/lib/gbrain-guards.ts
@@ -17,9 +17,12 @@
* subcommand `--help` is generic — so capability detection is best-effort and
* defaults to "unsupported". When we can't protect a user-managed source's
* files, we FAIL CLOSED (refuse the remove) rather than delete unprotected.
- * - The autopilot lock filename isn't documented and (gbrain #1226) ignores
- * GBRAIN_HOME, so the live `gbrain autopilot` process is the PRIMARY signal;
- * known lock paths under both the configured home and ~/.gbrain are secondary.
+ * - The live `gbrain autopilot` process is the PRIMARY signal and it is
+ * deliberately machine-wide (#3360, Garry 2026-10-24): any autopilot on the
+ * machine refuses, whichever brain or repo it serves, because two syncs that
+ * share one brain or working tree can race. The refusal names the matched
+ * pid and command so the user can tell whose it is. Known lock paths under
+ * both the configured home and ~/.gbrain are secondary.
* - We refuse only on an AFFIRMATIVE autopilot signal — inability to introspect
* never blocks a normal sync (that would brick the tool).
* - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't
@@ -69,11 +72,17 @@ export interface AutopilotStatus {
signal: string | null;
}
+/** A running process whose command line matches `gbrain autopilot`. */
+export interface AutopilotProcess {
+ pid: number;
+ command: string;
+}
+
export interface AutopilotProbe {
/** Override the lock-path list (tests). */
lockPaths?: string[];
- /** Override the live-process check (tests). */
- processRunning?: () => boolean;
+ /** Override the live-process check (tests): a bare boolean, or the matched processes. */
+ processRunning?: () => boolean | AutopilotProcess[];
}
/**
@@ -109,10 +118,17 @@ export function detectAutopilot(
// Stale lock (holder pid is dead): ignore this signal, keep checking. Pure
// decision function — we do NOT delete the file here; the caller may clean it.
}
- // Primary signal: a live `gbrain autopilot` process.
- const running = (probe.processRunning ?? defaultProcessRunning)();
- if (running) return { active: true, signal: "process:gbrain autopilot" };
- return { active: false, signal: null };
+ // Primary signal: a live `gbrain autopilot` process anywhere on the machine.
+ const running = (probe.processRunning ?? autopilotProcesses)();
+ if (running === true) return { active: true, signal: "process:gbrain autopilot" };
+ if (!Array.isArray(running) || running.length === 0) return { active: false, signal: null };
+ const listed = running.map(p => `pid ${p.pid} \`${p.command}\``).join(", ");
+ return {
+ active: true,
+ signal: `process:gbrain autopilot ${listed}; this check counts any process on this machine whose command line contains ` +
+ `"gbrain autopilot", even an autopilot serving another brain or repo. Stop that autopilot (or its service, if ` +
+ `\`gbrain autopilot --install\` set one up), or end the process that only mentions it`,
+ };
}
/** Read the holder pid from a lock/pid file. Returns null if no integer pid is present. */
@@ -143,11 +159,22 @@ function isPidAlive(pid: number): boolean {
}
}
-function defaultProcessRunning(): boolean {
+function autopilotProcesses(): AutopilotProcess[] {
// No reliable pgrep on Windows; rely on the lock-file signal there.
- if (process.platform === "win32") return false;
- const r = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 });
- return r.status === 0 && (r.stdout || "").trim().length > 0;
+ if (process.platform === "win32") return [];
+ const found = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 });
+ const pids = found.status === 0 ? (found.stdout || "").split(/\s+/).filter(p => /^\d+$/.test(p)) : [];
+ if (pids.length === 0) return [];
+ const ps = spawnSync("ps", ["-o", "pid=,args=", "-p", pids.join(",")], { encoding: "utf-8", timeout: 3_000 });
+ const commands = new Map();
+ for (const line of (ps.stdout || "").split("\n")) {
+ const m = line.match(/^\s*(\d+)\s+(.*\S)/);
+ if (m) commands.set(m[1], m[2]);
+ }
+ return pids.map(pid => ({
+ pid: Number(pid),
+ command: (commands.get(pid) ?? "command unavailable").replace(/\/\/[^\s/@]+@/g, "//***@").slice(0, 200),
+ }));
}
// ── Capability detection (E4 + Codex: per-process memo, no persistent cache) ─
diff --git a/lib/gbrain-repo-policy-client.ts b/lib/gbrain-repo-policy-client.ts
index f62d0a163..941a4c9d0 100644
--- a/lib/gbrain-repo-policy-client.ts
+++ b/lib/gbrain-repo-policy-client.ts
@@ -17,8 +17,8 @@
*/
import { spawnSync } from "child_process";
-import { existsSync } from "fs";
-import { join } from "path";
+import { lstatSync, statSync } from "fs";
+import { dirname, join } from "path";
import { mergedStateRoots, resolveStateRoot } from "./state-root";
export type RepoPolicyTierValue = "deny" | "read-only" | "read-write" | "none";
@@ -46,9 +46,36 @@ export function repoPolicyStorePath(env: NodeJS.ProcessEnv = process.env): strin
/**
* No store on disk = no policy was ever set (the fast path — no subprocess).
* The ~/.gstack store counts too: its deny tiers merge into every state root.
+ * Only confirmed absence is "no store": ENOENT whose nearest existing
+ * ancestor resolves (so no dangling symlink cut the walk short). A dangling
+ * symlink at the store or above it, a non-directory component, or a path that
+ * cannot be checked (an unsearchable directory) counts as a store, so the
+ * caller spawns the script and its own fail-closed rules apply.
*/
export function hasRepoPolicyStore(env: NodeJS.ProcessEnv = process.env): boolean {
- return mergedStateRoots(env).some((root) => existsSync(join(root, "gbrain-repo-policy.json")));
+ return mergedStateRoots(env).some((root) => {
+ let path = join(root, "gbrain-repo-policy.json");
+ try {
+ lstatSync(path);
+ return true;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") return true;
+ }
+ for (let up = dirname(path); up !== path; path = up, up = dirname(up)) {
+ try {
+ lstatSync(up);
+ } catch {
+ continue;
+ }
+ try {
+ statSync(up);
+ return false;
+ } catch {
+ return true;
+ }
+ }
+ return false;
+ });
}
/** The bash script that owns the store — resolved relative to this file (lib/ → bin/), never cwd. */
@@ -94,6 +121,50 @@ export function repoPolicyTier(
return { tier: "none", error: "unreadable" }; // unexpected output — a read failure, not a tier
}
+export interface MemorableRecallPolicy extends RepoPolicyResult {
+ /**
+ * True only when the tier is `read-only` and the store opts this remote in
+ * to Memorable recall. Never true alongside `error` or any other tier.
+ */
+ memorableOptIn: boolean;
+}
+
+/**
+ * Tier plus the per-remote Memorable recall opt-in, via
+ * `gstack-gbrain-repo-policy memorable get ` (one spawn). Unlike
+ * repoPolicyTier, the script never quarantines a corrupt store on this path,
+ * so a corrupt store reads as `unreadable` instead of as "no policy".
+ * Fast paths and failure classification match repoPolicyTier; anything but
+ * exactly "\t" is `unreadable`. POLARITY IS THE CALLER'S.
+ */
+export function memorableRecallPolicy(
+ url: string | null,
+ env: NodeJS.ProcessEnv = process.env,
+ timeoutMs: number = 10_000,
+): MemorableRecallPolicy {
+ if (!hasRepoPolicyStore(env)) return { tier: "none", memorableOptIn: false };
+ if (!url) return { tier: "none", memorableOptIn: false };
+ const [cmd, args]: [string, string[]] =
+ process.platform === "win32"
+ ? ["bash", [POLICY_SCRIPT, "memorable", "get", url]]
+ : [POLICY_SCRIPT, ["memorable", "get", url]];
+ const res = spawnSync(cmd, args, {
+ encoding: "utf-8",
+ timeout: Math.max(1, timeoutMs),
+ env: { ...env } as NodeJS.ProcessEnv,
+ });
+ if (res.error) {
+ const code = (res.error as NodeJS.ErrnoException).code;
+ return { tier: "none", memorableOptIn: false, error: code === "ENOENT" ? "spawn-failed" : "unreadable" };
+ }
+ const unreadable: MemorableRecallPolicy = { tier: "none", memorableOptIn: false, error: "unreadable" };
+ if (res.status !== 0) return unreadable;
+ const match = /^(deny|read-only|read-write|unset)\t(allow|off)\n?$/.exec(res.stdout || "");
+ if (!match) return unreadable;
+ const tier = match[1] === "unset" ? "none" : (match[1] as RepoPolicyTierValue);
+ return { tier, memorableOptIn: tier === "read-only" && match[2] === "allow" };
+}
+
/**
* Bulk trust-tier lookup via `gstack-gbrain-repo-policy get --batch` — ONE
* spawn total for the whole url list (memory-ingest checks every distinct
diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts
index e46d2336d..4b5cfb408 100644
--- a/lib/headless-artifacts.ts
+++ b/lib/headless-artifacts.ts
@@ -73,6 +73,10 @@ export interface PregateFile {
schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number;
// C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict.
head?: string; base?: string; generated_at?: string; policy?: string; registry?: string; requires_remote?: PregateRemote[]; verdict?: string;
+ /** sha256 of the user-owned policy when `policy` is `user `; verify re-checks it. */
+ policy_digest?: string;
+ /** Under the defaults, the user-owned policy path that was looked for and absent; verify checks it is still absent. */
+ policy_user_path?: string;
}
export interface ShipReceipt {
schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string;
@@ -189,7 +193,7 @@ export const ARTIFACT_SCHEMAS = {
properties: {
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList } } },
- head: str, base: str, generated_at: str, policy: str, registry: str, verdict: str,
+ head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str,
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str } } },
},
},
diff --git a/lib/model-policy-freshness-sources.ts b/lib/model-policy-freshness-sources.ts
index b90bbecb4..e0dfee345 100644
--- a/lib/model-policy-freshness-sources.ts
+++ b/lib/model-policy-freshness-sources.ts
@@ -178,7 +178,6 @@ function openaiLifecycle(text: string): ParsedSource {
if (!upcoming || !modelTables.length) throw new Error('missing-deprecation-tables');
const lifecycle = modelTables.flatMap(table => table.slice(1).flatMap(row => {
if (/^(?:Videos API|Assistants API|OpenAI-Beta: |New fine-tuning training on )/.test(row[1]) || row[1].startsWith('`/')) return [];
- if (!row[1].startsWith('`')) throw new Error('unrecognized-deprecated-model-cell');
const floor = row[0].startsWith('at earliest ');
const date = lifecycleDate(row[0].replace(/^at earliest /, ''));
if (!date) throw new Error('missing-shutdown-date');
diff --git a/lib/outside-review-result.ts b/lib/outside-review-result.ts
index 8409c73b2..ad76233ab 100644
--- a/lib/outside-review-result.ts
+++ b/lib/outside-review-result.ts
@@ -263,7 +263,8 @@ export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewC
}
if (!reviewed) return result(blocking ? 'findings' : 'clean');
const unparsed = firstUnparsed(code);
- const notReady = !findings.highest && verdictNotReady(plainCode);
+ // A last explicit not-ready blocks even when the only parsed severities are advisory.
+ const notReady = verdictNotReady(plainCode);
if (blocking || notReady) return { ...result('findings'), ...(unparsed ? { unparsed } : {}) };
if (unparsed) return { ...result('unverified', 'unparsed_severity', unparsed), unparsed };
if ((input.gate === 'structured' || input.gate === 'review') && !findings.highest && !concludesNoFindings(code)) {
diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts
index 04c697a37..8d8fdf054 100644
--- a/lib/pregate/checks/lanes.ts
+++ b/lib/pregate/checks/lanes.ts
@@ -16,9 +16,10 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { baseInputs, sha256 } from '../context';
import { PREGATE_CONFIG_PATH } from '../config';
-import { isDeviceDart, readRepoFile } from '../import-graph';
+import { isDeviceDart, listRepoFiles, readRepoFile } from '../import-graph';
import { selectLanes, type Selection } from '../lane-selection';
import { launchRuntime } from '../runtime';
+import { cargoModel, type CargoModel } from '../rust';
import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
import { matchesAny } from '../../ship-policy';
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
@@ -42,21 +43,24 @@ export function detectRunner(ctx: Pick, f
/** One runner invocation: `files` relative to `cwd` (repo-relative, `''` for the root). */
export interface RunGroup { runner: string; cwd: string; files: string[] }
-/** The nearest directory at or above `file` holding a pubspec.yaml, repo-relative; null outside any Dart package. `seen` memoises per directory. */
-function dartRoot(repoRoot: string, file: string, seen: Map): string | null {
+/** The nearest directory at or above `file` holding `marker`, repo-relative; null when none does. `seen` memoises per directory (one map per marker). */
+function nearestRoot(repoRoot: string, file: string, marker: string, seen: Map): string | null {
const walked: string[] = [];
let found: string | null = null;
for (let d = path.posix.dirname(file); ; d = path.posix.dirname(d)) {
const rel = d === '.' ? '' : d;
if (seen.has(rel)) { found = seen.get(rel)!; break; }
walked.push(rel);
- if (fs.existsSync(path.join(repoRoot, rel, 'pubspec.yaml'))) { found = rel; break; }
+ if (fs.existsSync(path.join(repoRoot, rel, marker))) { found = rel; break; }
if (d === '.' || d === '/' || d === '') break;
}
for (const w of walked) seen.set(w, found);
return found;
}
+/** The nearest directory at or above `file` holding a pubspec.yaml, repo-relative; null outside any Dart package. */
+function dartRoot(repoRoot: string, file: string, seen: Map): string | null { return nearestRoot(repoRoot, file, 'pubspec.yaml', seen); }
+
/** An fvm pin (`.fvmrc` or `.fvm/fvm_config.json`) at the package root `root` or any ancestor up to the repo root. */
function fvmPinned(repoRoot: string, root: string): boolean {
for (let d = root; ; d = path.posix.dirname(d)) {
@@ -72,6 +76,9 @@ function fvmPinnedFor(repoRoot: string, files: readonly string[]): boolean {
return files.some(f => { const root = f.endsWith('.dart') ? dartRoot(repoRoot, f, seen) : null; return root !== null && fvmPinned(repoRoot, root); });
}
+/** Whether `bin` is on the PATH of `env` (the PATH the runner will get). */
+const onPath = (bin: string, env: NodeJS.ProcessEnv) => !!Bun.which(bin, { PATH: env.PATH ?? '' });
+
/**
* `flutter test` for a package on the Flutter SDK, else `dart test`; through fvm
* when the package or an ancestor up to the repo root pins one and fvm is on PATH. `missing` names the tool when
@@ -82,48 +89,129 @@ export function dartRunner(repoRoot: string, root: string, env: NodeJS.ProcessEn
const pubspec = readRepoFile(repoRoot, path.posix.join(root, 'pubspec.yaml')) ?? '';
// `sdk: flutter` (the key optionally quoted) under any dependency name (flutter, flutter_test, ...), block style or inside a flow mapping a key opens, comments stripped.
const tool = /^[ \t]*(['"]?)sdk\1:[ \t]*['"]?flutter\b|^[ \t]*[\w-]+:[ \t]*\{(?:[^\n]*[{,])?[ \t]*(['"]?)sdk\2:[ \t]*['"]?flutter\b/m.test(pubspec.replace(/(^|\s)#.*$/gm, '$1')) ? 'flutter' : 'dart';
- const onPath = (bin: string) => !!Bun.which(bin, { PATH: env.PATH ?? '' });
- const fvm = fvmPinned(repoRoot, root) && onPath('fvm');
- if (!fvm && !onPath(tool)) return { missing: tool };
+ const fvm = fvmPinned(repoRoot, root) && onPath('fvm', env);
+ if (!fvm && !onPath(tool, env)) return { missing: tool };
return { runner: `${fvm ? 'fvm ' : ''}${tool} test {files}` };
}
-/** The run plan: the groups that can run, the files no runner claims, Dart SDKs missing from PATH, and `.dart` files with no pubspec.yaml above. */
+/**
+ * `cargo test -p ` for a crate (cargo runs a crate's tests, never a file list), or the missing tool when cargo is
+ * not on PATH. The name comes from the repo's Cargo.toml and reaches a shell and the `{files}` expansion: a name outside
+ * Cargo's grammar (letters, digits, `_`, `-`) gets no runner (null), and a valid one is still quoted like every file and cwd.
+ */
+function cargoRunner(name: string, env: NodeJS.ProcessEnv): { runner: string } | { missing: string } | null {
+ if (!/^[\p{ID_Continue}-]+$/u.test(name)) return null;
+ return onPath('cargo', env) ? { runner: `cargo test -p ${quote(name)}` } : { missing: 'cargo' };
+}
+
+type PackageJson = { packageManager?: string; devDependencies?: Record; dependencies?: Record; scripts?: Record };
+function readPackageJson(repoRoot: string, dir: string): PackageJson | null {
+ try { return JSON.parse(readRepoFile(repoRoot, path.posix.join(dir, 'package.json')) ?? 'null'); } catch { return null; }
+}
+
+const LOCKFILES: Array<[string, string]> = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm']];
+const PM_EXEC: Record = { pnpm: 'pnpm exec', yarn: 'yarn', bun: 'bun x', npm: 'npx' };
+
+/** The package manager for a package: the first `packageManager` field or lockfile from its directory up to the repo root; npm when none. */
+function packageManager(repoRoot: string, dir: string): string {
+ for (let d = dir; ; d = path.posix.dirname(d)) {
+ const rel = d === '.' ? '' : d;
+ const declared = /^(pnpm|yarn|bun|npm)@/.exec(readPackageJson(repoRoot, rel)?.packageManager ?? '')?.[1];
+ if (declared) return declared;
+ const lock = LOCKFILES.find(([f]) => fs.existsSync(path.join(repoRoot, rel, f)));
+ if (lock) return lock[1];
+ if (rel === '') return 'npm';
+ }
+}
+
+/**
+ * The runner a nested package (not the repo root) declares for itself: vitest
+ * or jest in its dependencies or its `test` script, through the package manager
+ * its `packageManager` field or nearest lockfile names; null when it declares
+ * neither, so its files stay with the root runner. `missing` names a package
+ * manager that is not on PATH.
+ */
+function packageRunner(repoRoot: string, dir: string, env: NodeJS.ProcessEnv): { runner: string } | { missing: string } | null {
+ const pkg = readPackageJson(repoRoot, dir);
+ if (!pkg) return null;
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
+ const script = pkg.scripts?.test ?? '';
+ const tool = deps.vitest || /\bvitest\b/.test(script) ? 'vitest run' : deps.jest || /\bjest\b/.test(script) ? 'jest' : null;
+ if (!tool) return null;
+ const pm = packageManager(repoRoot, dir);
+ const bin = pm === 'npm' ? 'npx' : pm;
+ if (!onPath(bin, env)) return { missing: bin };
+ return { runner: `${PM_EXEC[pm]} ${tool} {files}` };
+}
+
+/** The run plan: the groups that can run, the files no runner claims, tools missing from PATH, and `.dart` files with no pubspec.yaml above. */
export interface RunPlan { groups: RunGroup[]; unclaimed: string[]; missing: string[]; noPubspec: string[] }
/**
* The invocations that run `files`. A declared runner takes every file. Otherwise
* Dart tests run from their own package root (a sub-package's imports resolve
- * against its own pubspec), and the rest go to the detected runner. Files no
- * runner claims (a Dart SDK missing from PATH, a `.dart` file outside any
- * package, no detected runner) are `unclaimed`: the lane is `incomplete`, never a pass.
+ * against its own pubspec); Rust tests run per crate as `cargo test -p `
+ * from the crate's workspace root; JS/TS tests owned by a nested package that
+ * declares vitest or jest run from that package; the rest go to the detected
+ * runner from the repo root. Files no runner claims (a tool missing from PATH,
+ * a `.dart` file outside any package, a `.rs` file outside any crate or in a
+ * crate whose name Cargo would reject, no detected runner) are `unclaimed`: the lane is `incomplete`, never a pass.
*/
export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan {
if (ctx.config.runner) return { groups: [{ runner: ctx.config.runner, cwd: '', files: [...files] }], unclaimed: [], missing: [], noPubspec: [] };
const groups: RunGroup[] = [];
const dart = new Map();
+ const cargo = new Map();
+ const packages = new Map();
const seen = new Map();
+ const seenPackage = new Map();
+ let model: CargoModel | null = null;
+ const pkgRunners = new Map>();
const rest: string[] = [];
const noPubspec: string[] = [];
+ const unclaimed: string[] = [];
+ const missing = new Set();
+ const add = (map: Map, key: string, value: T) => { let list = map.get(key); if (!list) map.set(key, (list = [])); list.push(value); };
+ const under = (root: string, f: string) => (root ? f.slice(root.length + 1) : f);
for (const f of files) {
+ if (f.endsWith('.rs')) {
+ model ??= cargoModel(listRepoFiles(ctx.repoRoot), rel => readRepoFile(ctx.repoRoot, rel));
+ const crate = model.crateOf(f);
+ const cwd = crate?.workspace ?? null;
+ if (!crate || cwd === null) { unclaimed.push(f); continue; }
+ const key = `${cwd}\0${crate.name}`;
+ if (!cargo.has(key)) cargo.set(key, { name: crate.name, cwd, files: [] });
+ cargo.get(key)!.files.push(under(cwd, f));
+ continue;
+ }
+ const pkg = /\.[cm]?[jt]sx?$/.test(f) ? nearestRoot(ctx.repoRoot, f, 'package.json', seenPackage) : null;
+ if (pkg) {
+ if (!pkgRunners.has(pkg)) pkgRunners.set(pkg, packageRunner(ctx.repoRoot, pkg, ctx.env));
+ const r = pkgRunners.get(pkg);
+ if (r && 'missing' in r) { missing.add(r.missing); unclaimed.push(f); continue; }
+ if (r) { add(packages, pkg, under(pkg, f)); continue; }
+ }
if (!f.endsWith('.dart')) { rest.push(f); continue; }
const root = dartRoot(ctx.repoRoot, f, seen);
if (root === null) { noPubspec.push(f); continue; }
- let list = dart.get(root);
- if (!list) dart.set(root, (list = []));
- list.push(root ? f.slice(root.length + 1) : f);
+ add(dart, root, under(root, f));
}
- const unclaimed: string[] = [];
- const missing = new Set();
for (const [root, list] of [...dart].sort(([a], [b]) => a.localeCompare(b))) {
const r = dartRunner(ctx.repoRoot, root, ctx.env);
if ('missing' in r) { missing.add(r.missing); unclaimed.push(...list.map(f => (root ? `${root}/${f}` : f))); } else groups.push({ runner: r.runner, cwd: root, files: list });
}
+ for (const [, c] of [...cargo].sort(([a], [b]) => a.localeCompare(b))) {
+ const r = cargoRunner(c.name, ctx.env);
+ if (r && 'runner' in r) { groups.push({ runner: r.runner, cwd: c.cwd, files: c.files }); continue; }
+ if (r) missing.add(r.missing);
+ unclaimed.push(...c.files.map(f => (c.cwd ? `${c.cwd}/${f}` : f)));
+ }
if (rest.length) {
const runner = detectRunner(ctx, rest);
if (runner) groups.push({ runner, cwd: '', files: rest });
else unclaimed.push(...rest);
}
+ for (const [root, list] of [...packages].sort(([a], [b]) => a.localeCompare(b))) groups.push({ runner: (pkgRunners.get(root) as { runner: string }).runner, cwd: root, files: list });
return { groups, unclaimed: [...unclaimed, ...noPubspec], missing: [...missing].sort(), noPubspec };
}
@@ -132,8 +220,8 @@ function planGap(ctx: PregateContext, plan: RunPlan, total: number): Pick 8 ? ' …' : ''}${missing.length ? `; not on PATH: ${missing.join(', ')}` : ''}${noPubspec.length ? `; no pubspec.yaml above ${noPubspec.length} .dart file(s)` : ''}`;
const fix = missing.length ? `install ${missing.join(' and ')}${fvmPinnedFor(ctx.repoRoot, unclaimed) ? ' (or fvm, which this repo pins)' : ''} and rerun`
- : noPubspec.length ? 'commit the Dart package\'s pubspec.yaml, or declare `runner` (with {files}) in .gstack/pregate.json'
- : 'declare `runner` (with {files}) in .gstack/pregate.json';
+ : noPubspec.length ? `commit the Dart package's pubspec.yaml, or declare \`runner\` (with {files}) in ${ctx.configFile}`
+ : `declare \`runner\` (with {files}) in ${ctx.configFile}`;
return { code: 'PREGATE_INCOMPLETE', detail, fix };
}
@@ -153,9 +241,18 @@ function parseDart(output: string): RunnerSummary | null {
return d ? { ran: Number(d[1]) + Number(d[2] ?? 0), failed: Number(d[2] ?? 0) } : null;
}
-/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test. A bats or dart/flutter runner's summary is tried first; for any other runner those formats are a last resort. `ran: null` when not recognised. Colour codes are dropped and a carriage return ends a line, so a coloured or redrawn verdict still reads. */
+/** cargo test: one `test result: ok|FAILED. N passed; M failed; …` line per test binary (unit tests, each integration test, doc-tests), summed; ignored and filtered-out tests did not run. */
+function parseCargo(output: string): RunnerSummary | null {
+ const lines = [...output.matchAll(/^test result: \w+\. (\d+) passed; (\d+) failed;/gm)];
+ if (!lines.length) return null;
+ const failed = lines.reduce((n, m) => n + Number(m[2]), 0);
+ return { ran: lines.reduce((n, m) => n + Number(m[1]), 0) + failed, failed };
+}
+
+/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test, cargo test. A bats, dart/flutter or cargo runner's summary is tried first (cargo's only there: its `N passed` lines would misread as another runner's); for any other runner the bats and dart formats are a last resort. `ran: null` when not recognised. Colour codes are dropped and a carriage return ends a line, so a coloured or redrawn verdict still reads. */
export function parseSummary(raw: string, runner = ''): RunnerSummary {
const output = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '\n');
+ if (/(?>
const timedOut = r.runs.filter(x => x.outcome === 'timeout');
const rest = r.failed ? `; ${r.failed} failed` : '';
if (stalled.length) {
- return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in .gstack/pregate.json; 0 disables) for a test that is quiet for longer` };
+ return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that is quiet for longer` };
}
if (!timedOut.length) return { code: 'PREGATE_INCOMPLETE', detail: `the runner could not be launched (${total} files${rest})`, fix: 'read the lane log, fix the runner command, then rerun gstack-pregate --stage tests' };
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
@@ -282,7 +379,7 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string
*/
async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult): Promise {
const lines = base.lines ?? [];
- if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: 'declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in .gstack/pregate.json on the base branch, or run the tests locally without --remote' };
+ if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: `declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in ${ctx.configFile === PREGATE_CONFIG_PATH ? `${PREGATE_CONFIG_PATH} on the base branch` : ctx.configFile}, or run the tests locally without --remote` };
const obligation = remoteObligation(ctx, sel);
const cap = ctx.remote!.spendCapUsd;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-remote-'));
@@ -314,7 +411,7 @@ async function run(ctx: PregateContext): Promise {
const lines = describe(sel);
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines, remote: sel.remote };
if (sel.noLane.length) {
- return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(sel.noLane) };
+ return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) };
}
if (sel.tests.length === 0) {
const inert = sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : '';
@@ -352,9 +449,9 @@ async function run(ctx: PregateContext): Promise {
return { ...withRun, status: sel.remote.length ? 'requires-remote' : 'pass', detail };
}
-function noLaneFix(noLane: readonly string[]): string {
+function noLaneFix(ctx: PregateContext, noLane: readonly string[]): string {
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) and let CI run them` : '';
- return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${PREGATE_CONFIG_PATH}${device}`;
+ return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}${device}`;
}
export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run };
diff --git a/lib/pregate/checks/strays.ts b/lib/pregate/checks/strays.ts
index d46e88b39..4b5d5b572 100644
--- a/lib/pregate/checks/strays.ts
+++ b/lib/pregate/checks/strays.ts
@@ -31,8 +31,8 @@ function run(ctx: PregateContext): CheckResult {
if (!baseDirs.has(top)) warnings.push(`${file}: new top-level directory ${top}/ (not on ${ctx.baseRef})`);
}
ctx.explain.push(`strays: base directories ${[...baseDirs].join(',')}; allow ${allow.join(',') || '(none)'}`);
- if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: 'delete or move the file; an intentional root file goes in .gstack/pregate.json strays.allow' };
- if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: 'confirm the directory is intended, or allow it in .gstack/pregate.json strays.allow' };
+ if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: `delete or move the file; an intentional root file goes in ${ctx.configFile} strays.allow` };
+ if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: `confirm the directory is intended, or allow it in ${ctx.configFile} strays.allow` };
return { id: 'strays', stage: 'preflight', status: 'pass', inputs, detail: `${ctx.touched.filter(t => t.status === 'A').length} added file(s), none stray` };
}
diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts
index cb9d82a9e..e1f2b3e20 100644
--- a/lib/pregate/config.ts
+++ b/lib/pregate/config.ts
@@ -16,6 +16,9 @@
* warn check ids downgraded to warn (never `secrets`; `lanes` keeps
* its required-lane and requires-remote verdicts)
* strays.allow root files or new top-level directories that are intentional
+ * local `/` lanes a local run satisfies (a
+ * platform-agnostic toolchain, or a repo whose CI is off):
+ * their tests run here instead of becoming requires-remote
* guards `{ id, when: [globs], command, fix?, timeout_s? }`: a repo
* command that must pass when a touched file matches `when`
* (manifest checks, CLI goldens); never downgradable
@@ -30,10 +33,16 @@
* remote_gate the repo's remote test gate command: under 8 cores the test
* tier is a requires-remote obligation naming it, and
* `--remote` runs it (it prints `PREGATE_REMOTE: tree=… tests=… failed=…`)
+ *
+ * A repo that keeps `.gstack/` out of git can hold the same file at
+ * `/projects//pregate.json` (lib/state-root.ts), honoured
+ * only when the base carries none. It must sit outside the repo, so a PR
+ * cannot reach it; a path inside the checkout is ignored (pregate/context).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
import type { ResultCodeName } from '../result-codes';
import { sealedErrors } from '../sealed-guard';
import { containedRelative, globToRegExp } from '../ship-policy';
@@ -52,6 +61,7 @@ export interface PregateConfig {
selection: Record;
warn: string[];
strays: { allow: string[] };
+ local: string[];
guards: Guard[];
serial_patterns: string[];
price_file: string | null;
@@ -59,12 +69,13 @@ export interface PregateConfig {
remote_gate: string | null;
}
export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number }
-export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
+export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
export interface ConfigError { code: ResultCodeName; path: string; message: string }
export const MAX_LIST = 256;
const MAX_COMMAND = 512;
-const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
+const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
+const LANE_ID = /^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/;
const MAX_GUARDS = 32;
const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s'];
@@ -153,7 +164,7 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
const sel = obj.selection;
if (!sel || typeof sel !== 'object' || Array.isArray(sel)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'selection', message: 'must be an object of / → command' });
else for (const [lane, command] of Object.entries(sel as Record)) {
- if (!/^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be /' });
+ if (!LANE_ID.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be /' });
const why = commandError(command);
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: why });
}
@@ -176,6 +187,10 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
}
}
}
+ if ('local' in obj) {
+ if (!Array.isArray(obj.local) || obj.local.some(l => typeof l !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'local', message: 'must be an array of /' });
+ else (obj.local as string[]).forEach((l, i) => { if (!LANE_ID.test(l)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `local[${i}]`, message: `${JSON.stringify(l)} must be /` }); });
+ }
return errors;
}
@@ -186,14 +201,15 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []):
const errors = validateConfig(raw, knownChecks);
if (errors.length) return { config: DEFAULT_CONFIG, errors };
const o = raw as Partial;
- return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
+ return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
}
-export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string };
+export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string };
function git(cwd: string, args: string[]): { ok: boolean; out: string } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
- return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
+ // r.error covers ENOBUFS, where stdout is cut at the buffer limit even with status 0.
+ return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim() };
}
export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: readonly string[] = []): ConfigLoad {
@@ -201,7 +217,12 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks:
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` };
const show = git(repoRoot, ['show', `${sha.out}:${PREGATE_CONFIG_PATH}`]);
- if (!show.ok) return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' };
+ if (!show.ok) {
+ // Absent only when the tree says so: a timeout, an oversized blob or a missing/corrupt object must refuse, not hand the run to the user file.
+ const entry = git(repoRoot, ['ls-tree', '--name-only', sha.out, '--', PREGATE_CONFIG_PATH]);
+ if (!entry.ok || entry.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} could not be read and git cannot confirm it is absent` };
+ return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' };
+ }
return { ...parseConfig(show.out, knownChecks), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' };
}
@@ -211,6 +232,37 @@ export function loadConfigFromHead(repoRoot: string, knownChecks: readonly strin
return { ...parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' };
}
+/** A symlink to nothing on the way to `file` (a dotfiles link not yet cloned): a policy the user meant to have, not an absent one. */
+function danglingLink(file: string): boolean {
+ for (let p = file; path.dirname(p) !== p; p = path.dirname(p)) {
+ try { if (fs.lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() && !fs.existsSync(p)) return true; } catch { return true; }
+ }
+ return false;
+}
+
+/** Nothing at `file` and no dangling link on the way to it: the only state that means "no user policy", for the loader and verify alike. */
+export function userPolicyAbsent(file: string): boolean {
+ try { fs.statSync(file); return false; } catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT' && !danglingLink(file); }
+}
+
+/** The user-owned copy for a repo that keeps `.gstack/` out of git; the caller consults it only when the base has none. */
+export function loadConfigFromUser(file: string, knownChecks: readonly string[] = []): ConfigLoad | null {
+ let text: string;
+ try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT' && userPolicyAbsent(file)) return null;
+ // An unreadable policy must refuse, never fall back to defaults (its guards would silently vanish).
+ return { ...parseConfig('{}', knownChecks), errors: [{ code: 'PREGATE_CONFIG_INVALID', path: file, message: `cannot read the user-owned policy: ${(e as Error).message}` }], present: true, label: `user ${file}`, kind: 'user' };
+ }
+ const load = parseConfig(text, knownChecks);
+ // `sealed` (the pre-push guard) and `price_file` (gstack-eval-plan) read only the git copies, so a non-null value in the user file would look active and do nothing.
+ let raw: unknown = null;
+ try { raw = JSON.parse(text); } catch { /* parseConfig reported it */ }
+ const ignored = raw && typeof raw === 'object' && !Array.isArray(raw) ? ['sealed', 'price_file'].filter(k => (raw as Record)[k] != null) : [];
+ // parseConfig names the repo copy for a JSON error; this file is not it.
+ const errors = [...load.errors.map(e => e.path === PREGATE_CONFIG_PATH ? { ...e, path: file } : e), ...ignored.map(k => ({ code: 'PREGATE_CONFIG_INVALID' as const, path: k, message: `the user-owned policy covers gstack-pregate run only; ${k} is read from ${PREGATE_CONFIG_PATH} on the base branch` }))];
+ return { ...load, errors, present: true, label: `user ${file}`, kind: 'user', digest: createHash('sha256').update(text).digest('hex') };
+}
+
export function isConfigUnavailable(load: ConfigLoad): load is { unavailable: true; base: string; detail: string } {
return (load as any).unavailable === true;
}
@@ -223,11 +275,13 @@ export function explainConfig(): string {
'selection: / → the runner-owned command that lists that lane’s test files when its matrix is planner-computed',
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`,
'strays.allow: root files or new top-level directories that are intentional',
+ 'local: / lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)',
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree= tests= failed=) (docs/pregate.md#remote-gate)',
+ 'no .gstack/pregate.json on the base: /projects//pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)',
].join('\n') + '\n';
}
diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts
index 4d5584284..ba9b4e532 100644
--- a/lib/pregate/context.ts
+++ b/lib/pregate/context.ts
@@ -9,13 +9,16 @@
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
+import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
+import { slugFromEnvironment } from '../bin-context';
import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type Registry } from '../regen';
+import { resolveStateRoot } from '../state-root';
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
-import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, type PregateConfig } from './config';
+import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
import type { CheckResult, PregateContext, TouchedFile } from './types';
import { discoverLanes } from './workflows';
import type { RegistryError } from '../regen';
@@ -27,6 +30,8 @@ export interface BuildContextOptions {
stallTimeoutMs?: number; progress?: (line: string) => void; cores?: number; testBackend?: string; remote?: { spendCapUsd: number } | null;
/** How gstack's own bash bins launch (gstack-wtree, gstack-config); tests inject a Windows probe. */
invocation?: InvocationOptions;
+ /** `/projects//pregate.json`: read only when the base carries no pregate.json, and never from a relative path or one inside the repository. */
+ userConfigPath?: string;
}
export const DEFAULT_STALL_TIMEOUT_S = 300;
@@ -123,6 +128,61 @@ export function touchedFiles(repoRoot: string, mergeBase: string): TouchedFile[]
return [...out.values()].filter(t => t.path !== 'node_modules' && !t.path.startsWith('node_modules/')).sort((a, b) => a.path.localeCompare(b.path));
}
+/** The deepest existing ancestor realpathed (on-disk casing), the rest re-joined: a symlinked state root inside the checkout still counts as inside. */
+function realish(p: string): string {
+ let head = path.resolve(p); const tail: string[] = [];
+ while (!fs.existsSync(head) && path.dirname(head) !== head) { tail.unshift(path.basename(head)); head = path.dirname(head); }
+ try { return path.join(fs.realpathSync.native(head), ...tail); } catch { return path.resolve(p); }
+}
+/**
+ * Every location the path to `p` passes through, each symlink followed one hop at a time: a link inside the checkout picks
+ * the file even when the last hop points back outside it. A missing component ends the walk with the rest joined lexically.
+ */
+function resolutionChain(p: string): string[] {
+ const chain: string[] = [];
+ let cur = path.parse(path.resolve(p)).root;
+ let rest = path.relative(cur, path.resolve(p)).split(path.sep).filter(Boolean);
+ for (let hops = 0; rest.length && hops <= 40;) {
+ const next = path.join(cur, rest.shift()!);
+ let st: fs.Stats | undefined;
+ try { st = fs.lstatSync(next, { throwIfNoEntry: false }); } catch { st = undefined; }
+ chain.push(st ? next : path.join(next, ...rest));
+ if (!st) break;
+ if (!st.isSymbolicLink()) { cur = next; continue; }
+ hops++;
+ const target = path.resolve(cur, fs.readlinkSync(next));
+ cur = path.parse(target).root;
+ rest = [...path.relative(cur, target).split(path.sep).filter(Boolean), ...rest];
+ }
+ return chain;
+}
+const within = (root: string, p: string) => { const rel = path.relative(root, p); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); };
+
+/** Dotenv files Bun loads from the launch directory unless started with --no-env-file. */
+const DOTENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local', '.env.production', '.env.production.local', '.env.test', '.env.test.local'];
+
+/**
+ * The dotenv files in `cwd` that Bun loaded (no --no-env-file in `execArgv`). Any of them could set the slug or state
+ * root that picks the user policy, and Bun's dotenv syntax is looser than a key scan can follow, so any one refuses.
+ */
+export function loadedDotenvFiles(cwd: string, execArgv: readonly string[]): string[] {
+ if (execArgv.includes('--no-env-file')) return [];
+ return DOTENV_FILES.filter(f => fs.existsSync(path.join(cwd, f)));
+}
+
+/** `/projects//pregate.json` for the repository at `root`, from the caller's environment. */
+export function defaultUserConfigPath(root: string): string {
+ return path.join(resolveStateRoot(), 'projects', slugFromEnvironment(undefined, root), 'pregate.json');
+}
+
+/** Why a user policy path must not be read, or null: a relative path (no HOME) or one a PR could commit is never trusted. */
+export function userConfigRejection(repoRoot: string, file: string): string | null {
+ if (!path.isAbsolute(file)) return 'not an absolute path';
+ const repo = path.resolve(repoRoot), real = realish(repoRoot);
+ if (within(repo, path.resolve(file)) || [file, ...resolutionChain(file)].some(p => within(real, realish(p)))) return 'inside the repository; a PR could supply it';
+ return null;
+}
+
export function buildContext(o: BuildContextOptions): ContextLoad {
const base = o.base.replace(/^origin\//, '');
const baseRef = `origin/${base}`;
@@ -130,9 +190,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
- const configLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
+ let configLoad: ConfigLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
+ const explain: string[] = [];
+ let absentUserPolicy: string | undefined;
+ if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') {
+ const rejected = userConfigRejection(o.repoRoot, o.userConfigPath);
+ const user = rejected ? null : loadConfigFromUser(o.userConfigPath, o.knownChecks);
+ if (user) configLoad = user;
+ else explain.push(`user policy: ${o.userConfigPath} ${rejected ? `ignored (${rejected})` : '(absent)'}`);
+ if (!user && !rejected) absentUserPolicy = o.userConfigPath;
+ }
if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } };
- if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors } };
+ if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors, ...(configLoad.kind === 'user' ? { fix: `edit ${o.userConfigPath} directly at each path the message names, then rerun gstack-pregate; it lives outside the repository, so a base-branch change does not reach it` } : {}) } };
const registryLoad = o.policyFrom === 'head' ? loadRegistryFromHead(o.repoRoot) : loadRegistryFromBase(o.repoRoot, base);
if (isRegistryUnavailable(registryLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: registryLoad.detail } };
if (registryLoad.errors.length) return { refused: { code: 'REGEN_REGISTRY_INVALID', message: `${registryLoad.source.label}: ${registryLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: registryLoad.errors } };
@@ -142,15 +211,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
const wtree = fp.tree;
const platform = o.platform ?? process.platform;
const config = configLoad.config as PregateConfig;
+ const lanes = discoverLanes(o.repoRoot, platform);
+ // A lane that exists but is not platform-tier on this host already runs here, so only an unknown id is worth a line.
+ for (const id of config.local) if (!lanes.some(l => l.id === id)) explain.push(`local: ${id} matches no workflow lane; ignored`);
return {
ctx: {
repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase),
- config, configLabel: configLoad.label, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
- lanes: discoverLanes(o.repoRoot, platform), platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
+ config, configLabel: configLoad.label, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.kind === 'user' && configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
+ lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000,
progress: o.progress ?? (line => process.stderr.write(`${line}\n`)),
cores: o.cores ?? os.availableParallelism(), testBackend: o.testBackend ?? readTestBackend(env, o.invocation), policyFrom: o.policyFrom, remote: o.remote ?? null,
- env, explain: [],
+ env, explain,
},
};
}
@@ -159,7 +231,7 @@ export function sha256(text: string): string { return createHash('sha256').updat
/** The common input identity every check records; checks add their own keys. */
export function baseInputs(ctx: PregateContext, extra: Record = {}): Record {
- return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...extra };
+ return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...(ctx.configDigest ? { policy: ctx.configDigest } : {}), ...extra };
}
/**
diff --git a/lib/pregate/import-graph.ts b/lib/pregate/import-graph.ts
index 52da30f32..22b90e4fb 100644
--- a/lib/pregate/import-graph.ts
+++ b/lib/pregate/import-graph.ts
@@ -12,7 +12,10 @@
* 3. the `bin/` → `test/*.test.*` convention (with and without
* the `gstack-` prefix);
* 4. explicit `.gstack/pregate.json` `dependencies` declarations;
- * 5. in a Django project, the files Django loads by name (lib/pregate/django.ts).
+ * 5. in a Django project, the files Django loads by name (lib/pregate/django.ts);
+ * 6. in a Cargo crate, every `.rs` file and the Cargo.toml select the crate's
+ * tests and its dependents' (lib/pregate/rust.ts); in-source
+ * `#[cfg(test)]` files and `/tests/` files are the tests.
* Dart directives (`import`/`export`/`part`, conditional URIs included) resolve
* `package:/x` through every tracked pubspec.yaml to `/lib/x`.
* Uncertainty selects more tests, never fewer: a dependency on a directory
@@ -25,6 +28,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { matchesAny } from '../ship-policy';
import { djangoEdges } from './django';
+import { rustEdges } from './rust';
export const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|sh|bash|go|rs|dart)$/;
export const TEST_FILE_RE = /(?:^|\/)(?:test|tests|spec|__tests__)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]*\.py$|_test\.(?:py|go|rb)$|_spec\.rb$|(?:^|\/)[^/]*\.bats$|(?:^|\/)test\/(?:.*\/)?[^/]*_test\.dart$/;
@@ -465,6 +469,9 @@ export function buildGraph(repoRoot: string, opts: BuildOptions = {}): Graph {
for (const { target, via } of referencesIn(files, dirs, rel, source, config, graph.tests.has(rel) ? unresolved : undefined, packages)) addEdge(graph, target, rel, via);
if (unresolved.length) graph.unresolved!.set(rel, unresolved);
}
+ const rust = rustEdges(all, rel => readRepoFile(repoRoot, rel, maxBytes));
+ for (const t of rust.tests) graph.tests.add(t);
+ for (const e of rust.edges) addEdge(graph, e.dependency, e.dependent, e.via);
const django = djangoEdges({ files: [...files], tests: graph.tests, read: rel => pySources.get(rel) ?? readRepoFile(repoRoot, rel, maxBytes), code: pythonCode, resolveDotted: dotted => pythonPatchTarget(files, dotted) });
for (const e of django) addEdge(graph, e.dependency, e.dependent, 'framework');
for (const test of graph.tests) {
diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts
index 74a553be1..d6a52462f 100644
--- a/lib/pregate/index.ts
+++ b/lib/pregate/index.ts
@@ -22,8 +22,8 @@ import { regenCheck } from './checks/regen';
import { secretsCheck } from './checks/secrets';
import { straysCheck } from './checks/strays';
import { timingCheck } from './checks/timing';
-import { NON_DOWNGRADABLE } from './config';
-import { withFingerprint } from './context';
+import { NON_DOWNGRADABLE, userPolicyAbsent } from './config';
+import { sha256, withFingerprint } from './context';
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
@@ -57,7 +57,7 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise c.remote ?? []).map(r => ({ ...r }));
const file: PregateFile = {
schema_version: 1, tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, generated_at: new Date().toISOString(),
- policy: ctx.configLabel, registry: ctx.registryLabel, run: o.run,
+ policy: ctx.configLabel, ...(ctx.configDigest ? { policy_digest: ctx.configDigest } : {}), ...(ctx.absentUserPolicy ? { policy_user_path: ctx.absentUserPolicy } : {}), registry: ctx.registryLabel, run: o.run,
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' ? undefined : clocks.tests,
checks: checks.map(c => { const { remote: _r, ...rest } = c; return rest; }),
requires_remote: remote,
@@ -122,12 +122,17 @@ export interface Verification { ok: boolean; code?: 'PREGATE_STALE' | 'PREGATE_R
/**
* Publication check: the recorded tree must equal the tree now (results
* current after final generation and stamping), no check may be fail or
- * incomplete, and every requires-remote obligation needs its receipt.
+ * incomplete, and every requires-remote obligation needs its receipt. The
+ * user-owned policy behind the run (or its absence, under the defaults) must
+ * still hold, since it lives outside the tree.
*/
export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: WtreeResult = wtreeFingerprint({ cwd: repoRoot })): Verification {
const uncleared = (file.requires_remote ?? []).filter(r => !r.receipt);
- const current = 'tree' in treeNow && treeNow.tree === file.tree;
- if (!current) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current, uncleared };
+ const treeCurrent = 'tree' in treeNow && treeNow.tree === file.tree;
+ if (!treeCurrent) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current: false, uncleared };
+ const policyChange = userPolicyChange(file);
+ if (policyChange) return { ok: false, code: 'PREGATE_STALE', message: policyChange, current: false, uncleared };
+ const current = true;
const failed = file.checks.filter(c => c.status === 'fail');
if (failed.length) return { ok: false, code: 'PREGATE_LANE_FAILED', message: `${failed.length} check(s) failed: ${failed.map(c => c.id).join(', ')}`, current, uncleared };
const incomplete = file.checks.filter(c => c.status === 'incomplete' || c.status === 'not_installed');
@@ -136,6 +141,27 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}`, current, uncleared };
}
+/**
+ * Why the user-owned policy a run recorded no longer holds, or null. Both paths come from the record, never the verifier's
+ * environment: the policy file and its digest, or under the defaults the path that was looked for and absent.
+ */
+function userPolicyChange(file: PregateFile): string | null {
+ const recorded = file.policy?.startsWith('user ') ? file.policy.slice('user '.length) : null;
+ if (recorded) {
+ if (!file.policy_digest) return `pregate.json names the user-owned policy ${recorded} without its digest; rerun gstack-pregate`;
+ let text: string;
+ try { text = fs.readFileSync(recorded, 'utf8'); } catch { return `the user-owned policy ${recorded} that pregate.json was recorded under is gone or unreadable; rerun gstack-pregate`; }
+ return sha256(text) === file.policy_digest ? null : `the user-owned policy ${recorded} changed after pregate.json was recorded; rerun gstack-pregate`;
+ }
+ const absent = file.policy_user_path;
+ if (!absent) return null;
+ if (userPolicyAbsent(absent)) return null;
+ let leaf: fs.Stats | undefined;
+ try { leaf = fs.lstatSync(absent, { throwIfNoEntry: false }); } catch { leaf = undefined; }
+ if (!leaf) return `cannot confirm ${absent} is still absent (a link on the way to it is dangling or unreadable); rerun gstack-pregate`;
+ return `${absent} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`;
+}
+
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string }
export type ClearDecision = { receipt: string; attested: boolean } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string };
diff --git a/lib/pregate/init-draft.ts b/lib/pregate/init-draft.ts
index 974d48169..4be30a1f3 100644
--- a/lib/pregate/init-draft.ts
+++ b/lib/pregate/init-draft.ts
@@ -72,13 +72,14 @@ export function draftConfig(repoRoot: string): InitDraft {
}
const groups = new Map();
- for (const t of tests) { const lang = t.endsWith('.dart') ? 'dart' : t.endsWith('.py') ? 'python' : /\.[cm]?[jt]sx?$/.test(t) ? 'javascript' : 'other'; groups.set(lang, [...(groups.get(lang) ?? []), t]); }
+ for (const t of tests) { const lang = t.endsWith('.dart') ? 'dart' : t.endsWith('.rs') ? 'rust' : t.endsWith('.py') ? 'python' : /\.[cm]?[jt]sx?$/.test(t) ? 'javascript' : 'other'; groups.set(lang, [...(groups.get(lang) ?? []), t]); }
const notes = [...groups].map(([lang, list]) => {
if (lang === 'dart') return `${list.length} Dart test file(s) run per package with flutter test or dart test`;
+ if (lang === 'rust') return `${list.length} Rust test file(s) run per crate with cargo test -p`;
const runner = detectRunner({ repoRoot, config: DEFAULT_CONFIG }, list);
return runner ? `${lang} tests detected at run time: ${runner}` : `no runner detected for ${list.length} ${lang} test file(s); set runner (with {files})`;
});
- const mixed = [...groups.keys()].filter(l => l !== 'dart').length > 1 ? '; a selection mixing these languages needs runner set' : '';
+ const mixed = [...groups.keys()].filter(l => l !== 'dart' && l !== 'rust').length > 1 ? '; a selection mixing these languages needs runner set' : '';
const runnerNote = notes.length ? notes.join('; ') + mixed : 'no test files found';
const selectionTodo = discoverLanes(repoRoot).filter(l => l.runsTests && l.matrixExpression && l.testFiles.length === 0).map(l => l.id);
diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts
index 044e27312..529227cd5 100644
--- a/lib/pregate/lane-selection.ts
+++ b/lib/pregate/lane-selection.ts
@@ -130,12 +130,13 @@ export function selectLanes(ctx: PregateContext): Selection {
const set = laneFileSet(ctx, lane);
if (set !== 'all') laneSets[lane.id] = set;
const inLane = set === 'all' ? selected : set === '?' ? selected : selected.filter(t => set.includes(t));
- if (lane.tier === 'platform') {
+ if (lane.tier === 'platform' && ctx.config.local.includes(lane.id)) ctx.explain.push(`lane ${lane.id}: ${lane.platform} lane satisfied locally (pregate.json local)`);
+ else if (lane.tier === 'platform') {
if (inLane.length) remote.push({ lane: lane.id, platform: lane.platform, workflow: lane.workflow, job: lane.job, tests: inLane, receipt: null });
continue;
}
if (set === 'all') anyLocalAll = true;
- if (inLane.length) { tiers.add(lane.tier); for (const t of inLane) local.add(t); }
+ if (inLane.length) { tiers.add(lane.tier === 'platform' ? 'unit' : lane.tier); for (const t of inLane) local.add(t); }
}
if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t);
for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t);
diff --git a/lib/pregate/rust.ts b/lib/pregate/rust.ts
new file mode 100644
index 000000000..6e0028229
--- /dev/null
+++ b/lib/pregate/rust.ts
@@ -0,0 +1,258 @@
+/**
+ * pregate/rust — Cargo crates for the import graph and the `lanes` runner.
+ * A crate is the nearest directory holding a Cargo.toml with a `[package]`
+ * name. Its `.rs` files are tests when they sit under `/tests/` or hold
+ * a `#[cfg(test)]` (in-source unit tests). Cargo runs tests per crate, never
+ * per file, so the graph maps at crate level: every `.rs` file of a crate,
+ * and its Cargo.toml, select that crate's tests and the tests of every crate
+ * that depends on it, transitively.
+ * Manifests are parsed as TOML (Bun.TOML, as lib/model-policy.ts does), so
+ * dotted keys, `[dependencies.]` tables, target-specific tables and
+ * `{ workspace = true }` inheritance read as Cargo reads them. A dependency
+ * resolves by `path` (relative to the manifest declaring it) to a tracked
+ * crate, else by package name (`package = ` or the key); a name match only
+ * widens. The workspace a crate runs in follows Cargo's membership rules:
+ * `package.workspace`, else the nearest `[workspace]` above it, when that
+ * workspace is the crate's own manifest, lists it in `members` or reaches it
+ * by a path dependency, and does not `exclude` it; otherwise the crate
+ * directory. Nothing here runs cargo: pregate reads files only.
+ */
+import * as path from 'node:path';
+
+/** Reads a repo-relative file's text, or null when it is absent or unreadable. */
+export type ReadFile = (rel: string) => string | null;
+
+type Table = Record;
+const isTable = (v: unknown): v is Table => typeof v === 'object' && v !== null && !Array.isArray(v);
+const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
+const DEP_TABLES = ['dependencies', 'dev-dependencies', 'build-dependencies'] as const;
+
+/** One dependency: the crate directory its `path` names (repo-relative, normalized) and the package name it asks for. */
+interface DepSpec { path: string | null; name: string }
+
+export interface Crate {
+ root: string;
+ name: string;
+ /** Where `cargo test -p ` runs: the root of the workspace it is a member of, else the crate directory; null when membership is undecided. */
+ workspace: string | null;
+ /** Roots of the tracked crates it depends on. */
+ deps: string[];
+}
+
+export interface CargoModel { crates: Map; crateOf(file: string): Crate | null }
+
+interface Manifest { dir: string; toml: Table }
+
+const dirOf = (manifest: string) => (path.posix.dirname(manifest) === '.' ? '' : path.posix.dirname(manifest));
+/** `dir` joined with a manifest-relative path, normalized; null when it leaves the repository. */
+function joinRel(dir: string, rel: string): string | null {
+ const joined = path.posix.normalize(path.posix.join(dir || '.', rel.replace(/\\/g, '/'))).replace(/\/+$/, '');
+ if (joined === '..' || joined.startsWith('../') || path.posix.isAbsolute(joined)) return null;
+ return joined === '.' ? '' : joined;
+}
+/** One glob component (`*`, `?`, `[...]` with `!`/`^` negation and ranges, else literal) against one path component, by Unicode code point and dynamic programming: O(pattern × name), no regex backtracking. */
+function componentMatch(glob: string, name: string): boolean {
+ const g = Array.from(glob);
+ const s = Array.from(name);
+ const nextClose = new Array(g.length + 1).fill(-1);
+ for (let i = g.length - 1; i >= 0; i--) nextClose[i] = g[i] === ']' ? i : nextClose[i + 1]!;
+ const tokens: Array<'*' | ((c: string) => boolean)> = [];
+ for (let i = 0; i < g.length; i++) {
+ const ch = g[i]!;
+ const negate = ch === '[' && /[!^]/.test(g[i + 1] ?? '');
+ const close = ch === '[' ? nextClose[i + (negate ? 3 : 2)] ?? -1 : -1;
+ if (ch === '*') tokens.push('*');
+ else if (ch === '?') tokens.push(() => true);
+ else if (close > 0) {
+ const body = g.slice(i + (negate ? 2 : 1), close).map(c => c.codePointAt(0)!);
+ tokens.push(c => {
+ const p = c.codePointAt(0)!;
+ let hit = false;
+ for (let j = 0; j < body.length; j++) {
+ if (body[j + 1] === 0x2d && j + 2 < body.length) { hit ||= p >= body[j]! && p <= body[j + 2]!; j += 2; } else hit ||= p === body[j];
+ }
+ return hit !== negate;
+ });
+ i = close;
+ } else tokens.push(c => c === ch);
+ }
+ let row = s.map(() => false).concat(false);
+ row[0] = true;
+ for (const t of tokens) {
+ const next = row.map(() => false);
+ next[0] = t === '*' && row[0]!;
+ for (let j = 1; j <= s.length; j++) next[j] = t === '*' ? row[j]! || next[j - 1]! : row[j - 1]! && t(s[j - 1]!);
+ row = next;
+ }
+ return row[s.length]!;
+}
+/** A Cargo `members` glob against a workspace-relative crate directory, component by component; `**` spans any number of components. */
+function memberMatch(glob: string, rel: string): boolean {
+ const p = rel.split('/').filter(Boolean);
+ let row = p.map(() => false).concat(false);
+ row[0] = true;
+ for (const part of glob.split('/').filter(Boolean)) {
+ const next = row.map(() => false);
+ next[0] = part === '**' && row[0]!;
+ for (let j = 1; j <= p.length; j++) next[j] = part === '**' ? row[j]! || next[j - 1]! : row[j - 1]! && componentMatch(part, p[j - 1]!);
+ row = next;
+ }
+ return row[p.length]!;
+}
+/**
+ * Past this many characters of `members` globs a workspace's membership is undecided: every crate under it counts as a
+ * member for dependency edges (more tests, never fewer), but where its tests run is unknown, so they are unclaimed and the
+ * lane is incomplete (running from the wrong root can test a same-named member instead).
+ */
+const MAX_MEMBER_GLOB_CHARS = 4096;
+const inside = (dir: string, root: string) => root === '' || dir === root || dir.startsWith(`${root}/`);
+const relTo = (dir: string, root: string) => (root === '' ? dir : dir === root ? '' : dir.slice(root.length + 1));
+
+/** Every dependency of a manifest: `[*dependencies]` and `[target..*dependencies]`, `{ workspace = true }` read from `ws`'s `[workspace.dependencies]` (its paths relative to the workspace root). */
+function depSpecs(m: Manifest, ws: Manifest | null): DepSpec[] {
+ const tables: Table[] = [];
+ for (const key of DEP_TABLES) if (isTable(m.toml[key])) tables.push(m.toml[key] as Table);
+ if (isTable(m.toml.target)) for (const cfg of Object.values(m.toml.target)) if (isTable(cfg)) for (const key of DEP_TABLES) if (isTable(cfg[key])) tables.push(cfg[key] as Table);
+ const inherited = ws && isTable(ws.toml.workspace) && isTable(ws.toml.workspace.dependencies) ? ws.toml.workspace.dependencies : {};
+ const out: DepSpec[] = [];
+ for (const table of tables) {
+ for (const [key, raw] of Object.entries(table)) {
+ const fromWorkspace = isTable(raw) && raw.workspace === true;
+ const spec = fromWorkspace ? inherited[key] : raw;
+ const renamed = [spec, raw].find(s => isTable(s) && typeof s.package === 'string') as Table | undefined;
+ const p = isTable(spec) && typeof spec.path === 'string' ? joinRel(fromWorkspace ? ws!.dir : m.dir, spec.path) : null;
+ out.push({ path: p, name: (renamed?.package as string | undefined) ?? key });
+ }
+ }
+ return out;
+}
+
+/** The tracked crates among `files` (the paths `git ls-files` reports), what each depends on, and the workspace each runs in. */
+export function cargoModel(files: readonly string[], read: ReadFile): CargoModel {
+ const manifests = new Map();
+ for (const f of files) {
+ if (path.posix.basename(f) !== 'Cargo.toml') continue;
+ const text = read(f);
+ if (text === null) continue;
+ try { const toml = Bun.TOML.parse(text); if (isTable(toml)) manifests.set(dirOf(f), { dir: dirOf(f), toml }); } catch { /* an unparsable manifest names no crate */ }
+ }
+ const nameOf = (dir: string) => { const pkg = manifests.get(dir)?.toml.package; return isTable(pkg) && typeof pkg.name === 'string' ? pkg.name : null; };
+ const roots = [...manifests.keys()].filter(d => nameOf(d) !== null);
+ const workspaces = [...manifests.values()].filter(m => isTable(m.toml.workspace));
+ const byName = new Map();
+ for (const r of roots) byName.set(nameOf(r)!, [...(byName.get(nameOf(r)!) ?? []), r]);
+
+ // `package.workspace` names the workspace; otherwise Cargo consults only the nearest [workspace] at or above the crate.
+ const candidate = (root: string): Manifest | null => {
+ const pkg = manifests.get(root)!.toml.package as Table;
+ if (typeof pkg.workspace === 'string') { const w = manifests.get(joinRel(root, pkg.workspace) ?? '\0'); return w && isTable(w.toml.workspace) ? w : null; }
+ return workspaces.filter(w => inside(root, w.dir)).sort((a, b) => b.dir.length - a.dir.length)[0] ?? null;
+ };
+ const memberCache = new Map>();
+ const undecided = new Set();
+ const membersOf = (ws: Manifest): Set => {
+ const cached = memberCache.get(ws.dir);
+ if (cached) return cached;
+ const conf = ws.toml.workspace as Table;
+ const norm = (g: string) => joinRel('', g) ?? '\0';
+ const globs = strings(conf.members).map(norm);
+ const exclude = strings(conf.exclude).map(norm);
+ const own = new Set(roots.filter(r => candidate(r)?.dir === ws.dir));
+ const excluded = (r: string) => exclude.some(e => { const rel = relTo(r, ws.dir); return rel === e || rel.startsWith(`${e}/`); });
+ // Cargo's rules: the root package and a literally listed member always belong; a glob match or a path dependency of a
+ // member inside the workspace belongs unless `exclude` names it or a directory above it.
+ const literal = (r: string) => globs.some(g => !/[*?[]/.test(g) && relTo(r, ws.dir) === g);
+ const oversized = globs.join('').length > MAX_MEMBER_GLOB_CHARS;
+ const members = new Set([...own].filter(r => r === ws.dir || literal(r) || ((oversized || globs.some(g => memberMatch(g, relTo(r, ws.dir)))) && !excluded(r))));
+ if (oversized) for (const r of members) if (r !== ws.dir && !literal(r)) undecided.add(r);
+ memberCache.set(ws.dir, members);
+ const queue = [...members];
+ while (queue.length) {
+ for (const dep of depSpecs(manifests.get(queue.shift()!)!, ws)) {
+ if (dep.path === null || members.has(dep.path) || !own.has(dep.path) || !inside(dep.path, ws.dir) || excluded(dep.path)) continue;
+ members.add(dep.path);
+ queue.push(dep.path);
+ }
+ }
+ return members;
+ };
+
+ // A path names one crate; without a path (or when it names no tracked crate) every crate of that package name is a candidate.
+ const resolve = (specs: DepSpec[]) => [...new Set(specs.flatMap(s => (s.path !== null && nameOf(s.path) !== null ? [s.path] : byName.get(s.name) ?? [])))];
+ const crates = new Map();
+ for (const root of roots) {
+ const ws = candidate(root);
+ const member = ws !== null && membersOf(ws).has(root) ? ws : null;
+ crates.set(root, { root, name: nameOf(root)!, workspace: undecided.has(root) ? null : member?.dir ?? root, deps: resolve(depSpecs(manifests.get(root)!, member)).filter(d => d !== root) });
+ }
+ const crateOf = (file: string): Crate | null => {
+ for (let d = path.posix.dirname(file); ; d = path.posix.dirname(d)) {
+ const c = crates.get(d === '.' ? '' : d);
+ if (c) return c;
+ if (d === '.' || d === '/' || d === '') return null;
+ }
+ };
+ return { crates, crateOf };
+}
+
+/**
+ * Whether Rust source declares tests: a `#[test]` or `#[::test]` attribute (a plain test function, `#[tokio::test]`),
+ * or a `#[cfg(...)]` whose predicate names `test` outside a string (`cfg(test)`, `cfg(all(test, unix))`; never
+ * `cfg(feature = "test")`). One forward scan: each attribute is read once to its `]`, and one this scan cannot close
+ * (an unterminated string, no `]` before the end) counts as a test, which selects more, never fewer.
+ */
+export function hasCfgTest(source: string): boolean {
+ if (/#\s*\[\s*(?:[A-Za-z_]\w*\s*::\s*)*test\s*[\]()]/.test(source)) return true;
+ const open = /#\s*\[\s*cfg\s*\(/g;
+ for (let m = open.exec(source); m; m = open.exec(source)) {
+ let predicate = '';
+ let i = open.lastIndex;
+ for (; i < source.length && source[i] !== ']'; i++) {
+ if (source[i] !== '"') { predicate += source[i]; continue; }
+ const close = source.indexOf('"', i + 1);
+ if (close < 0) return true;
+ predicate += '""';
+ i = close;
+ }
+ if (i >= source.length) return true;
+ const body = predicate.trimEnd();
+ if (body.endsWith(')') && /\btest\b/.test(body.slice(0, -1))) return true;
+ open.lastIndex = i + 1;
+ }
+ return false;
+}
+
+export interface RustGraph {
+ tests: string[];
+ edges: Array<{ dependency: string; dependent: string; via: 'import' | 'convention' }>;
+}
+
+/**
+ * The Rust part of the reverse graph over the tracked `files`: each crate's
+ * test files, an edge from every `.rs` file of a crate to its Cargo.toml (the
+ * crate's node), and an edge from each Cargo.toml to the tests of its crate
+ * (`convention`) and of every crate depending on it, transitively (`import`).
+ */
+export function rustEdges(files: readonly string[], read: ReadFile): RustGraph {
+ const { crates, crateOf } = cargoModel(files, read);
+ const out: RustGraph = { tests: [], edges: [] };
+ if (!crates.size) return out;
+ const testsOf = new Map([...crates.keys()].map(k => [k, []]));
+ const manifestOf = (root: string) => (root ? `${root}/Cargo.toml` : 'Cargo.toml');
+ for (const f of files) {
+ if (!f.endsWith('.rs')) continue;
+ const crate = crateOf(f);
+ if (!crate) continue;
+ out.edges.push({ dependency: f, dependent: manifestOf(crate.root), via: 'convention' });
+ if (f.startsWith(crate.root ? `${crate.root}/tests/` : 'tests/') || hasCfgTest(read(f) ?? '')) { out.tests.push(f); testsOf.get(crate.root)!.push(f); }
+ }
+ const dependents = new Map>();
+ for (const c of crates.values()) for (const d of c.deps) dependents.set(d, (dependents.get(d) ?? new Set()).add(c.root));
+ for (const root of crates.keys()) {
+ const reach = new Set([root]);
+ const queue = [root];
+ while (queue.length) for (const next of dependents.get(queue.shift()!) ?? []) if (!reach.has(next)) { reach.add(next); queue.push(next); }
+ for (const r of reach) for (const t of testsOf.get(r)!) out.edges.push({ dependency: manifestOf(root), dependent: t, via: r === root ? 'convention' : 'import' });
+ }
+ return out;
+}
diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts
index 37512dee4..b93fbe140 100644
--- a/lib/pregate/types.ts
+++ b/lib/pregate/types.ts
@@ -33,6 +33,12 @@ export interface PregateContext {
touched: TouchedFile[];
config: PregateConfig;
configLabel: string;
+ /** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */
+ configFile: string;
+ /** sha256 of the user-owned policy's bytes when it is active; recorded as `inputs.policy` (a base copy is pinned by `inputs.base`). */
+ configDigest?: string;
+ /** The user-owned policy path looked for and absent under the defaults; recorded so verify can tell when one appears. */
+ absentUserPolicy?: string;
registry: Registry;
registryLabel: string;
lanes: Lane[];
diff --git a/lib/restamp.ts b/lib/restamp.ts
index 4824b3059..8980d5d27 100644
--- a/lib/restamp.ts
+++ b/lib/restamp.ts
@@ -68,7 +68,7 @@ export interface RestampResult {
function sha256(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex'); }
function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): { ok: boolean; out: string; err: string; status: number | null } {
- const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env });
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
return { ok: r.status === 0, out: (r.stdout ?? '').replace(/\n$/, ''), err: (r.stderr ?? '').trim(), status: r.status };
}
class Refusal extends Error { constructor(public code: ResultCodeName, message: string, public extra: Partial = {}) { super(message); } }
@@ -578,7 +578,9 @@ function gateAhead(o: RestampOptions, plan: Plan, stages: StageRecord[], common:
if (resolvable) {
for (const c of conflicts) {
if (c === plan.policy.changelog) {
- const merged = mergeChangelogs(git(dir, ['show', `:2:${c}`]).out + '\n', git(dir, ['show', `:3:${c}`]).out + '\n');
+ const stage = (n: number) => git(dir, ['show', `:${n}:${c}`]);
+ const [base, ours, theirs] = [stage(1), stage(2), stage(3)];
+ const merged = ours.ok && theirs.ok ? mergeChangelogs(ours.out + '\n', theirs.out + '\n', base.ok ? base.out + '\n' : undefined) : null;
if (merged === null) { unresolved = [c]; break; }
fs.writeFileSync(path.join(dir, c), merged);
} else git(dir, ['checkout', '--theirs', '--', c]);
diff --git a/lib/result-codes.ts b/lib/result-codes.ts
index cd5a5f74e..d1e09b56f 100644
--- a/lib/result-codes.ts
+++ b/lib/result-codes.ts
@@ -166,7 +166,7 @@ export const RESULT_CODES = {
// C-stamp (plan C1, C2, C3, C5, C7): ship policy, restamp, tree receipt, ship receipt.
POLICY_SOURCE_UNAVAILABLE: {
anchor: 'policy-source-unavailable',
- summary: 'policy source unavailable: origin/ is not a resolvable commit, so the reviewed .gstack/ship-policy.json cannot be read',
+ summary: 'policy source unavailable: origin/ is not a resolvable commit, or a pin file committed on it cannot be read, so the reviewed .gstack/ship-policy.json cannot be read',
fix: 'git fetch origin --depth=1 (the working-tree copy is never used silently)',
},
POLICY_INVALID: {
@@ -364,7 +364,7 @@ export const RESULT_CODES = {
},
PREGATE_CONFIG_INVALID: {
anchor: 'pregate-config-invalid',
- summary: '.gstack/pregate.json has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob',
+ summary: 'the pregate policy (.gstack/pregate.json, or the user-owned copy) has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob',
fix: 'run `gstack-pregate validate` and fix the file on the base branch (secrets and required lanes cannot be downgraded)',
},
PREGATE_SECRETS: {
diff --git a/lib/review-evidence.ts b/lib/review-evidence.ts
index 52586833d..418d08ab5 100644
--- a/lib/review-evidence.ts
+++ b/lib/review-evidence.ts
@@ -7,6 +7,7 @@ import { resultLine, type ResultCodeName } from './result-codes';
const DIFF_REVIEWS = new Set(['review', 'adversarial-review', 'codex-review', 'design-review-lite', 'ship']);
const SHARED_LIBS_COVERAGE_VERSION = 1;
+const HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
function record(value: unknown): value is Record {
return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -163,18 +164,54 @@ export function checkSharedLibsReuse(finding: unknown, token: string, env = proc
return result;
}
-export function captureReviewStart(skill: string, env = process.env): string {
+/**
+ * #3339: a fix-range repeat reviews only what changed since the latest pass of
+ * the same skill, and only when that pass completed and made fixes on this
+ * branch, no Step 11 review followed it, and the content it started from is
+ * exactly its commit's tree. That commit, held in the review log rather than
+ * the working tree, is the base; it must still be an ancestor of HEAD, so
+ * `git diff ` holds every fix. Anything else needs a full pass.
+ */
+function fixRangeBase(skill: string, env: NodeJS.ProcessEnv): string {
+ const refuse = (reason: string): never => {
+ throw new Error(`gstack-review-log: fix-range refused: ${reason}; run a full pass (gstack-review-log --start ${skill})`);
+ };
+ let rows: Record[] = [];
+ try {
+ rows = readFileSync(env.GSTACK_REVIEW_LOG!, 'utf8').split('\n').filter(Boolean).flatMap(line => {
+ try { const row = JSON.parse(line); return record(row) ? [row] : []; } catch { return []; }
+ });
+ } catch {}
+ const at = rows.findLastIndex(row => row.skill === skill);
+ if (at < 0) refuse('no earlier pass on this branch');
+ if (rows.slice(at + 1).some(row => row.skill === 'adversarial-review' || row.skill === 'codex-review')) refuse('a Step 11 review followed the latest pass');
+ const prior = rows[at]!, binding = prior.review_binding;
+ if (prior.completed !== true) refuse('the latest pass did not complete');
+ if (prior.converged !== false) refuse('the latest pass made no fixes');
+ if (!record(binding) || (binding.state !== 'changed' && binding.state !== 'incomplete') || typeof binding.start_wtree !== 'string') refuse('the latest pass has no captured start');
+ if (binding.branch_id !== sha256(env.GSTACK_REVIEW_BRANCH ?? '')) refuse('the latest pass is from another branch');
+ const base = prior.commit_full;
+ const git = (...args: string[]) => spawnSync('git', args, { cwd: env.GSTACK_REVIEW_REPO, encoding: 'utf8', timeout: 10_000 });
+ if (typeof base !== 'string' || !HASH.test(base) || git('cat-file', '-e', `${base}^{commit}`).status !== 0) refuse('the latest pass has no resolvable commit');
+ if (git('rev-parse', `${base}^{tree}`).stdout.trim() !== binding.start_wtree) refuse('the latest pass started from uncommitted content, so no commit marks what it read');
+ if (git('merge-base', '--is-ancestor', base, 'HEAD').status !== 0) refuse(`${base} is not an ancestor of HEAD`);
+ return base;
+}
+
+export function captureReviewStart(skill: string, env = process.env, fixRange = false): string {
if (!DIFF_REVIEWS.has(skill) || !env.GSTACK_STAMP_WTREE || !env.GSTACK_REVIEW_REPO) {
throw new Error('cannot capture a diff review without a working-tree fingerprint');
}
+ const fixBase = fixRange ? fixRangeBase(skill, env) : undefined;
const dir = join(env.GSTACK_REVIEW_DIR!, '.review-starts');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const token = crypto.randomUUID();
writeFileSync(join(dir, `${token}.json`), JSON.stringify({
skill, repo: env.GSTACK_REVIEW_REPO, branch: env.GSTACK_REVIEW_BRANCH,
wtree: env.GSTACK_STAMP_WTREE, started_at: new Date().toISOString(),
+ ...(fixBase ? { scope: 'fix-range', fix_base: fixBase } : {}),
}), { mode: 0o600, flag: 'wx' });
- return token;
+ return fixBase ? `${token}\nFIX_BASE=${fixBase}` : token;
}
const GATE_ACTIONS = new Set(['kept', 'restored', 'open']);
@@ -226,7 +263,7 @@ export function deriveFromFindings(rec: Record, findingsPath: strin
}
export function bindReview(rec: Record, token: string, env = process.env, findingsPath?: string): Record {
- for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file']) delete rec[key];
+ for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key];
// The findings file is validated BEFORE the start token is consumed below:
// a rejected row must leave the token for the retry.
rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' };
@@ -256,11 +293,19 @@ export function bindReview(rec: Record, token: string, env = proces
const state = !start || !end ? 'uncaptured'
: start.wtree !== end ? 'changed'
: rec.completed !== true || rec.converged !== true ? 'incomplete' : 'verified';
- rec.review_binding = {
- state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at,
- ...(typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {}),
- };
- if (state === 'verified') rec.wtree = end;
+ const branchId = typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {};
+ const narrowed = start?.scope === 'fix-range' || start?.fix_base !== undefined;
+ if (narrowed) {
+ rec.scope = 'fix-range';
+ if (typeof start.fix_base === 'string' && HASH.test(start.fix_base)) rec.fix_base = start.fix_base;
+ }
+ // A narrowed pass on unchanged content never carries start_wtree/end_wtree,
+ // so Step 11.5 cannot bind it and it never becomes CURRENT; only a later full pass can.
+ rec.review_binding = narrowed && (state === 'incomplete' || state === 'verified')
+ ? { state: 'fix-range', fix_base: rec.fix_base, range_start_wtree: start.wtree, range_end_wtree: end, started_at: start.started_at, ...branchId }
+ : { state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at, ...branchId };
+ const verified = rec.review_binding.state === 'verified';
+ if (verified) rec.wtree = end;
if (rec.skill === 'review' && Array.isArray(rec.findings)) {
rec.shared_libs_coverage_version = SHARED_LIBS_COVERAGE_VERSION;
for (const finding of rec.findings) {
@@ -270,7 +315,7 @@ export function bindReview(rec: Record, token: string, env = proces
const fingerprint = sharedLibsFingerprint(finding);
if (!fingerprint) continue;
finding.fingerprint = fingerprint;
- finding.snapshot_covered_paths = state === 'verified' && finding.action === 'skipped'
+ finding.snapshot_covered_paths = verified && finding.action === 'skipped'
? sharedLibsSnapshotCoverage(env.GSTACK_REVIEW_REPO!, end!, finding.evidence_paths, env) : [];
}
}
@@ -282,6 +327,7 @@ export function reviewFreshness(rec: Record, currentWtree: string):
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
const binding = rec.review_binding;
if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' };
+ if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' };
if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true ||
!rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) {
return { status: 'UNVERIFIED', reason: 'missing start capture or incomplete/nonconverged pass' };
diff --git a/lib/ship-queue-gate.ts b/lib/ship-queue-gate.ts
index 67c6e51cb..9e42bcf4d 100644
--- a/lib/ship-queue-gate.ts
+++ b/lib/ship-queue-gate.ts
@@ -99,7 +99,8 @@ export function recomputeMergeTree(repoRoot: string, branchHead: string, predece
if (!ours || !theirs) return { error: `${file} conflicts beyond a content merge (added or deleted on one side)` };
if (file === policy.changelog) {
const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n';
- const merged = mergeChangelogs(text(ours), text(theirs));
+ const base = entry.get(1);
+ const merged = mergeChangelogs(text(ours), text(theirs), base ? text(base) : undefined);
if (merged === null) return { error: `${file} conflicts beyond a heading move` };
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged);
if (!blob.ok) return { error: `cannot write the merged ${file}: ${blob.err}` };
diff --git a/lib/ship-queue-train-cli.ts b/lib/ship-queue-train-cli.ts
index 03c83c780..cc41f5988 100644
--- a/lib/ship-queue-train-cli.ts
+++ b/lib/ship-queue-train-cli.ts
@@ -15,7 +15,9 @@ import { trainBisect, trainRebuild, trainVerdict } from './ship-queue-train-verd
import { verifyTrain } from './ship-queue-train-verify';
import { redactCause } from './ship-queue-train-record';
-export const TRAIN_HELP = ` train build --prs @[,...] [--urgent | --repair] [--dry-run] [--free-timeout ]
+export const TRAIN_HELP = ` train build --prs @[,...] [--order ,...] [--urgent | --repair] [--dry-run] [--free-timeout ]
+ --order: explicit slot order, every PR in --prs once (default: screened
+ first, then READY order)
train build --train resume a train (push, free tests, dispatch) from its record
merge pinned unstamped heads in slot order onto main, free tests on
train/, then one full gate through eval-bg's nonce
@@ -77,8 +79,9 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise,... orders a new train from --prs; a resumed train keeps its recorded order');
return guarded('train build', () => buildTrain(ctx, {
- pins: flag(rest, '--prs'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
+ pins: flag(rest, '--prs'), order: flag(rest, '--order'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000,
}));
}
diff --git a/lib/ship-queue-train-ready.ts b/lib/ship-queue-train-ready.ts
index 12102394e..43f24fc29 100644
--- a/lib/ship-queue-train-ready.ts
+++ b/lib/ship-queue-train-ready.ts
@@ -14,6 +14,7 @@ import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, awaitNonceRun, dedupeKey, dispatchWorkflow, findDuplicateRun, listDispatchRuns, makeNonce, type WorkflowDispatch } from '../scripts/eval-bg';
import { EXIT } from './headless-artifacts';
import { ready, type QueueContext } from './ship-queue';
+import { classifyTrainHead } from './version-gate-train';
import { git } from './ship-queue-gate';
import { execFor, repoSlug } from './ship-queue-train-github';
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
@@ -100,6 +101,8 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise; superseded_by: string | null;
effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string;
}
@@ -87,11 +87,34 @@ const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json');
const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`);
export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`);
-/** The repository's identity: origin's URL, keyed so records of different repositories never mix. */
-export function repoIdentity(repoRoot: string): { key: string; url: string } {
- const url = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot;
- const norm = url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, '');
- return { key: sha256(norm).slice(0, 16), url: norm };
+/** Hosts that serve GitHub repositories at /