diff --git a/CHANGELOG.md b/CHANGELOG.md index f7fdb7fed..9395f3d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,7 +72,7 @@ ## [1.91.272.0] - 2026-10-27 ### Added -- **Four lazily read passes absorbed from pstack** (Lauren Tan, MIT; ideas rewritten in gstack's words, NOTICE.md; Garry, 2026-10-26: "Do it, send"). `/investigate` gains `sections/blast-radius.md`, read before fixing a shared helper, library call or data format or when asked what a change could break: name the one fact the change is safe because of, climb the five-rung evidence ladder (said so, pointed at it, walked the failure, ran it, reproduced it) and say where you stopped, look where grep stops (library source at the pinned version, timing, the JSON an API returns, a column, a wire format, code three hops away), and list confirmed and cleared risks apart; Phase 3's red flags add the sharper trigger before the 3-strike rule: two fixes that shared one premise and failed the same gate mean the premise is written down and tested before a third fix. `/context-restore` is carved for the first time: when Step 1 prints `NO_CHECKPOINTS`, `sections/transcripts.md` rebuilds the brief from this workspace's own transcripts (Claude Code `~/.claude/projects//*.jsonl`, Codex `~/.codex/sessions/` filtered by cwd, Capy none, from a `transcripts` table in `hosts/*.ts` rendered by the new `{{TRANSCRIPT_SOURCES}}` and `{{TRANSCRIPT_DISCOVERY}}` resolvers), a fixed window (7 days by default, never quietly narrowed), never another project's folder, cheap subagents that return only findings, redaction before anything leaves the machine, git and gh verifying every PR and branch, and a Capsule / Threads (one status tag each) / Problems / Next move brief with an offer to save it as a checkpoint. `/benchmark` is carved for the first time: `sections/vet-the-number.md`, read before Phase 5 reports any comparison, asks the seven questions (why not double: name the limiter; was it tuned; did it break a limit; did it error; does it reproduce over at least 5 alternating runs, a gap smaller than the run-to-run variation being no difference; does it matter end to end; did the work even hap... (line truncated to 2000 chars) +- **Four lazily read passes absorbed from pstack** (Lauren Tan, MIT; ideas rewritten in gstack's words, NOTICE.md; Garry, 2026-10-26: "Do it, send"). `/investigate` gains `sections/blast-radius.md`, read before fixing a shared helper, library call or data format or when asked what a change could break: name the one fact the change is safe because of, climb the five-rung evidence ladder (said so, pointed at it, walked the failure, ran it, reproduced it) and say where you stopped, look where grep stops (library source at the pinned version, timing, the JSON an API returns, a column, a wire format, code three hops away), and list confirmed and cleared risks apart; Phase 3's red flags add the sharper trigger before the 3-strike rule: two fixes that shared one premise and failed the same gate mean the premise is written down and tested before a third fix. `/context-restore` is carved for the first time: when Step 1 prints `NO_CHECKPOINTS`, `sections/transcripts.md` rebuilds the brief from this workspace's own transcripts (Claude Code `~/.claude/projects//*.jsonl`, Codex `~/.codex/sessions/` filtered by cwd, Capy none, from a `transcripts` table in `hosts/*.ts` rendered by the new `{{TRANSCRIPT_SOURCES}}` and `{{TRANSCRIPT_DISCOVERY}}` resolvers), a fixed window (7 days by default, never quietly narrowed), never another project's folder, cheap subagents that return only findings, redaction before anything leaves the machine, git and gh verifying every PR and branch, and a Capsule / Threads (one status tag each) / Problems / Next move brief with an offer to save it as a checkpoint. `/benchmark` is carved for the first time: `sections/vet-the-number.md`, read before Phase 5 reports any comparison, asks the seven questions (why not double: name the limiter; was it tuned; did it break a limit; did it error; does it reproduce over at least 5 alternating runs, a gap smaller than the run-to-run variation being no difference; does it matter end to end; did the work even happen) and ends with a verdict of faster, slower, no measurable difference or inconclusive. `/plan-eng-review`'s lazy review section gains the pass "Two shapes before one (design-twice)" inside Section 1 (Architecture review), run only when the plan introduces a new module, service, package or boundary: a second structurally distinct shape compared on interface depth, and the eight design red flags written for agent contributors (shallow module, information leakage, temporal decomposition, pass-through method, split ownership, two ways to do one task, importable internals, hand-synced list); the autoplan runner's checklist maps the pass, and the skeleton, manifest and methodology bundle are unchanged. The transcript table each host renders names only that host's store (a Codex render never names `~/.claude/`). ### Tests - `test/context-restore-transcripts.test.ts` (new): the carve shape, the host table against `hosts/*.ts`, and the discovery block run against a fixture transcript (`test/fixtures/context-restore-transcript.jsonl`): it lists this workspace's files inside the window, not a 10-day-old one, never the sibling project's, prints none on Capy and for a missing folder, and the Codex render keeps only the rollout whose cwd is this workspace. `test/pstack-sections.test.ts` (new): benchmark's and plan-eng-review's carve shape and tokens, and the credit line plus NOTICE.md entry on every pstack-derived section. `test/investigate-recipes.test.ts`: the sixth section, its Phase 4 pointer and the shared-premise red flag. @@ -127,4 +127,15329 @@ **An autoplan gate now arrives with its price, and the approvals record lands where the whole crew can read it.** Captains reported the same two frictions: review rounds and reviewer subagents had no credit figure at the gate, and the approvals mirror sat in a private project-user folder no sibling thread could read. The Eng phase also gains the performance-budget question, after a correctness pass doubled a consumer's p95 with no bench step to catch it. (The standing "take the recommendations" answer planned beside these, `answer --owner-standing rec`, was cut from this release after review: a keyword grammar over the owner's words could still read a refusal as a grant, so it returns with a structured grant in its own change.) -### Added \ No newline at end of file +### Added +- **`GATE_SPEND:` and `GATE_REC_COST:`** (`lib/autoplan-gate-cost.ts`; printed by every gate-producing `gstack-autoplan` command under `GATE:`, by `run` when it stops at a gate, and as `gate_cost` in the `--json` payloads): the run's ledger at the gate (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced `unknown` and counted at their reservation, with a pointer to the host's own cost view instead of an invented figure) and the price of `rec` as an upper bound from the answer and revise rules (a plan change counts every phase `revise` can keep or add, since it reopens each phase whose snapshot the edit changes and re-detects scope, so a run that skipped Design or DX without `--no-ui`/`--no-dx` counts them too; a round grant counts its phase and Eng; the effective choice per item is parseReply's (an answer already recorded at this gate revision, else the carried choice, else the recommendation), a recorded plan rerun `p1d` prices every phase, a reject or stop nothing, and an override of the recommendation counts as `answer` counts it; two voices each with the outside voice on, at the per-voice reservation the run has been making; a reopen past the cap says so). `gstack-owner-brief render` carries the same figures as one `Cost so far:` sentence under its Summary, and `render --captain` carries them as a `### Cost` section above the relaying-agent trailer, so the person approving sees it. +- **The Captain-named approvals directory** (`lib/approval-record.ts` `captainApprovalsDir`): on Capy the mirror is `GSTACK_APPROVALS_DIR` when set, else the `approvals/` directory in the one mounted Captain drive (`~/.capy/drive/captain-*/approvals//.jsonl`, readable by every crew thread), else the private project-user folder as before; two Captain drives with one each is ambiguous and falls through. The other roots are still read (`read_also`), so a row mirrored under the earlier rule stays visible to `list` and `render`; `gstack-approvals path` prints which rule chose the mirror and the `READ_ALSO:` roots. + +### Changed +- **Eng phase, Section 4 (Performance)** (`autoplan/sections/eng-phase.md.tmpl`): a hot path with no performance budget or bench step is a finding, and the consensus table's row 3 asks "Perf risks + budget/bench named?". Five nearby phrases were shortened by the same number of characters, so every host's generated autoplan SKILL.md stays at its size-guard pin (raising a pin is an owner decision). +- **`autoplan/DRIVER.md`, `docs/capy-install.md`, `docs/unattended.md`, `docs/recommended-harness.md`, `capy/skill/gstack/captain.md`, `docs/troubleshooting.md`**: the loop relays the two cost lines with every gate render; the Captain sections name the crew-readable mirror and the `READ_ALSO:` roots; the brief descriptions carry the `### Cost` section; the `APPROVAL_RECORD_UNAVAILABLE` fix names the three mirror sources. `capy/skill/gstack/SKILL.md` is untouched here (it is #3475's file) and points at `autoplan/DRIVER.md` already. + +### Tests +- `test/autoplan-gate-cost.test.ts` (new): `recReopens` as the upper bound (plan change counts every phase revise can keep or add, a round grant its phase plus Eng, carried items only when changed, overrides, `p1d`/`p1c`/`p1e`), `priorChoices`, and the two cost lines from a ledger with an unknown host attempt (cap, no cap, over the cap, no ledger); the mirror chain (project-user, one Captain drive, two, `GSTACK_APPROVALS_DIR`, mirror off) and `gstack-approvals path` with `READ_ALSO:`; end-to-end runs through `bin/gstack-autoplan`: the closing submit prints `GATE_SPEND:`/`GATE_REC_COST:` (text) or `gate_cost` (`--json`), the owner brief carries the `Cost so far:` sentence, `render --captain` a `### Cost` section above its trailer, and `run` at a pending gate prints the gate lines once (one `--json` payload). + +### Gate integrity +- No threshold, rubric, trial count, case kind or judge prompt changed. The only prompt edit is the Eng phase's performance paragraph; the one paid autoplan case (`autoplan-dual-voice`) reads Phase 1, which is untouched. + +## [1.91.268.0] - 2026-10-27 + +**Two read-and-write helpers absorbed from pstack: a decision trail a reviewer reads without the transcript, and a worktree audit that tells you which Conductor worktrees are safe to remove.** Both are ports of Lauren Tan's MIT scripts (header credit in each file, `licenses/MIT-pstack.txt`, NOTICE.md), approved with the rest of the pstack absorb (Garry, 2026-10-26: "Do it, send"). + +### Added +- **`bin/gstack-decision-trail `**: appends one UTC-stamped row to a run's TSV decision trail, writing the header once and only ever appending; cells are flattened to one line and a cell that starts with `=`, `+`, `-` or `@` gets a leading quote so evidence lifted from a PR title or a filename cannot run as a formula in a reviewer's spreadsheet. `start ` appends a run's `start` row (naming the earlier rows and `run:`; the id is letters, digits and `. _ : @ / -`, so a tab or quote in it is a usage error rather than a row that breaks the idempotence check) and is a no-op while that row is the newest start row, so a rerun writes nothing twice; only a leading `-h`/`--help` is help, so a cell whose text is `-h` is appended as data; a trail without a final newline, with CRLF rows, a UTF-8 BOM, a repeated header line or no header at all is appended to correctly; `--run ` on an append refuses with `DECISION_TRAIL_START_REQUIRED` (exit 3) until the run has opened its stretch. The end-of-run rule, in `docs/unattended.md` ("The decision trail") and `autoplan/DRIVER.md`: audit the trail against the transcript and correct the log, not the story (a wrong row is superseded, never edited), then a reviewer on another model family reads it and the report ends with an `Attention` section (`reviewed by `, the flags, or `No flags`). +- **`bin/gstack-worktree-audit [-b ] [--no-fetch] [repo]`** (bash, read-only): one TSV row per git worktree beside the main one, largest first: size, age, merged into the base, `clean`/`wip:`/`scratch:`, `pushed`/`ahead`/`no-remote`/`detached`, the PR on its branch through `gh`'s own `--jq` (no `jq` needed), the newest Claude Code chat that ran in the worktree (`${CLAUDE_CONFIG_DIR:-~/.claude}/projects/`) and a bucket: `prunable` (registered, directory gone), `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe`, `review`. `safe` means nothing exists only in that worktree: a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed; an unpushed commit or an untracked file on a merged branch reads `review`. A fork's PR on a same-named branch is ignored and an open PR wins over an older closed one on a reused name. A locked entry whose directory is gone and a `broken` worktree whose own `.git` link is missing (git would otherwise describe the containing repository and hide its files) read `review`; a clean tree names its gitignored entries as `clean,ignored:` without changing the bucket; a branch name shared with a tag still resolves; worktree paths with leading, trailing or inner spaces are read whole. It never deletes or prunes; its one write is a best-effort fetch of the base ref that `--no-fetch` skips. `/landing-report` offers it when the sibling table shows stale worktrees; `docs/CONTRIBUTOR_REFERENCE.md` has the guide for Conductor users, `docs/standalone-binaries.md` lists both bins. + +### Tests +- `test/decision-trail.test.ts`: header once (an empty file gets one, a second append does not), the formula guard on every leading byte it covers, flattened tabs and line breaks, the start row's text and its idempotent rerun, a second run's start row, the `--run` refusal and admission, a `-h` cell appended as data, a missing final newline, CRLF rows and a repeated header, a blank file and a BOM, run ids with whitespace or quotes refused, usage errors. +- `test/worktree-audit.test.ts`: a bare origin, a clone and twelve worktrees (merged, wip with an open PR, scratch with a chat today, stale, closed PR, a merged one whose path has a space, a merged one with only an untracked file, a registered one whose directory is gone, a locked one whose directory is gone, a broken one inside the clone with a precious file, one whose branch shares its name with a tag, one with only a gitignored `.env`) classified into every bucket (also under `status.showUntrackedFiles=no`, which must not hide untracked files) through a fake `gh` (which records its arguments and runs the audit's own `--jq` over canned JSON with a fork PR when `jq` is installed) and a `CLAUDE_CONFIG_DIR` transcript directory; the fixture, its worktree registry included, is byte-identical after the audit; `-b`, `--no-fetch`, an unpushed commit on a closed PR reading `review` and an open PR on the reused name winning, no `gh` on PATH (git resolved from `git --exec-path`, so a distribution's wrapper script does not matter), a failing `gh`, a missing base and a non-repo path. + +### Gate integrity +- No threshold, timeout, trial count or case kind changed. No pinned skill changed; `landing-report/SKILL.md` grows by one line and is unpinned. `test/autoplan-driver.test.ts`'s line cap on `autoplan/DRIVER.md` moves 160 → 162 (measured 162) for the decision-trail paragraph at the end of the coordinator contract section; the file was at the cap before. + +## [1.91.267.0] - 2026-10-27 + +**The Captain guide says which questions never need to reach a person.** Follow-up to #3493 (pstack absorb item 4, GSTA-124). + +### Changed +- **`capy/skill/gstack/captain.md` section 1**: a new bullet after "Always a fresh ask": a question a command, render or measurement can settle is settled by the thread, which runs the thing and reports the result; only a product or preference call goes up, with the observation already made; a Captain that receives an observable question sends it back with the check that answers it. The Drive copy of `captain.md` needs the owner's recopy (or a Capy re-sync) to pick this up. + +## [1.91.266.0] - 2026-10-27 + +### Added +- **`bin/gstack-pr-wait`, `lib/pr-wait.ts`, `lib/pr-wait-github.ts`**: an event-driven pull-request watcher (design ported from tstack's pr-wait, MIT, Theo Browne; NOTICE.md, `licenses/MIT-tstack.txt`). `gstack-pr-wait [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES]` prints one stdout line per real event, prefixed `#`, and progress on stderr: a check failed (name and link), all checks finished on the head, a comment, review, thread or reply from someone other than the PR author or the gh user, a review bot finished on the head or a gating bot's summary changed, the base branch moved and its new commits touch this PR's files, merge state BEHIND or DIRTY, merged or closed. `green` gates on every check on the head, as `gstack-ci-gate` does, so Step 2 and Step 3 of `/land-and-deploy` never disagree; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission, and two identical polls confirm the check set. `ready` is green plus a gating bot's review of the head (green alone when no gating bot is configured), no unresolved thread, and a known merge state (GitHub's `UNKNOWN` right after a push holds it); a gating bot's failed check is a failed check, never a review. A bot owns the logins that start with its name (`greptile-apps[bot]`, not `notgreptile`) and the checks that mention it. `--timeout` (default 60 minutes, 0 for none) ends a watch that met nothing as blocked, naming the still-pending checks; at `/land-and-deploy` Step 3 that is a STOP showing the line, and the user decides whether to start again with a larger `--timeout` or `--timeout 0`. Exit 0 when the condition is met or the PR merged, 1 when it needs action (`PR_WAIT_BLOCKED`), 2 on bad input, an unreadable PR, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`; a lock whose pid is dead is taken over), so exit 1 always carries a `PR_WAIT_BLOCKED` line. The loop is synchronous and installs no signal handlers, so Ctrl-C and `kill` stop it at once. State lives under `/pr-wait/` (lib/state-root.ts chain), and a baseline older than 6 hours is discarded. Transient gh failures (5xx, 429, network, gh's own 60 s spawn timeout) retry with backoff; ten failed polls in a row end the watch as blocked. BEHIND and DIRTY are announced once per head (keyed on the head alone, so a base that keeps moving does not repeat them), with the short head in the line. A verdict whose state file cannot be written keeps its exit code; the save error goes to stderr. +- **`gstack-config` keys `pr_wait_gating_bots` (default `greptile,macroscope`) and `pr_wait_advisory_bots` (default `coderabbit`)**: which review bots `--until ready` waits for and which it only reports; comma-separated names matched against bot logins and check names, `none` empties a list, anything else is rejected with the value kept. +- **Result codes `PR_WAIT_BLOCKED` and `PR_WAIT_REFUSED`** (`lib/result-codes-release.ts`) with their [troubleshooting anchors](docs/troubleshooting.md#gstack-pr-wait); a `gstack-pr-wait` row in docs/standalone-binaries.md. + +### Changed +- **`/land-and-deploy` Step 3**: the 4-minute `gstack-ci-gate --wait 240` polling rounds with a 15-minute cap and a wait-more/exclude/stop question are replaced by one `gstack-pr-wait "$PR_NUMBER" --repo "$REPO" --until green` call; exit 0 reruns Step 2's gate on the pinned head, exit 1 stops with the watcher's lines (a red `required=n` check stays the one case Step 3.5 may approve), exit 2 shows the refusal. The Important Rules line about polling intervals names the watcher. The old 15-minute cap becomes the watcher's `--timeout` default of 60 minutes; the wait-more question is gone, a timeout stops Step 3 with the pending checks named. The generated skill is the same size on every pinned host, so no pin moved. + +### Tests +- `test/pr-wait.test.ts` (new): the fake-gh scripts for the baseline poll, the six-hour staleness and the restart carry, author/gh-user/bot/edit filtering, merged and closed, a required check pending by omission, a failed required check on the baseline poll, an unknown bucket as failed, two identical polls before green, ready with a gating bot and an advisory bot, unresolved threads and BEHIND blocking ready, the base-overlap event and its silent negative, BEHIND once per head and DIRTY, the configured bot lists and `none`, the per-PR lock (live holder refused, dead holder taken over, released on exit), argument parsing, transient retry and the 403 on classic protection, and the bin's shebang; from the /ship review pass: every check gating green (a pending or red non-required check), a failed gating-bot check, prefix-matched bot logins, `ready` with no gating bot and while the merge state is UNKNOWN, `--timeout`, the ten-failure and non-transient `run()` endings, a gh spawn timeout retried, a local fault as exit 2, control characters in check names, `gstack-config` rejecting malformed bot lists and its defaults matching the lib constants, a real SIGTERM against the bin, and the fourteen state-matrix cells the state-machines specialist listed (ready × fail, any-change × pass/pending/fail, BEHIND and DIRTY × any-change, DIRTY × ready, UNKNOWN × green/ready/any-change, the free and dead-pid unlocks, a restarted watcher on a failed required check and on merged). `test/land-and-deploy-ci-gate.test.ts` asserts Step 3 is one watcher call with the three exit outcomes and no polling prose. + +### Gate integrity +- No threshold, timeout, trial count or case kind changed. `test/fixtures/module-size-ratchet.json` registers `lib/pr-wait.ts` and `lib/pr-wait-github.ts` under the existing 800-line cap. `test/fixtures/skill-md-size-allowlist.json` is unchanged: the land-and-deploy render is byte-for-byte the pinned size on every host. + +## [1.91.265.0] - 2026-10-27 + +**Four more principles, and three places gstack stops asking what it could observe (pstack absorb, lane C3, GSTA-124).** Ideas from Lauren Tan's pstack (MIT), written in gstack's words; NOTICE.md and the ETHOS.md credit line say so. + +### Added +- **ETHOS.md principles 5 to 8**: Attack the Premise (two fixes that share a premise and fail the same gate indict the premise; write it down and falsify it before a third fix), Separate Before Serializing Shared State (a lock is a smell to check; remove the race before refereeing it), Make Operations Idempotent (design for the run that happens twice; ask what happens if the previous run died at every point), Explain the Number (a measurement carries what was measured, the limiter it moved and the run-to-run variation; a gap inside the variation is no difference). "How They Work Together" gains one line each and a Credits section names the source. +- **`/eval-plan` Step 1c, candidate-side blinding**: three preregistration rules (no eval word anywhere the candidate can read; project-shaped fixture roots; the judge sees `A`/`B` labels and grades chain-following from the files the transcript shows the candidate opened). `prereg --check` does not read fixture trees, and the step says so. `docs/TESTING_INTERNALS.md#candidate-side-blinding` states the same rules for gstack's own fixtures, lists the fixture directory names on the word list and the candidate-visible temporary roots it found (`arm---*` from `test/helpers/arm-benchmark-harness.ts`, `skill-e2e-benchmark-*` from `test/skill-e2e-deploy.test.ts`, the `skill-e2e--*` roots of `test/skill-e2e-test-value.test.ts` whose ids carry `test`, `gstack-health-eval-*` from `test/skill-e2e-health.test.ts`; the list is a scan of `mkdtemp` prefixes in e2e tests, not a proof of completeness); no fixture is renamed here. +- **Observable questions are not the user's to answer**: a rule in `/plan-tune`'s Important Rules and a section in `docs/proactive.md`. A question a local, read-only, free command, render or measurement can settle is settled that way and reported (a call that changes state, reaches a live or third-party system or spends money stays a question); only product and preference calls reach the user, with the observation already made. The question-log audit treats an observable question as a defect in the asking skill rather than a preference to tune. + +### Changed +- **AGENTS.md validation discipline step 4**: one sentence on measured deltas: name the limiter that moved and the run-to-run variation; a gap smaller than that variation is no difference. `agents-digest/gstack-AGENTS.md` is generated from `scripts/gen-agents-digest.ts` and `VERSION`, not from AGENTS.md, so the regenerated digest is unchanged. + +### Gate integrity +- No threshold, trial count, case kind or judge prompt changed. Prose-only change: `plan-tune` and `eval-plan` are unpinned skills; sizes measured in the PR body. + +## [1.91.264.0] - 2026-10-27 + +**/design-motion turns an approved design into a showcase loop for X and the website; adapted from Elaya's design-to-motion (GSTA-92 plan item 15, Garry 2026-10-25 "Do it").** gstack could design a screen and build it, but not show it moving: a screen recording drops frames, jumps at the loop seam, and nothing tells you a card drifted out of its container. /design-motion rebuilds the exact frame from a /design-shotgun PNG, a /design-html `finalized.html` or a URL (a Figma link when `FIGMA_TOKEN` is set), loads it from an empty state, gives each visual one story, records frames on a fake clock, and exports only after the loop is proven seamless. + +### Added +- **/design-motion** (new skill; sections `fidelity`, `motion`, and `figma` read only with a Figma token, which reaches curl on stdin so it never appears in a process list). Showcase context: every visual gets a story, the cycle ends in a dissolve and an empty beat. Website context: gstack's rules win, so one authored motion moment per page, content complete without script and under reduced motion, an `IntersectionObserver` start, and no pulse, marquee or caret left running. +- **`bin/gstack-motion-capture.ts`** (`lib/motion-capture.ts`): Playwright with its fake clock and gstack's own Chromium (`GSTACK_CHROMIUM_PATH` honoured). Steps are whole milliseconds averaging 1000/60, so the seam frame is exactly one cycle after the window start. The page contract is `window.__start()`, `window.__tick(dt)` and `window.__lt`, with 15 PRE frames before the start. Writes `frames/`, `still.png`, an optional container `mask.png` and `capture.json`. +- **`bin/gstack-motion-encode`** (`lib/motion-encode.ts`): `check` prints `MOTION_CHECK:` lines for seam, empty beat, rest and overflow (ffmpeg PSNR, identical means `inf`), writes a contact sheet, and ends `MOTION_RESULT: clean` or ` failed`. `export` writes `-x.mp4` (H.264, CRF 15, 60 fps), `-loop.gif` (960 px, 20 fps) and `-800x500.gif` with Elaya's recipes. `compare` gives the still-fidelity PSNR. ffmpeg is optional: without it the bin prints `FFMPEG_MISSING` with install commands and exits 3. +- Both bins refuse bad input before any work: `gstack-motion-encode` exits 2 on an unknown option or `--formats` token, a `--pad` that is not a 6- or 8-digit hex colour, a `--crop` that is not `w:h:x:y` (`compare` included, so nothing but a colour or a crop reaches ffmpeg's filter graph) and a capture whose frames are missing. `check` prints a `MOTION_SETTINGS:` line naming any threshold or phase override, so a relaxed run never looks like a strict one, a capture without a mask ends `MOTION_RESULT: clean (overflow not checked)`, and the contact sheet no longer needs ffmpeg 5.1's `-fps_mode`. `gstack-motion-capture.ts` refuses a `--cycle` that is not a multiple of 0.05 s (three 60 fps frames; only then does the seam land exactly one cycle later for every PRE) and an `--out` that holds a still or frames but not its `.gstack-motion-capture` marker, so a rerun after a failed capture works and a foreign directory is never cleared. `check` and `export` refuse a `capture.json` whose fields are not what the capture writes (they reach ffmpeg arguments and paths), and a cards overflow check with no story frames is an error instead of a pass. When an app frame's own surfaces cover more than 90% of the frame (full bleed), overflow is reported as skipped and the run ends `clean (overflow not checked)`; the same coverage in a cards layout, or reached only through `--mask-grow`, fails overflow instead of passing by blacking out everything; a `--mask` that matches no element stops the capture with `MOTION_MASK_EMPTY`; `--mask-grow` is a whole number from 0 to 32, and `--mask` refuses anything but a selector. Offscreen `loading="lazy"` images no longer hang a render, font or image loading is bounded at 30 s, and a frame whose `__tick` never returns fails after 10 s with `MOTION_CAPTURE_FAILED`. A CSS transition a story starts with a class toggle is seeked from the frame it appears rather than from the last `__mark`, so it eases in the export instead of popping. An empty numeric option (`--min-db ''`) is refused instead of read as 0, and the Figma section only fills a file key and node id that look like Figma's. Exports write to a `.partial-` file and rename on success, so a failed re-export never truncates the last good file; a `--gif-fps` or `--small-fps` above 50 or not dividing the cycle is refused instead of drifting; `compare` resolves its paths; a `GSTACK_FFMPEG` that names a missing file says so. A URL source is treated as untrusted data. A capture now clears the previous run's manifest and stills along with its frames, scales a portrait frame to 1200×1920, renders stills with CSS animations finished, and tolerates a page that takes longer than 10 s to load. + +### Tests +- `test/motion-capture.test.ts`, `test/motion-encode.test.ts`: step and clock math (the seam frame lands exactly one cycle later for every PRE and cycle tried), argument errors, the exact export recipes, check frame selection for both layouts, PSNR parsing and verdicts (fewer compared frames than planned fails), and the `FFMPEG_MISSING` path, all without a browser, ffmpeg or network. +- `test/motion-pipeline.test.ts`: a 0.5 s fixture loop goes through capture, check, export and compare on real Chromium and ffmpeg, a page that does not loop and a page without the capture contract fail with their named reasons, and the same non-looping capture checks clean under `--min-db 0` only with that override printed. The same `--out` is refused while it holds foreign files, accepts the retry after a refused page, and reports `clean (overflow not checked)` without a mask; a page with an offscreen lazy image renders its still at `--scale 2`; a full-frame mask fails a cards check and is not checked for an app frame, and an empty mask stops the capture before any frame; a spinning `__tick` fails after 10 s; a class-toggled story transition is mid-fade at its midpoint frame; a blank `--min-db` is refused; a failed re-export keeps the last good GIF and a drifting `--gif-fps` is refused. It skips itself when Chromium, ffmpeg or ffprobe is missing. The motion unit tests add the new refusals and a portrait scale. +- New paid case `design-motion-showcase` (`test/skill-e2e-design.test.ts`): a finalized page goes through rebuild, capture, checks and export, and the agent must read the fidelity and motion sections and run `check` before `export`. The design-motion carve guard points at it. It runs in the marathon tier as a 3-trial behavior panel, because the periodic census is at its 38-slice cap and three 3-6 minute trials would add two slices; it needs ffmpeg and Chromium on the runner. Its section-read and ordering checks accept a full `cat` of a section and capture, check and export in one Bash call (a free replay of the first panel's three captures passes; a `grep` of a section, no `check`, and export before check still fail). + +## [1.91.263.0] - 2026-10-27 + +**Merge trains read their screens from the run, follow themselves without a daemon, and refuse what would have cost a gate.** Operating trains on 2026-10-26 showed the gaps: two screens were VERDICT RED while their lanes reported green and the heads were READY'd anyway; 18 of 41 PR screens concluded success with no slice; a PR whose merge ref lagged main could not be screened at all (#3465); two trains whose PRs merged elsewhere sat in `merging`/`green` forever; a background `gh run watch` chain died with every machine restart; a PR's new tests passed on its branch and failed once stacked behind another PR; and the four-step hand procedure for a conflicting PR's CI was typed by hand each time. + +### Added +- **`lib/ship-queue-screen.ts`**: a dispatched screen's verdict is read from the run's own artifacts (`gate-binding-a`: verdict, reconcile exit, flaky cases; `report-verdict-a/paid-report/collector-outcomes.json`: the panels that failed the lane and the rule-16 flaky lines; a confirmed verdict reuse in `paid-plan/gate-reuse.json`), never from a lane's prose. `ready --screen`, `verify --pr --screen` and `train build` print `SCREEN_VERDICT: # run GREEN|RED|NOT-RUN|INCOMPLETE [red=…] [flaky=…]` plus one `FLAKY:` line per flaky case, and record the verdict in the READY receipt (`screen.verdict`, locally and in `refs/notes/gstack-trains`). +- **`QUEUE_SCREEN_RED`**: a RED screen refuses `ready` under `--screen` and `--no-screen` alike, is never re-dispatched at the same merge SHA, and `train build` refuses to seat the pin. **`QUEUE_SCREEN_NOT_RUN`**: a screen run that concluded success with no slice (its route job found the head superseded by a push, or the plan selected nothing) is `NOT-RUN`: `verify --screen` refuses, the receipt records `NOT_RUN`, `ready --screen` dispatches afresh instead of following it, and `train build` seats the pin unscreened with a `SCREEN: … counted as not-run` line. +- **Scratch-merge screens (#3465)**: when GitHub's `refs/pull//merge` lags the PR head, is missing or cannot be fetched, `ready --screen` plans the screen on a scratch merge of the head onto the base, pushed as `screen/-` (`SCREEN_MERGE:` names it); only a real conflict with the base (or a remote whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`, whose text now says so, and a base that cannot be fetched is `POLICY_SOURCE_UNAVAILABLE`. +- **`train watch --train [--timeout ]`** (`lib/ship-queue-train-watch.ts`): the one resumable follow command. It reads the record, runs the step its state asks for (`train build --train` to push, run the free tests and dispatch the gate; `train status` to settle an unconfirmed dispatch), waits for the pending run by polling gh, reads the verdict and prints the last step's `NEXT:`. No process state: rerun it after an interruption and it continues from the record; it never dispatches anything the record would not dispatch on its own. +- **`train prune --stale`**: a `merging` or `green` train whose every PR is MERGED or CLOSED on GitHub is marked `stale` (a new terminal state), its branch deleted, each PR's state logged on a `STALE:` line; a train with an OPEN PR is kept and says why. The same run deletes every `screen/-*` scratch branch whose PR is MERGED or CLOSED. **`train forget --train | --receipt @ [--dry-run]`** (`lib/ship-queue-train-forget.ts`) removes one settled train (attempts included) or one READY receipt from local state and from the notes ref with an exact `--force-with-lease` on `refs/notes/gstack-trains`; a train still building, running or merging is refused, and a rejected lease removes nothing. +- **`verify --post-merge [--timeout ]`** (`lib/ship-queue-post-merge.ts`): after a merge, waits for every check run and commit status attached to the merged commit (its full 40-hex SHA) to settle, prints one `CHECK: ` per check and `POST_MERGE: green|red|pending|none (passed= failed= pending= skipped=)`; green needs a completed check and no failure, `neutral`/`skipped` count as skipped, a pending commit status keeps it pending, a failed check or status is **`QUEUE_POST_MERGE_RED`**, a timeout is `QUEUE_TRAIN_PENDING` with the same command as `NEXT:`. +- **`ready … --dispatch-ci [--timeout ]`** (`lib/ship-queue-dispatch-ci.ts`): for a PR that conflicts with the base and so has no pull_request CI, dispatches each named workflow on the branch (skipped when a run at this head already concluded), waits for the runs (`CI_DISPATCH:` lines), clears every requires-remote pregate lane whose workflow is one of them with the run URL (`PREGATE_CLEARED:`), then reads the head as usual. A red run clears no lane and refuses; a fork head is `QUEUE_TRAIN_UNTRUSTED`. This replaces the four-step hand procedure in docs/ship-policy.md. +- **`train build --dry-run --stack-tests [--stack-tests-command ""]`** (`lib/ship-queue-stack-tests.ts`): runs each PR's new test files on the integrated prefix that includes it (T) in a scratch worktree and prints `STACK_TESTS: # T new test file(s) pass|FAIL ()` or `no new test files`; a failure is **`QUEUE_TRAIN_STACK_RED`** with nothing written, pushed or dispatched. + +### Changed +- **`lib/ship-queue-train-github.ts`**: `downloadJson` finds a named file anywhere in a multi-path artifact (`report-verdict-a1` keeps its `paid-report/` directory). **`lib/ship-queue.ts`**: `ciAtHead` is exported and returns the per-check list it already read. **`scripts/lib/windows-curation.ts`**: `test/ship-queue-train-ops.test.ts` is excluded from the Windows subset like the other gh-shim drivers. +- docs/ship-policy.md's merge-train section documents every command above; docs/troubleshooting.md gains `QUEUE_SCREEN_RED`, `QUEUE_SCREEN_NOT_RUN`, `QUEUE_TRAIN_STACK_RED` and `QUEUE_POST_MERGE_RED`, and `QUEUE_SCREEN_STALE_REF` now means a real conflict only. + +### Tests +- `test/ship-queue-train-ops.test.ts` (new; the gh shim in `test/helpers/ship-queue-train-fixture.ts` gained `binding`, `outcomes`, `reuse`, `plan: null` and `ci` rules with the real artifact layout): a RED screen refuses ready under either choice, is printed by verify --screen with its red and flaky cases and refuses the pin in train build; a GREEN screen with flaky cases is followed and recorded; a NOT-RUN screen refuses verify with `QUEUE_SCREEN_NOT_RUN`, records `NOT_RUN` and is re-dispatched; a lagging or missing merge ref gets a scratch merge as `screen/-` and a real conflict stays `QUEUE_SCREEN_STALE_REF`; prune --stale settles only an all-merged-or-closed train and keeps one with an OPEN PR; train forget removes a train or a receipt and refuses a merging train; watch resumes from free-pending through the gate to the verdict and never dispatches twice; verify --post-merge waits, is red with the failed names, green when settled, resumable after a timeout; ready --dispatch-ci dispatches, waits, clears the matching lanes, refuses a red run and a fork head; --stack-tests catches a PR whose new test fails on T2 and passes after `--order` moves it first. Each was red before its change. +- `test/ship-queue-train.test.ts`: the stale-ref test now proves the scratch merge path; the planned-0-cases test expects the SCREEN: verdict lines. + +### Gate integrity +- No threshold, rubric, trial count, case kind or judge prompt changed. A screen's verdict is read as evals.yml bound it; nothing here changes what a case must pass. + +## [1.91.262.0] - 2026-10-27 + +**The weekly release accepts a commit a green train merged, and five lane frictions seen in operation are gone.** Trains merge faster than the main backstop settles, so most main commits ended with a neutral (`superseded`) `main-backstop` check run and the weekly cut returned `NO_GREEN_COMMIT` for a week of commits every one of which merged through a green train. The evidence already existed in `refs/notes/gstack-trains`; the gate now reads it. Alongside: `gstack-issue-links` no longer turns another repository's `(name #N, #M)` into this repository's Refs lines, `gstack-post` no longer asks for a confirmation on run ids written next to run words, `gstack-post pr-body` no longer wipes a PR body when a lane posts one section, and `gstack-ship-queue ready` finds the pregate verdict a lane recorded from a separate clone. + +### Changed +- **`scripts/release-cut.ts`**: a commit on main's first-parent history is gated either by a successful backstop check run or by a train record (kind `train`, state `green`, `merging` or `merged`) whose `merged[]` lists its sha or its tree, the main commits `gstack-ship-queue verify --train` confirmed. Everything else stays required: the required check run succeeded, no red check run (a red backstop on a covered commit still refuses), nothing still running, no active freeze. A commit with neither is refused with the same message as before. The `CANDIDATE` line says `gated by main-backstop` or `gated by train `. The notes ref is fetched and read once (`readTrainNotes`: the freeze and the train evidence together; `activeFreeze` is folded into it). New flags `--required-check ` (default `free-tests`), `--backstop-check ` (default `main-backstop`) and `--notes-ref ` (default `refs/notes/gstack-trains`) let another repository run the script; `weekly-release.yml` is unchanged. +- **`lib/issue-links.ts`**: a parenthesised named reference, `(name #N[, #M]…)`, `(name PR #N)` or `(name issue #N)`, where the name is one token that is not a closure, reference, filler or version word (fix, fixes, fixing, closes, closing, resolves, addresses, implements, supersedes, refs, see, related, part, follow-up, deferred, PR, issue, bug, upstream, cf., e.g., i.e., of, to, the, v1.2, …), is a cross-repo reference to `name` (`Refs name#N`), never a same-repo Refs line or a `fix #N` promotion candidate. `(see #12)`, `(closing #12)`, `(cf. #12)`, `(#301)` and `Fixes #12` keep their meaning; the `owner/repo#N` form is unchanged. +- **`lib/gstack-post.ts`**: an 11-digit GitHub Actions run id right after a run word on the same line (run, runs, screen, screens, gate, gates, backstop, bisect, rerun, dispatched, dispatch, failed, passed, with an optional `:` `#` `=`; a generic word such as id, job, attempt or workflow exempts nothing), and every id chained after an allowlisted one through `, `, ` and `, ` & `, ` or ` or ` / ` (a `(#1234)` between allowed), is exempt from `pii.phone.e164`. `failed 40269992077 | rerun passed 40287239206`, `screen 40269992077 (#3434) and 40275443393 (#3453)` and `gate 40323989742` post without `--confirm`. A bare 11-digit number, a 10-digit national number and `+14155551234` still report. +- **`lib/gstack-post.ts`** (`pr-body`): the body file is merged by `## ` section into the PR's current body (read with `gh pr view --json body`, or `glab mr view`, through the same runner) instead of replacing it. A section in the file replaces the same-titled section in place (case-insensitive), the ````gstack-ship-receipt```` block is a section of its own so a receipt rewrite replaces only the receipt, sections the file does not rewrite are kept byte for byte, new sections are appended after them, and a current body with no `## ` headings is kept as the leading text. A `## ` line inside a code fence is not a section: a fence closes only on a run of its own character at least as long as the opener with nothing after it, so a ``` line inside a ```` block or a ```js line closes nothing, and a receipt fence that never closes is an unclosed fence (the body after it stays one kept section) rather than a receipt that swallows `## Approvals`. The file's leading text replaces the current leading text; text after a receipt block never replaces an intro; whitespace-only text in the file is dropped, so reposting the body /ship composes (receipt, blank line, sections) is byte-stable. One stderr line names the replaced, kept and added sections. `--replace` keeps the plain overwrite for a caller that composed the whole body. The redaction scan and the confirmation token cover the merged bytes, which are the bytes sent. A current body that cannot be read posts nothing (exit 3) and names `--replace`. +- **`lib/pregate/index.ts`**, **`lib/ship-queue.ts`**: `gstack-pregate` mirrors every record it writes to `/pregate/by-tree/.json` (best effort; a mirror failure never fails the record). `ready` reads the checkout's `.gstack/tmp/pregate.json` (or `--pregate`) first; when it is missing, unreadable, recorded for another tree, or current but still waiting on a remote receipt, it looks the current tree up in the mirror and uses that record when it verifies, printing `PREGATE: … (from the state root's pregate/by-tree/.json, recorded by another checkout)`. A lane whose `gstack-pregate --remote` had to run in a separate clone (a worktree cannot host it) no longer gets `PREGATE_STALE`, `PREGATE_REMOTE_UNCLEARED` or `missing` from its own checkout. Only a run that holds the tests stage stands in (what `--stage tests --remote` writes): a mirror without the `lanes` check (an `--only timing` or preflight-only run), one recorded against another base, or one run under `--policy-from head` is named on the `PREGATE:` line and never certifies READY; a record whose own checks failed or stayed incomplete is never overridden. A mirror for another tree changes nothing; the mirror is written through a temp file and rename. + +### Docs +- `docs/releases.md`: the release-gate paragraph describes both kinds of evidence, what train evidence does not relax, the `CANDIDATE` wording and the three flags. `docs/pregate.md`: the by-tree mirror and how `ready` reads it. +- `docs/troubleshooting.md`: `#redact-run-id-as-phone` (what triggers `pii.phone.e164` on a receipt token or a run id, the positions `gstack-post` allowlists, and the fix), `#gstack-post-pr-body-merge` (what `pr-body` keeps, replaces and adds, and `--replace`) and `#gate-error-no-base` (`GATE_ERROR=no_base` from `gstack-gate-diff`, the printed fetch command, and `SCOPE_ERROR=no_base` from `gstack-diff-scope` for the same cause). + +### Tests +- `test/release-cut.test.ts`: on the real bare origin with train records pushed to `refs/notes/gstack-trains`, a neutral backstop plus a record naming the sha, or only the tree, is the candidate and the `CANDIDATE` line names the train; a covered commit is still refused by a red check run, a red backstop, a missing or failed required run, a running run, and a record in state `red`, `superseded`, `incomplete` or `free-green` is not evidence; with no record every neutral commit is skipped with the unchanged text and an older covered commit wins; the three flags rename what the gate reads through `planRelease` and the CLI, and the defaults do not see a record under another ref. +- `test/ship-issue-intent.test.ts`: `(gbrain #7625, #7606)`, `(gbrain PR #7630)` and `(acme/tools issue #4)` are cross references with no Refs line and no promotion candidate; `(see #12)`, `(fixes #12)`, `(PR #12)` and the other reference words stay bare refs; `(#301)` and `Fixes #12` are unchanged. +- `test/gstack-post.test.ts`: each run-word and chained shape posts; the id without a run word, an id after `see`, a 10-digit number, `+14155551234` and `run +14155551234` still need confirmation. For `pr-body`, through `runPost` with a fake runner answering `gh pr view --json body`: a first-section-only file keeps the receipt and the other sections and the stderr line names them; a same-titled section is replaced in place; a receipt-only file replaces only the receipt; new sections are appended; a `## ` inside a code fence is not a heading; `--replace` overwrites without reading the body; a headingless current body is kept as the leading text; an empty current body posts the file byte for byte; a secret in a kept section is a HIGH refusal on the merged bytes; a failed read is exit 3 with the `--replace` hint; `--replace` on another operation is a usage error. +- `test/gstack-ship-queue.test.ts`: a mirror for the current tree under `GSTACK_STATE_ROOT` makes `ready` pass from a stale `--pregate` and from a missing default file, with the `from` suffix; a mirror for another tree leaves `PREGATE_STALE` and `missing` as they were; a current explicit `--pregate` is used as before with no suffix. + +### Gate integrity +- No threshold, rubric, trial count, case kind or judge prompt changed. The release gate accepts a new kind of evidence (a green, merging or merged train record naming the commit) and relaxes no other requirement; the `gstack-post` allowlist widens only for 11-digit tokens in the listed positions. + +## [1.91.261.0] - 2026-10-27 + +**`gstack-restamp --next` moves a PR's quoted `since:` rows when its VERSION already equals the base's (gbrain #7640).** A gbrain PR stamped 0.60.468.0, lost its slot, merged master, and master was 0.60.468.0 by then. At its next slot restamp left the PR's `{ since: '0.60.468.0', … }` notice rows at .468 while the release shipped as .469, and gbrain's `compareReleases` then hid that notice from every user already on .468. Two gaps combined: with VERSION equal on both sides the branch had no stamp of its own, so nothing was rewritten everywhere, and the mention scanner that reads the lines the branch added could not pass the opening quote of a quoted row (`since:\s*(\d…)`), so it found nothing either. `--was 0.60.468.0` was no answer, because it rewrites master's own .468 rows too. The quote fix alone covers that merged-base state (the base's .468 is newer than the branch's .467 fork point, so the scanner names it); the equal-version rule below covers the state the fork-point floor cannot, a branch rebased onto the base after it reached X, or one never stamped. + +### Fixed +- **Quoted versions are stamp forms** in both the mention scanner (`tentativeMentions`) and the exact rewrite table (`rewriteStamps`) of `lib/restamp.ts`: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` match like the bare forms and keep their quote style, and an opened quote must close right after X, so `since: '1.2.3-beta'` and a mismatched `since: '1.2.3"` are not the form (the tree receipt and the gate-reuse normalizers share `rewriteStamps`, so a quoted `since:` rewrite classifies as a stamp without a template, and a quoted prerelease change never does); `vX` already accepted a quote before the `v`. The scanner also reads the policy's `stamp_patterns` templates, so a template-only form on a branch-added line is a candidate too. +- **The equal-version rule.** When the branch has no stamp of its own (its VERSION equals the base's and no `--was` is given), a stamp-form mention of the base version on a line the branch added is its stale tentative stamp and moves to the allocated version; the same version on a line the base already carries is the base's release and stays. The `stamp_paths` stage detail, printed in the stage table and kept in the journal, names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (: N moved, M kept)`), so the stamp commit's tool output shows which lines moved and why; when an explicit stamp matched nothing the skipped detail now names the version it searched for. The rule reads line ownership, not meaning: a branch-added `Requires vX or later` inside `stamp_paths` on a branch forked at X moves too, visible in `--dry-run` and the counts; `docs/ship-policy.md` says how to write such a reference. The explicit `--was X` path is unchanged and still rewrites X on every line; its help line now says so and names the CHANGELOG re-heading it also drives. +- **Branch-added lines are known by position, not text.** `branchAddedLines` records each added line's new-side position beside its text (`parseUnifiedDiff` counts it through any context line a hunk carries, and the diff pins `--inter-hunk-context=0` so a `diff.interHunkContext` setting cannot join hunks), and the stage rewrites by position, so a base line whose text equals a branch-added line stays while the added copy moves, and a CRLF checkout (`core.autocrlf`, `eol=crlf`), whose diff is LF-normalized while the file carries CR, still finds the branch's row instead of reporting `0 moved` and shipping it stale. +- **A stamp interrupted after VERSION was written retries as a stamp.** The journal recovery restored the branch's own stamp but kept the already-written VERSION as a stamp when the original was the base version, so the retry rewrote nothing and returned `noop` with the row still stale; it now restores the equal-base provenance (no branch stamp) and the retry completes the stamp. + +### Tests +- `test/restamp-mentions.test.ts`: the pure forms (single quote, double quote, bare unchanged, quote style kept, a mismatched quote pair refused, a template overlapping the built-in form counted once, the fork-version floor on quoted mentions) and, on the fixture repo, the reporter's state rebuilt with the real `bin/gstack-restamp`: the other PR takes the slot through the tool, the PR merges main with VERSION equal on both sides, and `--next` moves the PR's quoted row, keeps the base's two rows byte for byte, and says why in the stage detail; the same with the built-in form alone (no `since:` template); a negative control where the PR adds no base-version mention and every base-side quoted row stays; a branch forked at the base version (fork equals base, nothing stamped) whose own base-version row and doc mention move while the base's stay; and `--was` rewriting the base's row as well; then the quoted-prerelease and mismatched-quote boundaries in both the scanner and the table, a branch-added line whose text equals a base line (the base copy stays), a CRLF checkout, and an apply interrupted by `EACCES` after VERSION was written whose retry completes the stamp. Eleven of the file's thirteen cases are red at 1.91.253.0 (the template-overlap and `--was` cases pass there, as they should). +- `test/stamp-templates.test.ts`: the quoted `since:` row now counts once with or without the template (it pinned zero without one). + +### Docs +- `docs/ship-policy.md`: the `stamp_paths` stage states the quoted forms and the provenance rule (the branch's own stamp everywhere; on branch-added lines, mentions newer than the fork version and, with VERSION equal to the base's, the base version; `--was` as the rewrite-everywhere tool). + +## [1.91.260.0] - 2026-10-27 + +### Changed +- **/design-review audits a Persuade page or a redesign for craft, or on request** (`sections/craft-audit.md`, read in Phases 1-6; design-review is now carved on every host, so the audit is one lazy section, not inlined text). It runs `bin/gstack-design-craft` on each page's served HTML (or the repo file when no app runs) and files each `CRAFT_CHECK` line as a category 9 finding (no `CRAFT_RESULT` line means the check was unavailable, and a framework mount-node shell is judged by eye too; the saved page holds only inline CSS and JS, so `100vh`, `transition: all`, scroll listeners and `alert()` are also judged by eye; it also holds session data, so it is deleted in the block that checks it, whatever the exit code, and never copied to the report; `--accepted` takes that screen's finalized.json, only for a page /design-html finalized, and never covers `needs-proof` or `lorem`; a claim without proof is filed and asked about, never marked or cut); checks what the tool cannot see (claims only from the user, realistic sample content, the ship floor, a designed 404, cookie consent where required, the motion rules); files a failed Persuade check (one primary action, named objections, proof beside each claim, a real risk reversal, an index/noindex decision) as a deferred High finding, since its fix needs the team's copy or indexing call; and in Phase 8 breaks impact ties with Elaya's fix order and reads the installed Tailwind major version before touching its config. The audit's rules render from /design-html's craft section, so the build and the audit cannot drift. +- **/plan-design-review**: a Persuade plan gets the same five checks in Pass 1, Pass 4 rejects unsourced proof, and the runner checklist names them. +- **The craft tells are catalog entries.** Placeholder people and brands, lorem, round sample numbers, `[Needs proof:` markers, "Oops" errors, scroll-listener reveals, dead `href="#"`, `alert()`, images without alt, `100vh`, and a missing title, description, favicon, skip link or `aria-current` live in `lib/design-catalog.ts` as `checkerOnly` entries: one id, one short line and the fix, reported by the craft check and kept out of Methodology category 9, `review/design-checklist.md` and the consultation bullets, so no skill carries their prose. `lib/design-craft.ts` prints only catalog ids, with each entry's `fix` text; `transition: all` now reports under the existing `layout-transition` id instead of `transition-all`. +- **The craft check's `transition: all` tell is `layout-transition`, the design lint's id for it** (`lib/design-lint/rules/copy.ts` leaves it to the lint's static tier, as it does `vh-height`). A `craft_exceptions` entry recorded as `transition-all` is read as `layout-transition`, so an exception accepted before the rename still applies. + +### Tests +- `test/helpers/placeholder-allowlist.ts` lists `` (path grammar), the saved page the craft audit passes to the check. +- The design-review and plan-design-review paid cases and judges that read a design resolver now also select on `scripts/resolvers/design-craft.ts`, and `design-html-slop-gate` on `lib/design-catalog.ts`, where the craft check's ids and fixes now live. +- `test/design-craft.test.ts`: every id the check prints is a grep-detectable catalog entry, and the fix it prints is the catalog's. design-review gets its first carve guard (skeleton, union and parity ratio at their measured sizes). +- `test/design-craft.test.ts` also pins that `checkerOnly` entries reach no rendered list while the craft check prints each one, that a change to `scripts/resolvers/design-craft.ts` selects the design-review and plan-design-review cases, and the exception cells a stale record, an over-count tell without `--accepted` and a malformed record on disk reach. New `test/design-craft-resolvers.test.ts`: `{{DESIGN_CRAFT_RULE}}` renders one paragraph with its bullets and fails generation on a missing or ambiguous lead, and both skills carry the same five Persuade checks; the generated craft audit keeps its outcome, exception and session-data rules (and the design-review carve guard checks the last one). `test/design-craft.test.ts` also covers the needs-proof and stale-record exception cells. +- `test/design-lint.test.ts` reads the craft ids from `CRAFT_IDS` instead of scanning `lib/design-craft.ts`, and every one is a lint rule (`layout-transition` and `vh-height` belong to the static tier); `test/design-craft.test.ts` covers the legacy `transition-all` exception. + +### Gate integrity +- Size budgets raised to measured sizes only (Garry 2026-10-25 "Do it" (GSTA-92)): the SKILL.md pins of design-review on 12 hosts (+578 Claude, +608 on the 11 external hosts: the Section index the carve test requires and the STOP pointer) and plan-design-review on the 11 external hosts (+1,082: the five Persuade plan checks). The craft tells are catalog entries the craft check reports with their fix, kept out of every rendered skill list, so design-consultation and the review checklist do not grow. No other threshold, budget, timeout, retry or trial count changed. + +## [1.91.259.0] - 2026-10-26 + +**New `/ubicloud` skill: the Ubicloud runner moves into gstack with the discipline a shared vCPU quota needs.** Crews ran heavy suites on Ubicloud through a user-scope skill and per-lane wrappers, and paid for it in stray VMs after a machine slept, a project quota at 528 of 512 vCPUs when lanes verified in parallel, verifies that hung for an hour before anyone noticed, and a stale wrapper that skipped a service start. `scripts/ubicloud/ubi-runner.sh` now carries the fixes and `ubicloud/SKILL.md` (entry point `bin/gstack-ubi`) teaches the watched launch; `bun run test:ubicloud` and the remote pregate are unchanged. + +### Added +- **`ubicloud/SKILL.md.tmpl`, `bin/gstack-ubi`**: the skill: set/unset prerequisite checks (never `env`), `UBI_OWNER` on every machine, a watched launch (a waited-on background operation or `gstack-detach`), the quoted recovery line, one committed verify per repository, how to read exit 124 and a quota refusal, and the resume checklist. Classified `CLAUDE_ONLY` in `lib/doctor-components.ts`; routed from the root `SKILL.md`; listed in README, AGENTS.md, docs/skills.md and docs/PROJECT_STRUCTURE.md. +- **TTL names and a reaper**: a generated VM name ends in `-t` (`UBI_TTL_HOURS`, default 4; 0 omits it). `reap` destroys every VM in the project whose TTL ended more than ten minutes ago, whichever owner created it, and every `up` runs it first (`UBI_REAP=0` skips it). Names without a TTL, including the ones `pregate-remote.sh` builds, are never reaped. +- **Quota preflight**: `up` adds the size's vCPUs to the project's running VMs and refuses before the create request when that exceeds the known ceiling (`UBI_QUOTA_VCPUS`, or the maximum the last API refusal printed, remembered in the state root); `UBI_QUOTA_WAIT=` polls for room instead of refusing. Both refusals (the preflight's and the API's) print the per-owner `usage` table. +- **Watchdog and heartbeat**: `run`, `verify` and `ssh NAME COMMAND` stream the remote output (both streams kept in the VM's state dir) through a watchdog: a heartbeat every `UBI_HEARTBEAT` seconds (300), and a command silent for `UBI_IDLE_TIMEOUT` seconds (1800) or older than `UBI_RUN_TIMEOUT` (0, unlimited) is killed in its own session with exit 124 and its last 40 lines on stderr. `run` also prints the VM's TTL and its `down` recovery command as soon as the VM is ready. +- **`verify`**: runs the repository's committed `.gstack/ubicloud.json` (setup, command, size, location, storage, image, env, pass, pull; flags and a trailing `-- COMMAND` override it) and refuses without the file or a command, so every lane runs one definition instead of its own wrapper. The file is repository content and gets no reach beyond the checkout: `setup` and each `pull` destination must be relative paths that resolve inside `--src` (absolute, `~` and `..` paths are refused before any API call, and under `verify` every pull destination is resolved again, symlinks followed, right before its pull, so a symlink an earlier pull planted cannot redirect a later one; a refused pull makes the run exit 1), and a name under `pass` is forwarded only when the command line grants it (`--pass NAME`, or every listed name with `--trust-config`; `verify` parses its options by arity, so an option value can never act as that flag). `--pass` never forwards `UBICLOUD_*`, from the file or the command line. +- **Watched commands keep their stdin and exit codes**: `ssh NAME COMMAND` hands the caller's stdin to the remote command (`printf payload | gstack-ubi ssh NAME 'cat > file'` wrote an empty file once the watchdog backgrounded the client), a command that finished while the watchdog read the clock, or in the instant before its kill, keeps its own exit status instead of 124, a hung setup script exits 124 like a hung command instead of 1, Ctrl-C on a watched command takes the ssh session down with it, and when the watchdog fires the runner also ends the command's session on the VM (`setsid` there, leader pid in `~/.ubirun--.sid`, kept until the cancel reads it so a background child the command left behind is still reachable, `pkill -s` bounded to 20 s), since killing the local client alone leaves a silent remote command running until it next writes. +- **`pack` and `sync` from a linked worktree**: a worktree's `.git` is a one-line pointer into the main repository, so the VM got a checkout whose git did not work (`gstack-pregate --remote` from a Capy worktree failed with `PREGATE_VM_TREE_MISMATCH vm=unavailable`). The tarball now carries a self-contained clone of the checked-out branch with the worktree's own index in its place (about 45 MB for gstack instead of the main repository's full `.git`); a detached worktree is refused with the fix named. +- **Quota ceiling and reaper outcomes**: the maximum a refusal cached is trusted for a day, then the API's own answer decides again; the refusal line says where the ceiling came from and names `UBI_QUOTA_VCPUS`, and its `vCPU quota exhausted` wording is the one `pregate-remote.sh` retries once on. `reap` returns 1 when the list fails (nothing destroyed) or an expired VM could not be destroyed, instead of 0 either way; a TTL suffix is read in base 10 (`UBI_TTL_HOURS` takes 0 to 999 with no leading zero). + +### Tests +- `test/ubicloud-runner.test.ts`: against the fake API, `up` reaps an expired other-owner VM, names its own VM `-t4`, and the preflight refuses with no create call; `UBI_REAP=0` and `UBI_TTL_HOURS=0`; `reap` leaves unexpired and TTL-less names alone. Against a fake `ssh`, the watchdog returns 124 with the last output and the heartbeat line appears. `verify` refuses a missing file, a missing command, a missing setup script (resolved against `--src`) and invalid JSON, all before any API call. A second fixture whose fake API can create runs `run` end to end: a hung setup exits 124 and still destroys the VM, `UBI_RUN_TIMEOUT` kills a chatty command, `--pass` writes the named variable (and never `UBICLOUD_*` or a non-name) into the VM's env file without printing it, `ssh NAME COMMAND` passes stdin through, `verify` refuses ungranted `pass` names and setup or pull paths outside `--src` and runs once granted, a quota refusal caches its maximum which drives the preflight until it is a day old, `UBI_QUOTA_WAIT` waits before refusing, and `reap` reports a failed list or destroy. +- `test/catalog-budget.test.ts` ratchet 1,251 → 1,289 token-equivalents (ubicloud adds 150 bytes on top of /captain's 1,251); `test/fixtures/context-budget.json` gains the `ubicloud` ceiling only. + +## [1.91.258.0] - 2026-10-26 + +**The session check stops failing for things that are not failures, and a consuming repository can name the gstack it wants without broadcasting to every thread.** Five Captains running gstack on Capy hit the same install friction this week: eight crew-wide "upgrade to " messages since Oct 16, `gstack: fail not-installed` on coordinator machines that run no workflow, `fail pins` from the image's Node while the tests run on the pinned one, reviewers told to "reinstall" when asked to run a workflow, and an environment dump from a broken `/cso` shell line. Each of these is now a product behaviour instead of a crew rule. + +### Added +- **Project floor** (`floor` row in `gstack-doctor --check`, `lib/doctor-components.ts`, `bin/gstack-doctor-check.sh`): a consuming repository declares the oldest gstack it wants its crew on as `min_gstack_version` in its `.gstack/ship-policy.json` (the field `gstack-ship-queue` already validates; a file holding only that field is valid). The doctor reads it from the project the check runs in: `PASS floor declared by `, `SKIP floor (... declares none)`, or `WARN floor — installed is below the floor ...; fix: ` (`PROJECT_FLOOR_UNMET`). The Capy installer adds `floor= ()` and a `fix:` note to its ok trailer. One commit in the project replaces a message to every thread; the fix on Capy is the install command, which fast-forwards a clean checkout to origin in one step. +- **`--for none`** (`capy/skill/gstack/scripts/install.sh` wrapper 3, `bin/gstack-capy-install`): a machine that runs no gstack workflow (a coordinator, a liaison, a Captain) prints `gstack: ok not-needed for=none ()` and exits 0, installing, moving and certifying nothing, with or without a checkout. The bootstrapper answers it itself, so the line is honest for any checkout revision. That ok admits no workflow: no install verdict is written, so a workflow started there is refused with the usual `--for ` fix. `GSTACK_CAPY_MIN_WRAPPER` is 3; older Drive copies keep working and print the owner action. +- **Review-voice machines upgrade in place** (`bin/gstack-capy-install`): running `--for ` on a machine installed `--for review-voice` says `upgrading the review-voice install in place to --for (same checkout ...; no second clone)`, applies the project's pins, runs `./setup` and both host profiles, and writes the workflow's verdict over the reviewer's. The admission refusal's fix now says so (`upgrades this checkout in place: ./setup and host profiles, no second clone`) instead of reading like a reinstall. + +### Changed +- **A gap above the supported minimum is never a hard fail** (`bin/gstack-capy-install`): an install-mode session whose session-start fast-forward was skipped (dirty, diverged or pinned checkout, a live lease, `gstack-upgrade` missing) prints `WARN behind — ... and the session-start upgrade was skipped: ; fix: (UPGRADE_BUSY|UPGRADE_REFUSED|UPGRADE_SKIPPED)` and keeps its ok trailer with `behind= (latest: ); upgrade skipped: (); fix: ` notes, exit 0. It used to be `gstack: fail behind`, exit 1. Below the supported minimum the bootstrapper still fails `revision` (`REVISION_BELOW_MINIMUM`). +- **A Node-only pin mismatch is advisory** (`bin/gstack-doctor-check.sh`): when the project's Node pin is unmet by the Node on PATH while every Bun pin is met, `pins` is `WARN pins — ... (node pin only; bun pins pass); fix: ... (PROJECT_NODE_PIN_UNMET)` and the trailer stays ok. gstack runs on Bun and the project's tests choose their Node (nvm, the CI matrix). A Bun pin that is unmet stays `FAIL pins` (`PROJECT_PIN_MISMATCH`). +- **WARN rows print their action as given** (`_dc_row`): `owner action: ...` for the Capy skill copy, `fix: ...` for the floor, a skipped upgrade and a Node pin, so a lane knows which WARNs it can clear itself. +- **A Drive copy below the wrapper minimum is a WARN in a direct run too** (`bin/gstack-doctor-check.sh`): with no bootstrapper provenance (`bin/gstack-upgrade`'s installer run, a direct `gstack-doctor --check`), every Drive copy at a bootstrapper revision below `GSTACK_CAPY_MIN_WRAPPER` is `WARN wrapper — every Capy skill copy is below the minimum wrapper 3 (it still works): ...` with the owner action and an ok trailer; only a pre-bootstrapper copy (no `GSTACK_CAPY_WRAPPER=` line) still fails. Found by /ship's red team: raising the minimum to 3 would otherwise have failed every direct run on a machine whose Drive copy is the deployed wrapper 2 until the owner recopied the skill. +- **A `--for none` session never self-upgrades** (`bin/gstack-capy-session`): an older bootstrapper (wrapper 1 or 2) hands `--for none` to the session in install mode; the session now treats it like `--check` (reports `behind=`, moves nothing) before the installer prints `ok not-needed`; the old bootstrapper's own clone or fast-forward of a below-minimum checkout, before the handover, is unchanged. Wrapper 3 answers `--for none` before the session. The session's header comment describes the `WARN behind` outcome (it said `fail behind`). +- **/cso** (`cso/SKILL.md.tmpl`): the always-loaded startup block gains two rules. Environment hygiene: prerequisite checks read a variable as set or unset only, and `env`, `printenv`, `set -x`, a bare `set` or `export`, `export $(...)` and `echo "$NAME"` never appear in a tool fence. Tooling errors fail loudly: a non-zero exit with no helper status is retried at most twice (5 s, 30 s; `start` is resumed, not restarted, because a run may exist before its ID prints) and then ends **not assessed** for whatever the helper never recorded, quoting the command, exit code and stderr tail; never a quiet partial, never another review offered as the missing coverage. +- **Capy skill and docs** (`capy/skill/gstack/SKILL.md`, `docs/capy-install.md`): the session-start steps cover `--for none`, `floor=`, `behind=` as informational, the Node WARN and the in-place review-voice upgrade; run directories and scratch on Capy live under `~/.capy/work/`, never `/tmp`, which Capy wipes when a machine sleeps. +- **Result codes** (`lib/result-codes-capy.ts`): `PROJECT_FLOOR_UNMET`, `PROJECT_NODE_PIN_UNMET` and `UPGRADE_SKIPPED`, each with a `docs/troubleshooting.md` anchor. +- **The trailer's `fix:` after a skipped upgrade is one runnable command** (`bin/gstack-capy-install`): the session's rerun or `bin/gstack-upgrade` (which prints its own refusal), never the refusal's prose; that prose, with its `GSTACK_UPGRADE_FORCE=1` escape hatch for a live lease, stays on the `WARN behind` row (found by /ship's adversarial review: an agent told to run the `fix:` command was being handed prose with the note separator in it). +- **The floor reader is whitespace-insensitive** (`bin/gstack-doctor-check.sh`): `min_gstack_version` on one line and its value on the next, which `lib/ship-policy.ts` accepts, is read, the first occurrence (the top-level field) wins, only the first 64 KiB of the file are read, and a present string value that is not a plain dotted version SKIPs as such instead of as "null or absent" (found by Codex and the adversarial pass in /ship's review). + +### Tests +- `test/gstack-doctor.test.ts`: the `floor` row's four outcomes (absent file, null key, met, unmet with the wrapper fix and without it) and the Node-only WARN beside a Bun-plus-Node FAIL; every required-row list gains `floor`. +- `test/gstack-capy-install.test.ts`: a skipped session-start upgrade is an ok trailer with the `upgrade skipped` note and a `WARN behind` row (replacing the `fail behind` pin); a `WARN floor` row becomes the `floor=` and `fix:` notes, with one `fix:` when `behind=` is also present; `--for none` with and without a checkout; a review-voice verdict upgraded in place by `--for autoplan` (setup runs, the verdict becomes the workflow's). +- `test/capy-skill-wrapper.test.ts`: `--for none` through the bootstrapper (checkout present, install mode, no checkout), wrapper 3 in the handover; a wrapper-2 handover of `--for none` in install mode leaves HEAD where it was. +- `test/gstack-doctor.test.ts`: a direct run with every Drive copy a bootstrapper below the minimum is a WARN with an ok trailer, and a pre-bootstrapper copy beside it keeps the FAIL. `test/gstack-capy-install.test.ts`: an unknown skip code collapses to `UPGRADE_SKIPPED` with its anchor; the in-place upgrade message is silent for a same-profile rerun, `--check` and a second review-voice install. +- `test/helpers/carve-guards.ts`: the /cso skeleton pin moves to 18,900 (measured 18,843) for the two startup paragraphs; `test/fixtures/context-budget.json` pins `capy/skill/gstack` and `cso` at their new eager sizes. + +### Gate integrity +- No threshold, rubric, trial count, case kind or judge prompt changed. The /cso skeleton cap and two context-budget ceilings went up by the measured size of the added prose, with the reason in the pin. + +## [1.91.257.0] - 2026-10-26 + +**A remote gate that ran ahead of the stamp now clears /ship's small-machine obligation; gbrain stops paying a second full gate per PR.** `gstack-pregate clear --remote pregate.json/remote_gate --receipt ` accepts a `PREGATE_REMOTE:` receipt whose tree differs from pregate.json's tree only in the repo's declared stamp paths. gstack proves it itself: a `git diff` of the two trees, every differing file classified by the tree receipt's own rule (`lib/tree-receipt.ts` `classifyFile` under `.gstack/ship-policy.json` on the base branch: `stamp_files`, `stamp_patterns`, and the built-in version-file, manifest-version, lockfile-root-version and changelog-heading forms), so the pregate verdict and `gstack-tree-receipt`'s can never disagree; there is no second stamp list, and its gate rule holds too: a policy whose `stamp_sensitive_gates` names the test lane (`tests`, as gstack's own policy does) refuses the substitution, because the tree receipt never marks that lane reusable. The policy is the ship's own base branch's (`origin/HEAD`); a pregate.json recorded against another base is refused. gbrain's rule lets a head reuse a full gate whose tree differs only in release stamps, but /ship's Step 12 restamp always changed those files, so no gate-ahead run could satisfy `remote_gate` and every gbrain PR ran the whole gate again after /ship (#7621, #7674, #7681: 17–37 min, 64–128 vCPU each). + +### Added +- **`lib/pregate/remote-receipt.ts`** (`substituteStampOnlyTree`): both trees must be objects here (the gated commit fetched, the head tree `bin/gstack-wtree` wrote); a path outside the rule, a stamp path whose hunk is not the exact old → new rewrite, an unfetched or non-hex gated tree, an unreadable, unfetched or invalid base policy, a policy whose `stamp_sensitive_gates` names the test lane, a pregate.json recorded against another base, a patch that does not account for every path `git diff-tree` lists, or a `stamp_files` hunk between two trees at the same version refuse with `PREGATE_RECEIPT_INVALID` and a fix naming the offending path (`lib/x.ts (stamp_files: not in the repo's stamp_files allowlist, line 1)`). The patch is read with a fixed presentation (`--no-textconv --no-renames --ignore-submodules=none`, `a/` `b/` prefixes) so a diff driver or `diff.*` setting can neither hide nor rename a path. The receipt's tests and failed checks are unchanged and run before any git work. +- **The substitution is recorded.** The receipt string gains `accepted-for= stamp-only=` after `tests=`, and pregate.json's obligation carries `substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }`; `clear` prints `PREGATE_RECEIPT_SUBSTITUTED: gated tree … accepted for tree … under ; stamp-only: (), …`, `verify` repeats it on its ok line, and a substitution whose `accepted_for` is not pregate.json's tree, or whose `gated_tree` is not the receipt string's `tree=`, is `PREGATE_REMOTE_UNCLEARED`. +- **Docs:** [docs/pregate.md#remote-gate-stamp-only](docs/pregate.md#remote-gate-stamp-only) (what is accepted, what is refused, how a consuming repo declares its stamp paths: `.gstack/ship-policy.json` on the base branch, the file the tree receipt already reads, and the four-command sequence that reuses a gate-ahead run); the `PREGATE_RECEIPT_INVALID` and `PREGATE_REMOTE_UNCLEARED` troubleshooting anchors. + +### Changed +- `lib/tree-receipt.ts` exports `classifyFile` (the function is unchanged) so pregate reuses the rule instead of restating it; `PregateRemote` in `lib/headless-artifacts.ts` gains the optional `substitution` field (`PregateReceiptSubstitution`) and its schema entry. Both trees must be 40- or 64-hex object ids before any git call (a symbolic `HEAD^{tree}` in a receipt is refused, never recorded as the gated tree), and `clearDecision` now takes the repository root. + +### Tests +- `test/pregate-remote-stamp-only.test.ts`: accept (five stamp files under a `stamp_files` + `stamp_patterns` policy, the recorded substitution equal to `gstack-tree-receipt`'s file verdicts on the same pair, `verify` ok), the unchanged same-tree path, refusals (a path outside the rule, a stamp path with a description edit, an unfetched gated tree, an unreadable receipt file, zero tests, a failure, a symbolic tree, a head tree not in the object store, a mode-only difference, a stamp file at the wrong version, a pregate.json recorded against another base with and without origin/HEAD, an invalid base policy, a policy whose `stamp_sensitive_gates` names the test lane), `verify` on a misapplied substitution, and a same-tree re-clear dropping the recorded substitution. + +## [1.91.256.0] - 2026-10-26 + +**The /ship review loop converges, and a receipt survives a base merge.** Five Captains' /ship runs hit the review cap on the same PR shape: every pass re-reviewed the whole diff, reviewers answered an empty range with an invented advisory, and each advisory earned another full pass; four more re-ran /ship on product code they had not changed because a conflict-only main merge or a test fix moved the head the receipt named. The loop now fixes blocking findings in the diff on pass 1, reads only the range since the previous pass afterwards, caps at three passes, and counts `passes= blocking_found= deferred=` in the ship receipt; the receipt carries to the new head when the product patch-id is unchanged. The blocking floor is the owner's call (Garry, 2026-10-26: "Ok set it at that"), and the policy lives in gstack rather than in any one team's rules (Garry, 2026-10-26: "Gstack should be the actual holder of how to do things not one off Capy files"): `docs/review-policy.md` is the canonical statement, the skills carry it as their written policy, and a crew rule can be one line pointing at it. + +### Added +- **`docs/review-policy.md`**: the canonical review policy: the blocking floor, `NO FINDINGS` as a result, how `/ship` converges (pass 1 the diff, pass 2+ the range, three passes), what the receipt's `review_loop` records, the `review_scope` table and where each rule is enforced. `ship/SKILL.md` states it in a Review policy paragraph in the always-loaded skeleton and `review/SKILL.md` Step 4 states the floor and the `NO FINDINGS` rule beside the checklist order, each pointing at the doc. +- **`lib/review-scope.ts`, `bin/gstack-review-scope`** (`classify`, `defer`, `render`, `summary`): each finding gets a `locus` (`diff`, `fix-range`, `outside`; a hunk plus three context lines), a `kind`, `blocking` (a CRITICAL defect: a test that would fail, a production break, a security or data-loss defect) and a `scope`: `fix`, `defer` (a blocking finding in code the PR did not change, filed as a GitHub issue reused through a `` marker, `unfiled ()` without `gh`) or `note`. `render` writes the PR body's `## Deferred findings` and `## Review notes` sections; `summary --since ` reads this invocation's review-log rows into the receipt's `review_loop` counters. Refusals end in `REVIEW_SCOPE_INPUT` or `REVIEW_SCOPE_RANGE`. +- **`.gstack/ship-policy.json` `review_scope`** (`frozen` | `diff-plus-blocking` | `all`, default `frozen`, since 1.91.249.0; a non-default value needs `min_gstack_version`): the blocking floor is owner-approved (Garry, 2026-10-26: "Ok set it at that"); `diff-plus-blocking` is the documented fallback that fixes a blocking finding in unchanged code in this PR instead of filing it, and `all` is the earlier loop, every finding fixed on every pass. `show-effective` prints the choice as a `ship: review_scope …` line ([docs/ship-policy.md#review-scope](docs/ship-policy.md#review-scope)). +- **`gstack-ship-receipt carry (--pr | --body ) --base [--dry-run] [--json]`**: moves the receipt to HEAD when the receipt head is an ancestor of HEAD and the product diff against the base has the same `git patch-id` at both heads; tests (`CARRY_TEST_GLOBS`), release metadata (`CARRY_METADATA_GLOBS`) and the base merge's own commits may differ. The carried receipt records `carry: { from, reason, product_patch_id, changed_paths, merge_commits, range_diff, covers, reruns }`, prints `CARRY: carried from to ()` and the `GSTACK_RESULT` line, and its summary line ends `carried_from=`. A product change is `RECEIPT_CARRY_PRODUCT_CHANGED` (exit 3, naming the paths); a rebased or missing receipt is `RECEIPT_CARRY_REFUSED`. `write` takes `--review-loop `; `ShipReceipt` gains optional `review_loop` and `carry` with schema ([docs/ship-policy.md#receipt-carry](docs/ship-policy.md#receipt-carry), [docs/unattended.md](docs/unattended.md#the-terminal-line)). +- **Severity glossary and lead judgment** (ideas absorbed from tstack's sol-loop and pstack's interrogate, rewritten; NOTICE.md): `docs/review-policy.md`, `ship/sections/review-army.md` Step 9.4 and `review/checklist.md` budget the loop by three words (a **blocker** is the intended case gone wrong, the floor; a **should** is a real bug a caller will hit inside the intended case, fixed in the diff on pass 1 and deferred otherwise; a **nit** is the rest, a named note) and carry two stop rules (fixes that grew the diff past the request are cut first; the cap approves nothing, so a standing blocker is reported). The finding merge both /review and /ship render (`scripts/resolvers/review-army.ts`) gains stage 4, lead judgment: nit gravity, hypothetical versus actual, structure for a case nothing takes, a different design is not a defect, missing context; independent agreement is the strongest signal and a reviewer's `NO FINDINGS` stands until a traced defect says otherwise. Every specialist prompt accepts an optional `cause` ({path, line}): a defect the diff causes in code it did not change (a broken caller, a consumer of a removed export) keeps the victim as `path` and the changed line in `cause`, and `classify` follows the anchor, so the finding is the diff's to fix rather than deferred to an issue. + +### Changed +- **`ship/sections/review-army.md` Step 9**: first entry saves `REVIEW_SINCE` and the `review_scope` line; every entry saves `PASS_HEAD`, and from pass 2 `RANGE_BASE` is the previous pass's head, so every prompt the step diffs from the merge base (specialists, Red Team, the outside voice in the adversarial section) reads `RANGE_BASE..HEAD` under `frozen`. Step 9.4 item 0 classifies the pass's findings, item 1 queues only `scope: fix`, item 3.5 defers and renders, and the summary line adds `D deferred, O noted`. Decision 2 keeps the three-cycle fix cap and adds the pass cap: a blocking finding still in `fix` scope on pass 3 stops for the owner with the finding named; a pass whose remaining findings are all below the floor applies no edits and ships through decision 4. The review-log row gains `pass`, `review_scope` and `range_base`. +- **Specialist and Red Team prompts** (`scripts/resolvers/review-army.ts`, both /review and /ship): `NO FINDINGS` alone is the expected, complete result for a clean diff and never a failed reviewer; CRITICAL is defined as the blocking floor and everything else is INFORMATIONAL. Shared prose was tightened by the same number of bytes, so /review's generated size is unchanged on every host. +- **`ship/sections/pr-body.md`**: the body carries the `## Deferred findings` and `## Review notes` sections verbatim from `render`, and the receipt write runs after `gstack-review-scope summary`. +- **`ship/SKILL.md` Step 1**: item 4, the carry check, runs `gstack-ship-receipt carry --pr --base ` when the branch has an open PR; `CARRY: carried` ends the run with the receipt bound to HEAD. +- **`gstack-ship-receipt carry`, after its own pre-landing review** (two blocking findings reproduced in the QA smoke before the fix): the product patch-id is `git patch-id --verbatim`, so a whitespace-only edit to a reviewed line (`rm -rf "$DIR"/x` → `rm -rf "$DIR" /x`) is a product change; `package.json` and `VERSION` are release metadata only while the from..head change is a version stamp and nothing else (`lib/pregate/stamp-only.ts`), so a dependency, a script or a new key is product; lockfiles left the default metadata list (product unless the policy's `stamp_files`/`stamp_paths` name them); the test globs name code extensions (`**/*.test.ts`, `**/*.spec.rb`, …), so an `openapi.spec.yaml` is product; a working tree with uncommitted changes is `RECEIPT_CARRY_REFUSED` before anything is compared (the carry binds HEAD alone, and a parent that stops at `CARRY: carried` would otherwise report pending edits as shipped); a receipt carried twice keeps the reviewed head as `carry.from` and adds up the hops' merges and paths; the per-path comparison stops at the 20 paths the refusal names and the advisory `git range-diff` gets 15 s; `carry.reruns` says that test and helper code changed since the reviewed head runs in CI unreviewed. +- **`gstack-review-scope`**: `classify` refuses an unresolvable base with `POLICY_SOURCE_UNAVAILABLE` instead of reading the working tree's policy silently (pass `--policy` to classify without the base), validates `--range-base` as a commit id, runs git with `core.quotePath=false` so a non-ASCII changed path matches its finding, and records `range_base: null` on pass 1; a blocking finding with no path is `fix`, never an issue against `(no path)`; the Testing specialist's `category: testing` is a test finding below the floor (`TEST_GAP_CATEGORIES`), as the policy says; `defer` searches once for every marker before creating anything and files nothing when the search fails (a rate-limited rerun cannot duplicate issues), and exits 0 with `unfiled ()` lines (the documented degraded path); `summary` refuses with `REVIEW_SCOPE_INPUT` when no via-ship review pass matches `--since`, and `ship/sections/pr-body.md` chains it to the receipt write with `&&` after removing the previous loop file, so a receipt cannot record a loop that did not happen. +- **`ship/sections/review-army.md` pass 2 range**: the core checklist read (item 2) also diffs `$RANGE_BASE` from pass 2 on, as the dispatched prompts already did; the gate scan and the QA probes keep the whole candidate. `docs/review-policy.md` and `docs/ship-policy.md` say so, and name the release that added `review_scope` through `REVIEW_SCOPE_KEY_VERSION` instead of repeating the number. + +### Tests +- `test/review-scope.test.ts`: hunk parsing and locus with context, the blocking floor, `fix`/`defer`/`note` on pass 1 and pass 2 with a range base, the `all` policy, deferred-issue reuse through the marker and the unfiled path, the rendered sections and the summary counters. +- `test/gstack-ship-receipt.test.ts`: a base merge and a test-only change carry (receipt names HEAD, `carry` holds the range-diff, the summary line ends `carried_from=`); a product change is `RECEIPT_CARRY_PRODUCT_CHANGED` exit 3 naming the path; a non-ancestor head is `RECEIPT_CARRY_REFUSED`; `--review-loop` lands in the receipt and its summary line. +- `test/gstack-ship-policy.test.ts`: the `ship: review_scope frozen` consequence line, `all` or `diff-plus-blocking` without `min_gstack_version` and an unknown value are `POLICY_INVALID`. `test/review-fix-range.test.ts` asserts the Step 9 prose (PASS_HEAD, RANGE_BASE, classify, the cap rule). +- `test/review-loop-budget-wording.test.ts` (new): the glossary, the stop rules, the lead-judgment stage in both review armies, the `cause` anchor in every specialist prompt, and that `docs/review-policy.md` repeats no version number. `test/review-scope.test.ts`: a non-ASCII changed path classifies as `diff/fix`; `cause` anchors; an unplaced blocking finding is `fix`; `category: testing` is a test gap (asserted against `review/specialists/testing.md`'s schema line); `--pass 0`, `--policy loose`, an option-shaped `--range-base` and a non-array `render --deferred` refuse; an unresolvable base is `POLICY_SOURCE_UNAVAILABLE`; `defer` lists once, files nothing on a failed search and exits 0 unfiled; `summary` refuses zero passes. `test/gstack-ship-receipt.test.ts`: a whitespace-only edit to a reviewed line and a dirty tree refuse; a lockfile edit and a package.json dependency or script change refuse by path while a version-only stamp carries; a receipt carried twice keeps the reviewed head; the `classifyCarryPath` rows for lockfiles, `.spec.yaml`, stamp-only and non-stamp manifests. + +### Gate integrity +- No threshold, timeout, trial count or case kind changed. The ship parity `maxSizeRatio` in `test/helpers/carve-guards.ts` moves 1.6740 → 1.7010 (measured 319,071 / 187,706 = 1.6998) and `maxSkeletonBytes` 81,600 → 82,700 (measured 82,645) for Step 1's carry check, Step 9's scope prose and the skeleton's Review policy paragraph. The gstack-review host pins in `test/fixtures/skill-md-size-allowlist.json` go up by 250 characters on all eight pinned hosts (agy, codex, copilot, cursor, factory, kiro, opencode, slate) for the three-line policy statement in Step 4; the resolver prose that renders into those hosts was trimmed by the same number of bytes it gained for the `NO FINDINGS` and floor sentences. Owner decision for the pin raise: Garry, 2026-10-26, "Gstack should be the actual holder of how to do things not one off Capy files". +- Pass-1 review fixes raised the same two caps again, each with its measurement: ship `maxSizeRatio` 1.7010 → 1.7150 (measured 321,630 / 187,706 = 1.7135) for the lead-judgment stage, the `cause` anchor, the glossary, the stop rules and pass 2's core read; review `maxSizeRatio` 1.2960 → 1.3100 (measured 141,949 / 108,523 = 1.3080) for the shared lead-judgment stage and `cause` anchor. The gstack-review pins on the five hosts that inline the review-army section (cursor, factory, kiro, opencode, slate) go up by 1,396 characters each to their exact rendered sizes; agy, codex and copilot carve the section and are unchanged. The owner's standing decision for these raises is the one that opened the lane (GStack Minder, 2026-10-26: the two wording contributions are inside the "too overzealous" ruling, and a cap raise with a stated reason is the lane's to make). `test/review-workflow-clarity.test.ts`'s merge-stage marker list follows the new stage numbering (eight stages; the Red Team reruns 1–8); no assertion was weakened. `test/review-scope.test.ts`'s `defer` expectation moved from exit 3 to exit 0 because the contract moved (the prose always said `unfiled` keeps the finding deferred), and its `summary` zero-pass expectation from a zero record to a refusal, both by decision, not to pass. +- Pass 4 (a fourth review pass, approved by the owner's delegate for five findings at the blocking floor that the Step 11 outside voice and native adversarial pass raised after pass 3, recorded here as the policy requires): a path the policy names under `stamp_files`, `stamp_paths` or `release_outputs` is excluded from the carry's product patch only while its change is a stamp, the rule `lib/tree-receipt.ts` already applies to the same paths; `parseHunks` keeps a deleted file as a whole changed location, so a `cause` anchored in a removed export stays in the diff; `defer` files issues through `lib/gstack-post`'s publication scan, so reviewer text that quotes a credential stays `unfiled (publication scan refused: …)`; `classify` pins `--src-prefix=a/ --dst-prefix=b/ --no-textconv` so `diff.mnemonicPrefix` cannot send every finding outside; `summary` dates a pass from the logger's `review_binding.started_at` before the writer's `timestamp`. `test/review-scope.test.ts`'s deleted-file expectation moved from "dropped" to "whole" because the behaviour changed by decision; the e2e stamp-paths case now writes a real stamp (the old text with the version replaced) because a dropped line was never a stamp. + +- The `review-army-perf-n-plus-one` paid case (rule, gate) encoded the old severity: an N+1 was CRITICAL, so a foreground Red Team always followed the Performance specialist. Under the blocking floor the specialist rates a 12-line N+1 INFORMATIONAL (two focused runs at this head, both `0 critical`, no Red Team: `Red Team: not activated — ... no specialist produced a CRITICAL finding`), so the case now asserts the branch the run took: a Red Team that ran must follow the Performance specialist; no Red Team is correct only when the report's own count says `0 critical` and its Red Team line names that reason. The N+1 detection assertion is unchanged; `test/review-n-plus-one-contract.test.ts` gains the `informational-not-activated` (passes) and `informational-claimed` (fails: a claimed Red Team with no dispatch) controls, and the budget test's synthetic report carries the count line. No threshold moved; the case's kind and trial count are unchanged. + +## [1.91.255.0] - 2026-10-26 + +**Merge trains fill at 3 READY PRs or one hour after the first READY, and a train verdict's cost line no longer hides the trials it could not price.** On 2026-10-26 the 5-PR / 3-hour fill left READY PRs waiting most of a day, and `train verdict` printed `cost=$24.41` for a train whose slice report said `cost $32.92 known + 54 of 136 trial(s) cost unknown`. The slice report was right: 54 E2E trials had no cost attributed because their result records are named by a display label ("/benchmark workflow") while the case is named by its id (`benchmark-workflow`), so the per-case sum the train record carries was short by $8.51. Every train cost the coordinator reported until this fix was that undercounted verdict figure (about $24 per train against about $33 real). + +### Changed +- **`.gstack/ship-policy.json`**: `train.max_size` 5 → 3 and `train.fill_hours` 3 → 1. `fillDue` already dispatches at either bound, whichever comes first; a fourth READY PR is `HELD:` for the next train. docs/ship-policy.md says so in the fill paragraph and its example block. + +### Fixed +- **`scripts/lib/paid-report.ts`**: when a shard ran exactly one case (every case shard since W5a), its result records belong to that case even when they are named by a display label, so the trial's `cost_usd` is the record's and `cost_known` is no longer false; a failed trial in such a shard also takes its `failure_class`, `exit_reason` and `error` from that record (train-gate classes a red `infra` record INFRA instead of VERDICT) where it was a bare `assertion` with the JUnit message. No case's pass or fail changes. A multi-case shard whose records carry display labels keeps those trials cost-unknown rather than guessing which case paid. The train record's per-case `cost_usd` (scripts/train-gate.ts) and the slice report's headline now agree for case shards; the headline was never wrong. +- **`lib/ship-queue-train-verdict.ts`**: the `ATTEMPT:` line prints `cost=$ known + of case(s) cost unknown` when some but not every panel is priced, the bare `cost=$` only when every panel is priced, and no cost at all when none is (unchanged). + +### Tests +- `test/gstack-ship-policy.test.ts` pins the live fill rule (3 PRs or one hour) and exercises `fillDue` with the live block at 59 and 60 minutes and at 3 PRs. +- `test/paid-report-census.test.ts`: a display-labelled record in the periodic fixture's `qa-fix-loop` case shard gives the trial `cost_usd: 1.23` with no `cost_known: false`, `verdict.costUsd` 1.23 and the headline `cost $1.23 known + 6 of 7 trial(s) cost unknown` (red before the fix: `cost_usd: 0, cost_known: false`); a failed display-labelled record gives the trial `failure_class: 'timeout'`, its exit reason, error and cost (red before the fix: `assertion` with the JUnit message); the two-case `skill-e2e-cso` shard keeps its display-labelled trial unknown while the id-named one is priced. +- `test/ship-queue-train-cost.test.ts`: a green train whose record has two unpriced panels prints `cost=$0.70 known + 2 of 3 case(s) cost unknown`; a fully priced record prints `cost=$1.50` alone (red before the fix: the bare `cost=$0.70`); every panel priced at zero prints `cost=$0.00`. +- `scripts/lib/windows-curation.ts` excludes `test/ship-queue-train-cost.test.ts` from the Windows subset like `test/ship-queue-train.test.ts`: both drive `bin/gstack-ship-queue` against the `#!/bin/sh` gh shim, which windows-latest cannot run. + +### Gate integrity +- No threshold, rubric, trial count, case kind or judge prompt changed. Cost attribution changes what a trial record reports, never whether a case passed. + +## [1.91.254.0] - 2026-10-26 + +**Pregate ergonomics for a repository that is not gstack (Lane D, GSTA-112; plan harness-codify E4–E6; Captain issues 6, 11, 12).** Three Captains lost their first `/ship` pass to `PREGATE_NO_LANE` in a repo that had no `.gstack/pregate.json` yet (FOUN #292, harness-evals #81/#82); the non-downgradable `secrets` check refused 1,800 dummy per-trial tokens, 11-digit Actions run ids read as phone numbers and a public Firebase key every bundle rebuild re-adds (harness-evals #81/#83, GSTA-102, GSTA-106); a lane whose runner could not start read as the diff's failure and was waived by test name (truther #617); and pregate's logs under `os.tmpdir()` were shared between parallel runs and wiped by a Capy wake (gbrain 16). + +### Added +- **No policy → warn plus a starter, never a hard `PREGATE_NO_LANE`** (`lib/pregate/starter.ts`, `lib/pregate/checks/lanes.ts`). When the base branch carries no `.gstack/pregate.json` and no user-owned policy exists, a touched file with no lane is a `warn` row under the new code `PREGATE_NO_POLICY`; the selected tests still run (a red lane is still `fail`, an unknown platform matrix is still a `requires-remote` obligation), and the table prints a starter policy drafted from the repository: the detected runner, the test directories, the CI lanes (naming a planner-computed matrix that needs `selection`), the JSON between `PREGATE_STARTER: begin` and `end` with a `dependencies` entry per unmapped touched file mapped to the tests nearest it or sharing its stem (never `[]`), and a `TODO` line per file nothing covers. `run` writes nothing; `gstack-pregate init` is the explicit write. Once any policy exists (base copy, user-owned file, or `--policy-from head`), an unmapped file is `PREGATE_NO_LANE` as before. +- **`allowlist` in `.gstack/pregate.json`** (`lib/pregate/allowlist.ts`, `lib/pregate/config.ts`, `lib/pregate/checks/secrets.ts`): `[{ finding, paths?, pattern?, reason }]`, where `finding` is a gstack-redact finding id or one of the categories `pii`, `internal`, `hygiene`, `legal`; `paths` are contained globs; `pattern` is a regex matched at the finding's position in the added line and ending at a token boundary; one scope is required and `reason` always is (64 entries at most). The `secrets` row says `N allowlisted ()`, lists each allowlisted finding with its entry and reason, and records the entries' digest as `inputs.allowlist`. **`env.value` (a credential-named environment variable's real value in the added lines, #3449) and the whole `secret` category can never be allowlisted:** validation refuses the entry (`PREGATE_CONFIG_INVALID` at `allowlist[i].finding`) and the matcher never consults the list for `env.value`, so a stale or hand-edited policy cannot silence it; the scanner's own `engine.*` refusals (an oversize input fails closed) are refused and kept the same way. A `pattern` entry that named a finding the scanner gave no position for is reported as not tested (the finding is kept) instead of silently inert. An unparsed scanner report keeps its exit-code verdict. +- **Environment failures reported apart from diff failures** (`lib/pregate/environment.ts`; new code `PREGATE_ENVIRONMENT` with the troubleshooting anchor `pregate-environment`). A runner or guard binary missing from PATH (exit 127, `command not found`, a spawn `ENOENT`, a tool `not on PATH`), a network error or a quota refusal before any test was counted, a full disk, an unverified runtime or gstack's own scanner not finishing is `incomplete` with `cause: "environment"` and `environment: { kind, evidence }` in `pregate.json`, an `environment: — ` table line and a fix; `PREGATE_LANE_FAILED`, `PREGATE_GUARD_FAILED` and `PREGATE_SECRETS` carry `cause: "diff"`. `environment.proof` records how the cause was established: `launch` (a spawn failure, a binary off PATH, a runtime or tool refusal; the fix ends "the diff is not at fault") or `output` (a rule matched the command's text before any test was counted, which the diff's own output could have written; the table line says `(inferred from output)` and the fix asks for confirmation on another machine or with `--remote`). The classifier is conservative: a local lane that exited non-zero is read from its output only when its runner reported no test summary and was not seen starting a test file; a counted failure is the diff's whatever its log says; exit 127 and `command not found` are `missing-binary` only when the command's own binary is off PATH, so a runner, guard or gate script the diff controls that exits 127 is the diff's; a remote gate is environment only when it printed no `PREGATE_REMOTE:` line and the cause sits in its first 40 lines, before any test output could stream; a runner killed by a signal, stalls and timeouts keep `PREGATE_INCOMPLETE` / `PREGATE_STALLED`. `gstack-pregate verify` prints `PREGATE_ENVIRONMENT: check(s) blocked by the environment (), not by the diff` when every incomplete check is environment-caused, naming any check inferred from output alone, and names the kinds beside `PREGATE_INCOMPLETE` otherwise, so a lane waives by cause and proof, never by test name. +- **One scratch directory per run under the state root** (`lib/pregate/run-scratch.ts`): `/projects//pregate/runs//` (0700; `--run ` names it, else `--<4 hex>`) holds the secrets scan's added-line copies (deleted after the scan), `lanes/lanes.log`, `guards/.log` and the remote gate's log and file list, never `os.tmpdir()`. `pregate.json` records it as `scratch`, the table prints `scratch: `, a taken `--run` name takes a numbered sibling (`-2` … `-9`) rather than sharing the directory; runs older than 7 days under the same project are pruned when a new run starts; an unwritable state root falls back to a private per-run directory under `os.tmpdir()` and says so. + +### Fixed +- **`literals` no longer floods on a common module stem or a one-digit value** (`lib/pregate/checks/literals.ts` `consumersOf`). A test file counted as a consumer of a changed constant when it merely contained the source file's stem as a bare word (`install`, `retry`), so `GSTACK_CAPY_WRAPPER=2 → 3` in `capy/skill/gstack/scripts/install.sh` flagged 575 test files (`PREGATE_LITERALS`; GSTA-114, #3475). The module now counts only as a path fragment (`/install.sh`, `/retry.js`, `/retry'`), and an old value shorter than two characters needs the constant's name in the file. The same edit now flags the 5 files that name `GSTACK_CAPY_WRAPPER`. Regression: `test/pregate-selection.test.ts` "consumersOf: a module stem is a path fragment…". + +### Changed +- `docs/pregate.md`: the `allowlist` row, the "No policy yet" paragraph, three sections (allowlist, environment failures, scratch), the synopsis (`--run `), the stage-1 `guards` and stage-2 `lanes` bullets and the Publication code list. `docs/troubleshooting.md`: `PREGATE_NO_POLICY` and `PREGATE_ENVIRONMENT` anchors; `PREGATE_NO_LANE` and `PREGATE_INCOMPLETE` point at them; `PREGATE_GUARD_FAILED` carves out a guard whose own binary is off PATH, `PREGATE_SECRETS` points at the allowlist, `PREGATE_INCOMPLETE` names a signal-killed runner. `lib/headless-artifacts.ts`: the pregate schema accepts `cause`, `environment` and `scratch` (additive). `gstack-pregate --help` documents `--run`. +- Existing pregate tests whose fixtures had no policy and asserted the hard `PREGATE_NO_LANE` (`test/pregate-selection.test.ts`, `pregate-docs-lane-review.test.ts`, `pregate-non-js.test.ts`, `pregate-rust.test.ts`) now assert the no-policy warning and that the file is still named as unmapped; the Dart missing-SDK cases in `test/pregate-dart.test.ts` assert `PREGATE_ENVIRONMENT` and the new fix text. + +### Tests +- `test/pregate-starter.test.ts`: no policy → exit 0, `warn`/`PREGATE_NO_POLICY`, the mapped test still ran, the starter validates, maps by stem, and prints the TODO, test directories and CI lanes; a planner-computed platform lane keeps `requires-remote` with the code; a failing selected test is still `PREGATE_LANE_FAILED`; a base or user-owned policy keeps `PREGATE_NO_LANE`; `run` leaves no file and `init` writes one `validate` accepts. Red before the change: exit 1 with `PREGATE_NO_LANE`. +- `test/pregate-allowlist.test.ts`: validation (id or category with a scope and a reason; `env.value`, the `secret` wildcard, an empty scope, a bad regex and an uncontained glob refused); a dummy `sk-trial-…` token allowlisted by path and pattern and named, failing outside the path or under a pattern that covers only its head; a category entry for a run id; `env.value` survives a `pattern: ".*"` entry at the matcher and the config naming it is refused before any check runs. Red before: `PREGATE_SECRETS` on the dummy token and `allowlist` an unknown key. +- `test/pregate-environment.test.ts`: the classifier's vocabulary; a missing runner binary, a network error with no summary, a quota refusal from the remote gate (obligation uncleared) and a missing guard binary are `PREGATE_ENVIRONMENT` with their kinds and `verify` says so; a counted test failure beside an `ECONNREFUSED` line and a guard that exits 2 stay `cause: "diff"`. Red before: `PREGATE_LANE_FAILED` with no cause. +- `test/pregate-scratch.test.ts`: two `--run` ids get two directories under the fixture's state root, logs and the record name them, an unnamed run is unique, the secrets copies are gone after the scan, and prune keeps fresh runs and the current one. Red before: logs under `/tmp/gstack-pregate-lanes-*` and no `scratch` field. + +## [1.91.253.0] - 2026-10-26 + +**`/captain` (also `/crew`): the crew operating model that keeps many agents merging into one base branch without turning it red, as a gstack skill with templates and a team guide.** Teams running a controller agent over worker threads had the model only in one crew's private notes. It now ships generic: one controller merges and lanes never merge; a READY block (full head SHA, base, CI counts, pregate, gate evidence, `SHIP_RECEIPT` at the pin, screen choice with justification, receipt path, logged flaky cases) is the only merge trigger, and a green PR page or a cheap test tier never is; merge trains (build, verdict, per-slot stamp on call, verify, merge with the expected head, verify again, prune), the red-train rules (in-run flaky rerun once, bisect on the red cases, exactly one unchanged rebuild, one infra re-dispatch), the tree rule, the backstop and the freeze, and the post-merge babysit; the digest shape (Needs you / New / In progress with ages); the three approval shapes (the per-thread record, the plan ask with before → after and a "do it" reply, the merge ask with captioned before/after images); and the daily flaky-fix pass (ledger format, the gate runs' flake records and the free suites' retries as sources, red-before-green fix PRs, one named infra rerun allowance, never thresholds, trials or kinds). Where crews that run this model disagree (squash or merge, lane or controller stamps, gate-ahead, the severe race, a gate run id in the READY) the skill ships both as options for the crew rules file. Every repo-specific name is a parameter (`REPO`, `BASE`, `REQUIRED_CHECKS`, `FULL_GATE_CHECK`, `MERGE_METHOD`, `STAMP_FILES`, `KNOWN_INFRA_FLAKES`). + +### Added +- **`captain/SKILL.md.tmpl`**: the skill, tier 2, routed from the root router on "captain", "crew", a merge train, a slot call, a READY block or the crew digest; its own triggers add "validate this ready" and "flaky pass". +- **`captain/templates/`**: `CREW.md` (the 23 lane rules as one-line pointers into gstack's docs and skills, plus the crew's parameters and its standing-approval table), `AGENTS.md` (the controller's standing rules with the owner's dated words), `NOTES.md` (the append-only timestamped log), `DIGEST.md`, `flaky-ledger.md`, `report.md` (Outcome, PR, Needs, Cost, Lesson, Approvals), `READY.md` (the block, the optional lines, the refusal list), `slot-call.md`, `plan-ask.md`, `merge-ask.md` and `automation-flaky-pass.md` (the schedule, what it reads, what it starts). +- **`docs/captain.md`**: how a team sets up a Captain crew on Capy with gstack: the roles, the drive layout, the crew rules file, the READY contract, the lane's /ship sequence before READY, trains with the slot-2 recipe, approvals, the digest, the flaky pass, the owner's device, the lessons loop, and the setup steps; linked from the README docs table, `docs/skills.md`, `docs/capy-install.md` and the skill. +- **`test/captain-skill.test.ts`**: pins the contract the skill teaches, the template set, the generic wording (no crew codes, names or repository literals) and the registration points. + +### Changed +- **`gstack-owner-brief render --captain`** closes "What we need from you" with the **do it** reply (every recommendation at full scope, the plan's follow-ons included, carried through with no further ask) and invites `rec` or `do it`; `capy/skill/gstack/captain.md` documents it as step 6 and points at the controller's side. +- `README.md` (power tools), `AGENTS.md`, `docs/skills.md`, `docs/PROJECT_STRUCTURE.md`, the root router and the doctor component table register the skill; `test/fixtures/context-budget.json` gains the `captain` ceiling and the router's, grown by its one new rule; `test/catalog-budget.test.ts`, `CLAUDE.md`, `CONTRIBUTING.md` and `docs/CONTRIBUTOR_REFERENCE.md` carry the catalog ceiling the new skill ratchets to; `gstack-autoplan answer` accepts `do it` as a `rec` alias. + +## [1.91.252.0] - 2026-10-26 + +**`/retro` can now say whether the factory is getting better, and a new team has one page that takes it from zero to its first merged train on gstack + Capy.** `bin/gstack-factory-scorecard` reads one window (default 7 days) from GitHub through `gh` and prints a Markdown table plus a CSV: merged PRs (crew vs outside contributors), lead time (PR created → merged, and first READY comment → merged), gate runs green / red / cancelled with their known cost, cost per merge, the red-train rate, flaky events from the same two sources the daily flaky pass reads, issues opened vs closed, and releases cut. Each line carries its counting rule and what one of the best software factories would show on it; a line whose input cannot be read says `unavailable ()`, never 0. `docs/recommended-harness.md` is the adoption guide: install, the Captain and its Drive, the first train, the daily flaky pass, the weekly release, the scorecard with gstack's own numbers against the bar, what is Capy-specific, the options where two crews differ, and the known Capy limits as of 2026-10-26. + +### Added +- **`bin/gstack-factory-scorecard`** (`lib/factory-scorecard.ts`): `--repo`, `--base`, `--since-days`, `--crew login,login` (else author association OWNER/MEMBER/COLLABORATOR), `--gate-workflow`, `--gate-event`, `--free-workflow`, `--train-prefix`, `--ready-marker`, `--pass-marker` and `--pass-cap` (review passes per PR: median, p90 and how many PRs sat at the cap, from the ship receipt's `"passes"` in the PR body or a `passes=N` / `REVIEW_PASSES: N` comment), `--max-artifact-runs`, `--out-dir`, `--csv`, `--json`, `--fixtures` (recorded gh responses, no network) and `--now`. `--repo` defaults to the origin remote's owner/name and is required without one; every run appends its rows to `trend.csv` in `--out-dir`. Gate cost comes from the run's `report-verdict-a*` artifact (`collector-outcomes.json` `.verdict.costUsd`), else the `**$N** rule/judge cost` or `cost=$N` line of the PR's verdict comment; flaky events from `flake-records-a*` (paid) and `flake-ledger-*` (free) artifacts. A READY comment posted after the merge is ignored, a PR's verdict-comment cost backs one gate run, and a run whose artifacts cannot be read is counted and named rather than scored 0. Read-only; exits 0 with the table even when an input is unavailable, 2 on bad usage. +- **`/retro` Step 7.5 (Factory scorecard, optional)**: the report format gains a **Factory scorecard** slot; the step runs the bin for the retro window when `gh` is authenticated and pastes the table, third column included, into the report. +- **`docs/recommended-harness.md`**, linked from the README docs table; `docs/standalone-binaries.md` lists the new CLI. + +### Tests +- `test/factory-scorecard.test.ts`: the recorded window (`test/fixtures/factory-scorecard/recorded.json`) yields the expected merges, lead times, gate kinds and outcomes, artifact and comment cost sources, flake counts, free retries, issues and releases; a crew login list overrides the association rule; runs older than the window, non-dispatch events and draft releases are left out; the artifact cap leaves later runs without cost or flake data; a missing input marks only its lines unavailable; the markdown and CSV carry every row; gate classification, READY-marker matching, verdict-cost and flake-record parsing and nearest-rank percentiles; `--gate-event any` keeps non-dispatch runs; an empty window prints n/a instead of NaN; a failed merged-PR read leaves the gate rows standing; the bin end to end with `--fixtures`, `--crew`, `--out-dir`, `--csv`, `--ready-marker`, bad usage (including `--now`) and `--help`; the retro template pins the fence. +- `test/host-conformance.test.ts` C3 ("--status names a symlinked router and a broken section link per host") deletes a named section (`scope.md`) instead of the first `.md` that `readdirSync` returns: two gstack-qa sections are referenced only in table form, which `setup --status` does not count as links, so on an ext4 directory order that listed one of them first the case failed deterministically on remote-gate VMs while passing on Actions (#3479). + +## [1.91.251.0] - 2026-10-26 + +**A free, deterministic design lint ships with gstack: `bin/gstack-design-lint` checks a page's HTML, CSS and components for the tells that make it read as machine-made, in under a second, and with `--render` measures what only a browser can.** Until now gstack's only mechanical design check was the opt-in impeccable engine most users never install, plus `gstack-design-craft`'s seventeen copy and ship-floor regexes; everything else in the Slop Gate was prose the model judged itself. The lint ports the roughly fifty-five machine-checkable gates of [Nutlope/hallmark](https://github.com/Nutlope/hallmark)'s `sloplint` (MIT; see NOTICE.md) into TypeScript under `lib/design-lint/`, rewritten in gstack's voice and mapped onto gstack's severity words. This release is the tool alone (Phase 1 of the hallmark absorption plan, approved by Garry on 2026-10-26); /design-html and /design-review start calling it in Phase 2, so no skill prose changes here. + +### Added +- **`bin/gstack-design-lint [--render] [--url] [--scope page|component] [--job ] [--json] [--rules]`**: reads `.html`, `.css`, `.jsx`, `.tsx`, `.vue`, `.svelte` and `.astro` (Tailwind class strings included, `cn()`/`clsx()`/`cva()` calls too), links each page to the stylesheets it reaches through `` and `@import`, resolves CSS variables, converts colours to OKLCH and computes contrast, and prints one line per finding: `LINT: : | fix: `, then `LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=`. Exit 2 on blocks, 1 on an unanswered ask, 0 otherwise. `docs/design-lint.md` is the contract and lists every rule. +- **Three tiers in gstack's words, no fourth scale.** `blocks` (hallmark's Floor: contrast, focus rings, reduced motion, a card in a card, a purple-blue gradient, content hidden until JavaScript runs; never waivable), `ask` (hallmark's Reflex: three equal cards, Inter as the display, the centred-stack hero; waivable on the record) and `polish` (hallmark's Finish: straight quotes, a lonely last grid item; never blocks). Blocking and waived counts are never summed, so `blocks=0` is always a true sentence about the Floor. Three house rules hallmark holds at Floor stay at ask here: the eyebrow above a heading, the 80-word screen and the dash. +- **Waivers on the record.** `` (or the `/* */` and `//` comment forms; `impeccable-disable : ` is an alias) answers an ask or polish finding when the reason is about this page: 24 characters or more, not boilerplate, not reused on another waiver, sharing a word with the page's own text. A waiver of a blocking rule is refused and the rule still fires; more than three waivers on one page is reported as a house style that belongs in DESIGN.md. +- **The render tier** (`--render`, or `--url` for a dev server) runs on the same engine `gstack-render` picks, Aside first and gstack's own headless browser otherwise, with nothing to install: the hero fits at 1280x800 (the headline's bottom and the first action; the lede and the visual are not measured), about 80 words per 900px window, contrast against the painted pixels, focus rings found by focusing every control, headings crowded against the section above, text painted under another element, horizontal scroll between 320 and 1920, wrapping button labels, a hero offering more than two actions, section gaps under 120px or all alike and more than nine sections, thin sections, dead bands, drawn illustrations, empty picture slots, counting marks and photographs covering more than a third of the page. Without a browser the summary says `render=skipped: ` and the static tier still runs. +- `lib/aside-render.ts`: an eval step takes an optional viewport (`width`, `height`, `deviceScaleFactor`, `mobile`), set before the eval and cleared after it the way the screenshot step already does, and a spec's `file` may be an http(s) URL, opened as it is with no loopback server. Both engines take the size; `deviceScaleFactor` and `mobile` on an eval step reach Aside only (the browse daemon's `viewport` takes a size alone). Nothing else in the file changes. +- **The copy tier**: `gstack-design-craft`'s content-realism and ship-floor tells (placeholder names, lorem, buzzwords, round numbers, `[Needs proof]`, dead links, images without alt, the missing title, description, skip link and `aria-current`) report under the same contract and ids, so one command prints one list. `bin/gstack-design-craft` itself is unchanged until Phase 2 swaps the call. +- Fixtures under `test/fixtures/design-lint/` (hallmark's `slopped.html`, eyebrow and Tailwind pass/fail pairs and the waiver page, MIT-attributed, plus a clean page every rule family must leave alone); `licenses/MIT-hallmark.txt`; a NOTICE.md entry. + +### Tests +- `test/design-lint.test.ts`: the parsers (nested CSS, tokens, var() fallbacks, colour spaces, JSX pseudo-HTML), the rule table against `docs/design-lint.md`, the output lines and exit codes, waiver acceptance and every refusal reason, the waiver fixture. One test per rule family (`test/design-lint-.test.ts`) holds a negative control and a planted positive for every rule id. `test/design-lint-render.test.ts` drives the browse daemon when it is built and skips with a named reason otherwise. `test/design-lint-recorded.test.ts` lints the repository's recorded design slop page (the review eval's ground truth) and pins which rule answers each annotated issue, the same gate set hallmark's sloplint reports on the same files. + +### Gate integrity +- No threshold, budget, timeout, trial count or assertion was weakened. No template, catalog or eval changed. Gate edits in the diff: `test/fixtures/module-size-ratchet.json` registers the 27 new `lib/design-lint` modules under the existing 800-line cap, and `test/helpers/free-fixtures-data.ts` maps `test/fixtures/design-lint/**` to the design-lint test files; `lib/aside-render.ts` gains the viewport on eval steps and http(s) `file`, described above. + +## [1.91.250.0] - 2026-10-26 + +**Three free-tier fixtures stop racing themselves (GSTA-107 daily flaky pass).** Two tests polled for a result file's existence while its writer created it with a plain `writeFileSync`, so a poll between create and write read an empty file; a third copied a shared template repository with `fs.cpSync` and threw once inside the directory walk. + +### Fixed +- **`browse/test/dotenv-isolation.test.ts`** (Free Tests 39902683183 and 40322888041, shard 2, "JSON Parse error: Unexpected EOF" at the `agent-env.json` read): the probe writes its result to a temp file and renames it into place, and `waitForFile` waits for content, not only for the file. A new test feeds the old race (an empty file filled 150 ms later) and runs the probe source itself. +- **`test/claude-code-runner.test.ts`** (Free Tests 39694715116, shard 13, `running(pid)` false at the timeout case): the descendant publishes `descendant.pid` by rename, and the test polls `publishedPid()`, a positive integer, instead of the file's existence, so an empty read can no longer become pid 0. +- **`test/cso-git-hardening.test.ts`** (Free Tests 40288142343, shard 17, `fs.cpSync` threw in `fixture()` with an empty message and passed on the serial retry): every fixture builds its own repository with Git instead of copying a shared template, with auto-maintenance off (`maintenance.auto=false`, `gc.auto=0`) so the detached `git maintenance run` a commit starts no longer touches `.git` while a test walks or removes it; the `beforeAll` still pays the cold macOS Git spawns once, on a throwaway repository. No timeout changed. + +## [1.91.249.0] - 2026-10-26 + +**Five paid-case flakes from the 2026-10-26 train gates, repaired at their causes (GSTA-107 daily flaky pass).** The "ship-docsync family" was three distinct slips, not one shared cause: a mistyped `--help` path the detector refused although the usage had already been read, an undeclared refusal of gstack's own state directory as an artifact location, and a fixture that did not install the shared preamble a generated skill requires. tpa-present's consent question paraphrased "defer" as "wait for now", and qa-functional-webhook-fix ran the probe's `--help` bare before its first capture. + +### Fixed +- **ship-docsync-store (gate 40323989742 slice 3):** a candidate `--help` typed at a wrong, non-existent path (exit 127) is ignored when the declared binary's `--help` ran anywhere in the transcript, before or after, since a help probe reads nothing the agent still has to recover (`test/helpers/docsync-observer.ts` `candidateNotFound`); snapshot and compare still need the declared retry after the slip, and a wrong path that exists, a failed declared run, or no declared run at all still fail. +- **ship-docsync-completion (gate 40290033102 slice 2):** the parent put its snapshot `--out`, saved section and report insert under `/state`, gstack's own state directory (GSTACK_HOME), which the authority refuses but the fixture interface never named. The interface now says a private artifact lives directly under the fixture home or in a new directory there, and names every refused root: the repository, the installed skills, the Claude config dir, `/state` and `/remote.git` (`test/helpers/docsync-interface.ts`). The authority is unchanged. +- **docsync-spawned (PR #3434 screen 40269992077 slice 13):** `fixtureDocs` installs `/sections/preamble.md` beside the required sections when the generated skill has one, so a spawned child whose SKILL.md names the shared preamble as a required asset no longer blocks on its absence (`test/helpers/docsync-fixture.ts`). A generated skill without one installs exactly the files it did before. +- **tpa-present (gate 40290033102 slice 14):** the defer-option detector accepts "wait for now" and "hold off (on this) for now" as the option's own opening or sentence (`test/helpers/third-party-actions.ts` `offersDefer`); a drive option that waits while the user signs in, a "do not wait for now", or the phrase outside a lettered option still does not count. +- **qa-functional-webhook-fix (gate 40290033102 slice 9):** the fixture interface states that the evidence helper's `--help` is the only one it grants and that a bare `bun run probe` with `--help` or any argument outside a capture is a bypass (`test/helpers/qa-functional-eval.ts`); the grammar and the bypass detector are unchanged. + +### Tests +- `test/docsync-lifecycle-interface.test.ts` replays the captured store run (`test/fixtures/docsync-help-not-found-public.json`) as a pass, with controls for no declared `--help`, a failed one, and a wrong-path compare that only an earlier declared run precedes. +- `test/docsync-authority.test.ts` pins the interface sentence that names each refused root and replays the captured state-dir calls (`test/fixtures/docsync-state-dir-artifacts-public.json`): four refused commands and one refused write, all accepted once relocated to a new directory under the home. +- `test/ship-document-release-dispatch.test.ts` builds a generated root with and without a document-release preamble and checks the fixture installs it in the first case only. +- `test/third-party-actions.test.ts` adds the 40290033102 tpa-present render to the Opus consent captures (29 consent questions, 5 paraphrased defers) with new positive and negative wordings. +- `test/qa-functional-prompt.test.ts` pins the helper-only `--help` sentence before the observation forms and keeps `bun run probe -- --help` outside the grammar. + +## [1.91.248.0] - 2026-10-26 + +**`$B` in direct mode no longer lets Chromium pick a proxy from the environment (#3440 item 8).** `BROWSE_PROXY_URL=direct` (and an unparseable `HTTPS_PROXY`) only stopped gstack from configuring a proxy. The daemon inherits the whole environment and the launch passed no proxy switch, so Chromium read `HTTPS_PROXY`, `HTTP_PROXY` or `ALL_PROXY` itself and every `goto` still went through that proxy while `$B` reported a direct connection. That made the BROWSER.md precedence list false and any certificate check on a proxied machine unreliable. Reproduced on Linux with Playwright 1.62.1: a logging CONNECT proxy in `HTTPS_PROXY` received `CONNECT :443` with `BROWSE_PROXY_URL=direct` set. + +### Fixed +- **`browse/src/browser-manager.ts`**: each of `BrowserManager`'s four Chromium launch sites (headless `launch`, the `BROWSE_EXTENSIONS_DIR` persistent context, `launchHeaded`, `handoff`) adds `--proxy-server=direct://` when no proxy is configured and `forcesDirect()` says Chromium would otherwise pick one browse did not resolve, from one `proxyLaunchArgs()`. A configured proxy was already explicit (Playwright passes it as `--proxy-server`), so the environment can no longer win silently in either mode. `--no-proxy-server` was measured and rejected: chrome-headless-shell, the default headless build, ignores it and still took `HTTPS_PROXY`; the full Chromium honors both switches, and `direct://` works on both. The cookie-import worker (`cookie-import-native-worker.ts`), which launches the user's own browser only to read its cookie store and never navigates, is unchanged. +- **`browse/src/proxy-config.ts`**: `forcesDirect(env, platform)` is true on Linux always, where the leak lives, and on macOS and Windows only with `BROWSE_PROXY_URL=direct`. Chromium there never read the proxy variables and follows the system proxy settings (PAC and WPAD included), so a user of GStack Browser.app behind a corporate proxy keeps that connectivity, and a PAC cannot be named through `BROWSE_PROXY_URL`. An explicit `direct` bypasses the system settings on every platform. A standard proxy variable without a scheme (curl-style `host:port` or `user:pass@host:port`) is now read as an `http://` proxy (port 80 when none is given). For `host:port` that is what Chromium's own environment reader took it as: before this change browse rejected it with a warning and Chromium on Linux read the variable anyway, so that session was proxied; with the launch now explicit, reading it the same way keeps those sessions on their proxy instead of sending them direct. `user:pass@host:port` follows curl's reading and is newly proxied: Chromium rejects any proxy spec that carries a userinfo (`net/base/proxy_string_util.cc`), so that session was direct before and now sends its credentials to the named proxy. On macOS and Windows the same value is now a configured proxy too, where it used to be ignored in favour of the system proxy settings, so a daemon started under the old warning meets the mismatch refusal once. Only the plain `[user:pass@]host[:port]` shape is read that way; a value with a scheme (including the slash-less `https:user:pass@host:port`, which the URL parser reads as `https://`), a `/`, `\`, `?`, `#`, `@` or whitespace anywhere else in the value, or a bare number goes to the URL parser as written, so a bare number such as `1234` keeps its diagnostic and a one-slash spelling such as `http:/host:3128` is read as the URL parser always read it (`http://host:3128/`). A proxy URL with a path other than a bare `/`, a query or a fragment (a literal `?` or `#` anywhere) is now refused for every source (`--proxy` included), because that is where a `DOMAIN\user` login or a `/` in a password lands after URL parsing and only the userinfo is redacted; percent-encode `/`, `?` and `#` in credentials for every scheme and `\` in `http://`/`https://` credentials (a `socks5://` userinfo keeps a backslash as written). This narrows the environment path: a credential-free value with a path, such as `http://proxy.corp:3128/pac`, used to be read with the path dropped and the session proxied; it now warns and goes direct like any other unusable standard-variable value, and `--proxy`/`BROWSE_PROXY_URL` refuse it. Credentials that are not valid percent-encoding (a literal `%`) are a proxy configuration error instead of a crash, for every source. For a standard variable each of these parse problems is a warning and a direct connection, like any other unusable value; a scheme-less value with credentials beside `BROWSE_PROXY_USER`/`BROWSE_PROXY_PASS` is now refused like its `http://` spelling, which has always been a hard error (that value used to warn and go direct because it did not parse). A value that does not parse even as `http://` (an unsupported scheme, a bad port, a missing host) still warns and connects directly; `--proxy` and `BROWSE_PROXY_URL` still require the scheme. +- **`browse/src/cli.ts`, `browse/src/server.ts`**: on macOS and Windows the explicit `direct` is part of the daemon config hash (`explicitDirect()`), is recorded in the daemon state as `proxyDirect` and is re-emitted on a crash auto-restart from the session's own flags, so a running daemon started on the system proxy is refused rather than reused by a session that opted out (the refusal names `BROWSE_PROXY_URL` and offers `browse stop` or unsetting it; a daemon started before this release meets that refusal once, and a daemon with no recorded config hash is refused like one started without `--proxy`; the mismatch refusal offers `BROWSE_PROXY_URL=direct` as a remedy only where it would match the running daemon, on Linux or against a daemon started with it, and otherwise says to unset the variable that differs). The unparseable-variable warning's hint now says that `BROWSE_PROXY_URL=direct` also bypasses the system proxy settings on macOS and Windows. On Linux the opt-out hashes like no proxy, as before, because every direct connection there already carries the switch. +- **`browse/src/server.ts`** logs the effective proxy mode each time the daemon starts, beside the existing `[proxy] using from ` line: `[browse] [proxy] none: direct connection (--proxy-server=direct://; ignoring HTTPS_PROXY)` on Linux, naming each standard variable that was set and ignored; `(...; ignoring the system proxy settings)` for an explicit direct on macOS and Windows; and `[browse] [proxy] none: system proxy settings (Chromium's default on ; BROWSE_PROXY_URL=direct connects directly)` when those platforms are left to the system. +- **BROWSER.md, "Proxy from environment"**: says that whatever `$B` resolves is passed to Chromium explicitly, that on Linux a set proxy variable is ignored in direct mode, that macOS and Windows follow the system proxy settings unless `BROWSE_PROXY_URL=direct` is set (and that the opt-out is part of the daemon's settings there), where the daemon log records the effective mode, and that a managed Chromium proxy policy still overrides the switch. The eMudhra/TLS trust section is unchanged. + +### Tests +- `browse/test/proxy-direct-launch.test.ts` (Linux): two logging CONNECT proxies on loopback (`browse/test/helpers/connect-proxy.ts`) and a target name that does not resolve. With `HTTPS_PROXY` set and `BROWSE_PROXY_URL=direct`, the goto fails at DNS, no CONNECT reaches the proxy, the daemon log names the ignored variable and the state file has no proxy; this case fails on the unfixed tree with `ERR_TUNNEL_CONNECTION_FAILED` and a logged CONNECT. A scheme-less `HTTPS_PROXY` (`host:port`) is the configured proxy and the CONNECT reaches it (this case fails on the tree before the lenient parse); an `HTTPS_PROXY` that does not parse even as `http://` (`ftp://`) warns and still forces direct, so no CONNECT reaches the proxy it named. Two controls: `HTTPS_PROXY` alone is the configured proxy and receives the CONNECT; `--proxy` beside `HTTPS_PROXY` sends the CONNECT only to the configured proxy. Each case asserts on the daemon-log slice its own invocation wrote. +- `browse/test/proxy-direct-launch-paths.test.ts` (Linux, real Chromium under Xvfb): the `BROWSE_EXTENSIONS_DIR` persistent context, `launchHeaded()` and the `handoff()` relaunch each ignore an inherited `HTTPS_PROXY` (no CONNECT, `ERR_NAME_NOT_RESOLVED`), and a headed control with a configured proxy still sends the CONNECT to it. +- `browse/test/proxy-config.test.ts`: `forcesDirect` per platform, and `directProxyLogLine` for the Linux variable list, the explicit-direct line on macOS/Windows, and the system-proxy line. +- `browse/test/proxy-env.test.ts`, `browse/test/restart-env.test.ts`: the config hash separates an explicit direct from no proxy on macOS and Windows and not on Linux (`extractGlobalFlags` takes the platform); the mismatch refusal for both directions (explicit session against a system-proxy daemon, default session against an explicit-direct daemon) and the remedy an env-proxied session gets against a system-proxy daemon (unset the variable, not `BROWSE_PROXY_URL=direct`, which that daemon would refuse); `buildRestartEnv` re-emits `BROWSE_PROXY_URL=direct` from the invocation's flags only, never from the persisted state, and a proxied invocation wins over it; `resolveProxy` reads scheme-less standard variables as `http://` (with and without `user:pass@`, IPv6, no port, surrounding whitespace), leaves a one-slash scheme and a bare number to the URL parser, gives `h:3128` and `http://h:3128` the same config hash, warns for an unsupported scheme, a bad port, a missing host, a credential that would spill past the userinfo (`/`, `\`, `?`, `#`, including a `DOMAIN\user` login) or one that is not percent-encoded, without echoing credentials, refuses a proxy URL with a path other than a bare `/`, a query or a fragment (a bare trailing `?` or `#` included) for every source, keeps the protocol of a slash-less `https:user:pass@host:port` spelling, refuses mixed credential sources for the scheme-less spelling as for the `http://` one, and still fails closed for `--proxy` and `BROWSE_PROXY_URL`. +- `browse/test/proxy-env-daemon.test.ts`: through the real CLI and daemon, a scheme-less `HTTPS_PROXY` with credentials starts the daemon on that `http://` proxy, redacted in the start line and the state, and an `ftp://` value warns and falls back to direct; `BROWSE_PROXY_URL=direct` is written to the daemon state as `proxyDirect` where it bypasses the system proxy and a default session is then refused with the `BROWSE_PROXY_URL differs` lines; on Linux the daemon is reused. This file and `proxy-direct-launch-args.test.ts` join the macOS named-regressions CI job, so the refusal arm and the "no switch without an opt-out" arm run on macOS. `buildHeadedServerEnv` carries the opt-out to the headed server, and BROWSER.md's section is checked against the real switch and log literals. +- `browse/test/proxy-direct-launch-args.test.ts`: reads the real command line of the headless Chromium `BrowserManager` launched (from the pid it records) and asserts the switch is present on Linux and absent elsewhere without an opt-out, and present on every platform with `BROWSE_PROXY_URL=direct`; the per-platform expectations are written out, not read back from `forcesDirect`. Skipped on Windows, where no command line reader exists. + +## [1.91.247.0] - 2026-10-26 + +**/plan-eng-review no longer points its reader at three things its own text never defines.** The plan-eng-review sections judge marked down clarity and actionability for these references. All three live in shared resolvers, so the fixes reach every skill that renders them. The Claude Code hook that backs up AskUserQuestion carried the same stale pointer, so it's fixed too: + +### Fixed +- **Claims Need Evidence kernel** (`scripts/resolvers/preamble/generate-evidence-directive.ts`): the evidence-kind bullet ended with "Reuse rules: Step 16." That step exists only in /ship, so in every other skill the pointer led nowhere. It's gone. /ship's Step 16 still states its own reuse rule ("Reuse a check when its inputs match"), and every tier's DONE line still asks you to name reuse. +- **AskUserQuestion Tool resolution** (`generate-ask-user-format.ts`): "per its Unattended session block" read like a section of the skill, but the block is runtime output. The change removes the dangling pointer from item 1 and from the failure fallback's `unattended` branch. Both keep the destructive/irreversible carve-out the pointer used to supply: item 1 now names destructive or irreversible options among the pending gate items itself, and the fallback branch says a destructive or irreversible option stays pending, as the printed `unattended-session` block does. +- **AskUserQuestion fallback hook** (`hosts/claude/hooks/auq-error-fallback-hook.ts`): its unattended directive ended with "(Unattended session block; docs/unattended.md names each skill's record)", the same section-style pointer to runtime output. It now ends with "(docs/unattended.md names each skill's record)". The rest of the directive, including which skills print a GSTACK_RESULT line, is unchanged. +- **Reality rows, eng/ceo branch** (`scripts/resolvers/plan-reality.ts`): the Completion Summary row and plan-eng-review's checklist ask for the `PLAN_REALITY` line, but the operative block never said where that line comes from. It now says `gstack-plan-reality check` prints it. + +### Tests +- `test/honesty-kernel.test.ts` no longer requires the Step 16 pointer. It now rejects any `Step ` cross-reference in the kernel, which every skill renders. +- `test/resolver-ask-user-format.test.ts` checks that item 1 states the unattended pending set with no section or block pointer, in any spelling of the unattended-session block or "per its printed". It also checks that the failure fallback's `unattended` branch keeps destructive or irreversible options pending, and that the hook's unattended directive says the same. `test/gstack-skill-start.test.ts` checks that the block gstack-skill-start actually prints keeps destructive or irreversible options pending for every skill it samples. `test/auq-error-fallback-hook.test.ts` asserts the hook's unattended directive no longer says "Unattended session block". +- `test/plan-reality.test.ts` checks that the reality-rows block for the eng and ceo phases says its `gstack-plan-reality check` line prints `PLAN_REALITY`. +- 89 pins in `test/fixtures/skill-md-size-allowlist.json` were moved to their new exact sizes, and every pin went down. 78 went down by 32 characters. The 11 non-Claude plan-eng-review pins went down by 8, because they also inline the 24-character "prints `PLAN_REALITY`" pointer from `review-sections.md`. Both figures already include the destructive/irreversible carve-out that item 1 and the fallback branch keep. The /ship goldens were regenerated. + +### Gate integrity +- No threshold, rubric, trial count, case kind or judge prompt changed. Every size pin went down (78 by 32, 11 by 8); none was raised. + +## [1.91.246.0] - 2026-10-26 + +**The README is a first-ten-minutes page again: what gstack is, how to install it, the first three skills to try, the skills tables, platform notes, and links to docs/ for everything else.** It was 862 lines and 90 KB, with CSO producer sandboxing, Codex model-selection order and the uninstall script in the path of someone deciding whether to try it. It is now about 300 lines and 33 KB. Nothing was deleted: every removed section moved to a docs/ page, verbatim except the "Proactive skill suggestions" paragraph in docs/why-gstack.md, which now describes the core-set default and `gstack-config set proactive core|all|off` landing in #3432, and every anchor other files link to (`#install--about-30-seconds`, `#gbrain--persistent-knowledge-for-your-coding-agent`) still resolves. + +### Changed +- **`README.md`**: a short intro, a three-skill quick start (/office-hours, /review, /qa), the two-step install with the host matrix, the demo transcript, the sprint table, the review picker and the power-tools table unchanged, a short GBrain section, new "Platform notes" (macOS, Linux, the Windows paragraphs), the Docs table with the new pages, a one-paragraph privacy summary and a short troubleshooting section that keeps the CLAUDE.md `/browse` rule. + +### Added +- **`docs/why-gstack.md`**: the Karpathy quote, the LOC numbers and their caveats, the contribution graphs, "Karpathy's four failure modes", "Parallel sprints" and "10-15 parallel sprints". +- **`docs/install.md`**: setup timing, the requirements paragraph in full, the CSO image-preload window, team mode's Node.js hook, and the hooks `./setup` registers (the timeline Stop hook and canonical-only hook paths). +- **`docs/uninstall.md`**: the uninstall script and the manual removal steps, CLAUDE.md cleanup, Playwright and Aside notes. +- **`docs/hosts.md`**: OpenClaw and the ClawHub skills, which agents a checkout renders for, the Copilot and Antigravity notes, outside-review providers, the `/claude` rename, the instruction-only digest, the Codex behavioral profile and model selection order, the plan-review and implementation tiers, the design binary's models, and adding a host. +- **`docs/qa-without-a-webpage.md`**, **`docs/standalone-binaries.md`** (every `gstack-*` CLI plus the CSO evaluation producer contract and its host matrix), **`docs/voice-input.md`**, **`docs/privacy.md`** (the full Privacy & Telemetry section). +- **`docs/domain-skills.md`** gains the README's summary of domain skills and the raw CDP escape hatch; **`docs/gbrain-quick-tour.md`** holds the README's short version of the four paths, trust policy and memory sync; **`docs/troubleshooting.md`** gains a "Quick answers" section with the README's short troubleshooting entries. + +## [1.91.245.0] - 2026-10-26 + +**`gstack-ship-queue ready` counts CI the way GitHub's rollup does, and names the way through a CHANGELOG-only conflict.** A superseded run (gbrain saw a cancelled `contributor-gate` beside three later passes on the same head) made `ready` print `failed=1` where GitHub showed 0 failed, and a PR that conflicts with main on CHANGELOG alone had no documented route to green CI on its head. + +### Fixed +- **`ready`'s `ci:` line** (`lib/ship-queue.ts`, new `latestRunPerName`) judges each check (one app's check name) by its latest run (newest `completed_at`, then highest id; a run still in progress is newest), so a cancelled or re-run earlier attempt no longer counts as a failure; two apps reporting the same name stay separate, a run without a name or without a readable `completed_at` counts on its own, and commit-status contexts are counted one each, as before. +- **`ready` prints a `ci-hint:` line on a conflicting PR** (`gh pr view` now also reads `mergeable`, `headRefName` and `isCrossRepository`): with no CI at the head it names the procedure (dispatch `free-tests.yml` and `windows-free-tests.yml` with `gh workflow run … --ref `, clear the pregate lanes with the run URLs, rerun `ready`); with green runs at the head it says the dispatched runs count; a head on a fork gets a hint that leaves the dispatch to the coordinator; a branch name outside `[\w./-]`, or starting with `-`, prints as ``. Every hint ends in `(advisory)`; nothing parses it. No hint without a conflict, or while the runs are red or pending. +- **Docs:** `docs/ship-policy.md` records that procedure under "Lane: report ready" (and that `--screen` is `QUEUE_SCREEN_STALE_REF` on such a PR for want of a merge ref, with advice that does not apply to a CHANGELOG-only conflict), rewords the `stamp-at-merge` row's `next:` description to the train wording `gstack-restamp` prints since #3439 (`commit and push only the stamp diff above (FILES), with no edit of your own`), and rewords the restamp `changelog` stage so `CHANGELOG_DUPLICATE_HEADING` is described as #3439 implements it: only a duplicate the PR introduces counts, measured against the base's CHANGELOG. + +### Tests +- `test/gstack-ship-queue.test.ts`: two runs of one check name where the older is cancelled read green (either order), a newer failure reads red, a newer in-progress or queued run reads pending, equal `completed_at` falls back to the id, unnamed and undated runs stay separate, two apps with one name stay separate, a null conclusion reads red, and a commit status never collapses with a check run of the same name; a conflicting PR with no CI refuses with the dispatch hint, with green dispatched runs READYs with the "counts" hint, a fork head and an odd or dash-led branch name get the safe hint, and no hint appears without a conflict or while runs are red or pending. + +## [1.91.244.0] - 2026-10-26 + +**A restamp PR no longer fails pregate for the manifests it stamps.** gbrain's `bun run release:restamp` rewrites its plugin manifests and VERSION; its base's `.gstack/pregate.json` declares no lane for them, and pregate reads policy from the base, so every such PR failed `lanes` with `PREGATE_NO_LANE` and could not fix itself (gbrain #7625, #7606). + +### Fixed +- **`gstack-pregate` lanes**: a touched `*.json`, `*.plist`, `VERSION` or `VERSION.txt` file whose diff against the merge base changes nothing but its version values is waived as version-stamp-only, on its own line (`version-stamp-only (no lane needed): — only version values changed against the merge base; a manifest whose other content changed still needs a lane`), with the per-file verdict under `--explain`. The rule is exact per kind (`lib/pregate/stamp-only.ts`): JSON objects of the same shape (same own keys in the same order, same array lengths, same leaf types) whose only changed leaf is the document's top-level `version`, a bare version on both sides, with the text differing on that line alone; plist `` versions under `CFBundleShortVersionString` or `CFBundleVersion`; a VERSION file that is one bare version on both sides. Nothing nested qualifies (a dependency pin, a lockfile entry, a catalog), texts are compared byte for byte outside the stamped value (a line-ending change or a BOM is content), and a changed description, a new, removed or reordered key, a floor such as `minHostVersion` or `MinimumOSVersion`, a change the parser would erase, an invalid-UTF-8 or over-1-MiB file, a mode change or symlink, an added, deleted or renamed manifest, a file under a test, fixture, golden, `.gstack/` or workflow tree, or any other file kind is not a stamp and keeps its `PREGATE_NO_LANE`. It applies only after the import graph, the declarations, the regen registry and both empty lanes found nothing, so a manifest a test reads still selects that test. `docs/pregate.md` and the `PREGATE_NO_LANE` troubleshooting entry state the rule. + +## [1.91.243.0] - 2026-10-26 + +**The launch environment reaches every gstack command and child through a sourceable file, and nothing in a skill fence may print the environment (GSTA-104).** During GSTA-96's unattended /ship on #3425 (2026-10-26), the Step 14.5 docs-audit child was told to export the `GSTACK_LAUNCH` assignments and re-typed them as `export $(...)`. The substitution came back empty, the shell ran a bare `export`, and the values of ANTHROPIC_API_KEY, OPENAI_API_KEY and UBICLOUD_API_KEY landed in its tool output. The line itself carries no secret (PATH, GSTACK_SESSION_KIND, GSTACK_STATE_ROOT, GSTACK_EPHEMERAL, GSTACK_CAPY, GSTACK_INSTALL_VERDICT); the hazard was re-typing it. Now the installer writes the assignments as a file callers source, every fence that hands the launch environment to a child says so in one line, a free ratchet keeps environment dumps out of every skill fence and shell helper, and the redaction engine refuses a credential value from the writer's own environment, whatever its shape. + +### Added +- **`GSTACK_LAUNCH_FILE: .env`** after the `GSTACK_LAUNCH` and `GSTACK_LAUNCH_PATH` lines of every passing `bin/gstack-capy-install` run, `--check` included . The file is part of the ok verdict: a passing run that cannot write it exits 1 with `gstack: fail launch-file — could not write the launch file ; fix: … (LAUNCH_FILE_UNWRITABLE)`, and a failing run announces no launch file and removes the one an earlier passing run left beside the verdict it rewrites (a profile check that keeps a fuller ok verdict leaves that verdict's file; an unwritable directory keeps both the old file and the old verdict), so no caller is left with only the line and the re-typing path. The file holds the same assignments as `export` statements (the PATH prepend is idempotent), is written atomically with mode 0600 beside the install verdict under the state root, and is applied with `. ` at the start of the shell that runs a gstack command. A caller hands a child the path, never the values. +- **`env.value` (HIGH) in `lib/redact-engine.ts`**: the value of every `*_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_PASSWD`, `*_PASS`, `*_PAT`, `*_APIKEY` or `*_PWD` variable in the scanning process's environment (any case; eight characters or more after trimming, not a placeholder; names carrying a `PUBLIC` word and gstack's own `GSTACK_BRAIN_LOCK_TOKEN` are exempt, and there is no path exemption: a path-valued `SSL_KEY` that must appear in a text goes through `gstack-redact --allowlist` or the pre-push hook's `.gstack-redact-allowlist`, and for the writers that take no allowlist the variable is set empty for that one command) is a finding wherever it appears in the scanned text, so a leaked value cannot reach a PR body (`gstack-post`), a ship receipt, a report, an approval row or a Drive mirror through any writer that scans with the engine. The value is matched in the same normalized form as the text, so a value NFKC rewrites still finds its leaked bytes; when one value is a prefix of another, the longer value is redacted whole; the finding's preview never shows a byte of the value. `redactFindingSpans` removes it on the machine-egress path; `ScanOptions.processEnv` names the environment (`{}` scans without one). `test/redact-env-value.test.ts`. +- **`test/launch-env-echo-ratchet.test.ts`**: every generated SKILL.md and section file, their templates, `autoplan/DRIVER.md`, the Capy skill, every shell helper under `bin/`, `setup` and the `scripts/*.sh` install scripts carry no `export $(...)`, `export \`...\`` or unquoted `export $VAR`, no `env` or `printenv` (flags alone included), and no bare `set`, `export`, `declare -x`, `typeset -x`, `declare -p` or `typeset -p` in any command segment of a fence (`set | grep '^NAME_'` stays allowed: the anchored filter bounds what prints). `test/fixtures/launch-env-echo/` holds the negative controls the scanner must trip on. + +### Changed +- **`ship/sections/documentation.md.tmpl`** (Step 14.5 subagent prompt), **`document-release/SKILL.md.tmpl`** (spawned note), **`autoplan/DRIVER.md.tmpl`** and the runtime `capy-asks` block of **`bin/gstack-skill-start`** (every workflow started with `GSTACK_CAPY=1`, /review and /qa included) each say in one line: apply the launch environment with `. `; never `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence, because a bare `export` prints every variable, credentials included. /review and /qa dispatch no child that runs a gstack command, so their SKILL.md files, whose sizes are pinned, are unchanged. Nearby prose in the Step 14.5 section was tightened so /ship's union stays under its parity cap (313,279 of 313,281 bytes). +- **`capy/skill/gstack/SKILL.md`** (the source of the Drive skill) and **`docs/capy-install.md`** describe the launch file and name the incident; the Drive copy needs the owner's recopy to say the same. +- **`scripts/lib/shard-engine.ts`** `createShardSandbox` and the test preload (`test-setup.ts`, so a direct `bun test ` matches the lanes) drop `GSTACK_SESSION_KIND`, `GSTACK_CAPY`, `GSTACK_EPHEMERAL` and `GSTACK_INSTALL_VERDICT` from every test's environment, so a run started from a shell that sourced the launch file no longer hands the product-under-test children an unattended Capy session they never set up (GSTA-99's finding); each fixture sets its own session kind. `GSTACK_STATE_ROOT` and PATH are kept. + +### Gate integrity +- One test budget changed: `test/setup-gbrain-remote-caller.test.ts`'s late-completion case gives its second attempt 2s (was 250ms) with a 20s test timeout (was 10s) after it failed 4 of 4 on windows-latest once the new files repacked the Windows shards (run 40263984171). No other gate, threshold, budget, timeout, trial count or case kind changed. The redaction scan only gained findings, minus the framework-public and non-secret exemptions named above. Size pins are unchanged: the edited skills are under the limit. + +## [1.91.242.0] - 2026-10-26 + +**`gstack-context-bill` now bills a real install.** On `~/.claude/skills` it reported "2 skills, ~134 tok" always-on, because setup installs each skill as a real directory whose `SKILL.md` and `sections/` are symlinks into the gstack checkout, and the walker only counted regular files. The tool built to answer the token-cost complaint gave a falsely tiny answer on the layout users actually have. On a fresh install it now reports 58 skills, ~6.8K tokens always-on, and 3.6 MB on disk (it was 37 KB across 2 skills). Billing the checkout itself is unchanged (66 skills, ~7.2K tokens). + +### Fixed +- **`lib/context-bill.ts`**: `findSkillDirs`, `walkMd` and the foreign-file check follow symlinked files and directories. Every path the bill reports, and every file `--exact` keys on, stays the lexical path under the root you passed. A link to a directory that contains the tree being billed or the skill being walked (`..`, `/`, `$HOME`) is never followed, so a link cannot climb out of the root into its parent or your home directory, even from a symlinked skill that lives elsewhere. The markdown walk expands each real directory once and counts each real file once per skill, under the first path reached in sorted order, which is how a skill loads it: loops end, links that fan out into each other cannot multiply the walk, and a tree with more than 200,000 entries is refused with an error instead of being cut short. `--exact` walks and sends only what the bill walks (the same per-skill walks and fences, so a link no skill bills is never walked), each real file once, measures frontmatter only for the `SKILL.md` files the bill prices it for, and prices every link to it with that one measurement. It sends only files inside the tree, inside the gstack checkout that the tree's `gstack/` entry links to (a git checkout with `VERSION`, `setup` and a `SKILL.md` named `gstack`; `bin/dev-setup`'s `.claude/skills/gstack -> ` counts), or inside gstack's render folder `$GSTACK_STATE_ROOT/render` (where setup renders the `SKILL.md` files that gbrain, `--claude-model` and per-install renders link to; trusted only when `render` is a real directory, not a link). Each file is judged by its realpath, so a link out of one of those folders is refused too; anything else a symlink reaches (a skill's `notes -> ~/Documents`, say) is not sent, its directory is named in the notice, the files are listed, and they are estimated offline. The notice also says how many sent files come from the checkout and how many from the render folder. A bill in which `--exact` had to estimate any item (a refused file, or one that changed after it was measured) says so: `tokenSource` starts with `mixed:` and names the count, `tokenEstimateErrorPct` keeps the estimate's 40% band, and the text output says the counts are mixed instead of measured. A measurement is reused only for a file that still has the realpath, inode, size and mtime it was measured with, so a link retargeted or a file rewritten after the measurement (during `--diff`, say) is estimated and counted as estimated instead of inheriting the old count. Billed paths are matched to their measurement one link at a time, so a second skill sharing a link chain deeper than the kernel's limit is still priced from the one measurement. Files are opened without blocking, so a file swapped for a FIFO is refused instead of hanging the run. An empty `.md` file and a `SKILL.md` without frontmatter are measured as zero tokens without a request, and a whitespace-only `.md` file is not sent and is estimated (so the bill says mixed), because `count_tokens` rejects content with no non-whitespace text: a blank file used to drop every measurement with a 400, and an empty one turned a fully measured bill into a mixed one. When every file `--exact` could send is blank, nothing is sent, no receipt is written, and the notice says so (and whether other files were refused). The sending notice is printed after the files are read, just before the receipt, and counts exactly the files sent. A state root that is missing, unreadable or not a directory just means there is no render folder to trust; the bill still runs. Each file is read through the realpath its walk recorded and only while it is still that same file, so a link retargeted or a file swapped for a link mid-run is refused rather than sent. A link named `*.md` counts only when its target is a `.md` file, so `--exact` never sends a non-markdown file through a markdown-named link. `walkMd` skips a child repo checkout (the `gstack/` link beside the installed skills), as `findSkillDirs` already did, so `--exact` does not send the whole checkout. The walk and skill discovery read each directory through its realpath, so a chain of links deeper than the kernel's 40-link limit no longer drops files or skills silently. Skill discovery has the same 200,000-entry cap as the walk. A container skill skips only nested directories whose `SKILL.md` is a file, as discovery does, so a `sub/` whose `SKILL.md` is a directory is billed to the container instead of to nobody. The root container also skips a nested skill that a directory link reaches at any depth (discovery already bills it), so `refs/alias -> ../sub` no longer counts `sub`'s files a third time in TOTAL. `--exact` on a path that does not exist now stops with `No such tree` before any request, including `--diff` with one missing tree (it used to send its one-character envelope probe first, and `--diff` sent the whole first tree), and its notice counts the same list of files it then sends. An existing tree with no skills sends nothing and writes no egress receipt, a tree whose skills have no markdown file to send says the offline estimate was used, and a tree whose every billed file was refused says so. `--help` now says which `.md` files `--exact` sends and that others are named and estimated. + +### Tests +- `test/context-bill.test.ts`: an installed-layout fixture (a checkout outside the skills root, real skill directories with symlinked `SKILL.md` and `sections/`, and a `gstack/` link to the checkout) bills both skills with the target's exact frontmatter, `SKILL.md` and section bytes, and keeps every walked path under the root. Negative controls: a dangling `SKILL.md` link and a `SKILL.md` link to a directory are not skills. A second fixture holds a self link, a parent link that reaches a skill outside the root, a loop back into the skill and a link up to the temp root; only the one real skill and its own files are billed. Further cases cover a loop to a directory below the walk root, a root whose name starts with `..`, a symlinked `agents.md` (with a dangling link and a directory of a foreign-file name as controls), `--exact` on the installed layout (every billed file measured; nothing behind `gstack/` or a `.md`-named link to a non-markdown file is sent), a 12-level fan-out of links that reaches one file by 2^12 paths (walked once, where a walker that expands every link would return all 4,096), two skills linking one checkout file and directory (each real file sent once, both skills priced from it), a symlinked skill outside the root with a link up to the root's parent, the `--exact` notice's count of files from the gstack checkout, a skill whose `SKILL.md` another skill's walk reaches first under another name (its frontmatter still measured), a container skill whose nested skill links to the container's own files (both skills still bill them), `--exact` on a missing tree and `--diff --exact` with a missing second tree (named, with no request sent), a symlinked skill with a link up to its real parent (the files there are not billed, so not sent, and no billed file is keyed through that link), an existing tree with no skills (no request, no receipt), a skill whose `SKILL.md` links to a non-markdown file (no request, no receipt, and a line saying the estimate was used), a reference file with frontmatter (no frontmatter request for it), an install whose skill also links `notes` to a documents folder (the checkout files are sent; the documents file is refused, named, listed and estimated), a `gstack/` entry that is not a gstack checkout (a plain folder, a bare `.git` folder, another project's repo, or a gstack-shaped folder without `.git`: nothing behind it counts as the checkout), a `bin/dev-setup` tree inside the gstack repo (its skill files are sent), refused files in nested and sibling folders (each folder named once), every billed file refused (said so), a link retargeted, a file replaced by a link or a hard link, or a parent folder swapped during `--diff --exact` (never sent; a swapped file stops the run), a 50-deep chain of links (every file walked, billed and, under `--exact`, measured), a skill 45 directory links deep (discovered), skill discovery past its entry cap (refused), a `sub/` whose `SKILL.md` is a directory (billed to the container), a tree with no skills under `--exact` (no estimate line), a root container with a `refs/alias -> ../sub` link and a link to an outside folder holding a skill (neither nested skill billed to the container), a file swapped for a FIFO after the walk (refused within the subprocess timeout, where it used to hang), two skills sharing a 50-deep link chain (both priced from one measurement, nothing estimated), a `SKILL.md` link retargeted to a bigger file and a reference rewritten in place after measurement (both estimated and counted as such), a bill with a refused skill (`mixed:` in `--json` and in text), a file replaced by one of the same size and mtime (estimated), a skill without frontmatter plus an empty reference under a `count_tokens` stand-in that rejects empty content (still fully measured), a render folder that is itself a link (not trusted), a tree whose only file is empty, alone and beside a refused link (no request, no receipt, and a notice naming which case it is), a whitespace-only reference under a stand-in that rejects it (not sent, estimated, the bill labelled mixed, and the notice counting only the file sent), the render folder found through `GSTACK_STATE_ROOT` with no state root passed in, a state root that is a file (the bill still runs, measured), a git checkout inside a skill or linked from one (not billed; this case passes on main and fails when the walk's `.git` check is removed), and a render install (its `SKILL.md` and sections sent from `$GSTACK_STATE_ROOT/render`, a link from there to a documents file refused and listed). The installed-layout and escape fixtures fail on main (the first finds no skills, the second bills the outside skill), the missing-tree case fails on main (one request sent), and each later case failed on the commit before its fix, except the hard-link and parent-swap cases, which pin the identity check added with the realpath reads (they fail when that check is removed). + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion changed. The bill of this repo's own tree, which the context-budget ratchet reads, has the same skills, bytes and rounded token figures as on main. Four raw `totalMdTokens` values in `--json` differ in the last floating-point digit, because each skill's files are now summed in sorted order. + +## [1.91.241.0] - 2026-10-26 + +**The weekly OSV scan on main is green again, so the weekly release cut can run.** The 2026-10-26 scan ([run 40288353843](https://github.com/garrytan/gstack/actions/runs/40288353843), #3462) found 14 fixable vulnerabilities in six packages across both lockfiles, and the release workflow skips any main commit with a red check run. Every one is bumped to its fixed release; no application code changed, because gstack imports none of these packages directly (the MCP SDK and zod arrive through `@anthropic-ai/claude-agent-sdk`, body-parser through its express, fast-uri through ajv, ip-address through socks and express-rate-limit, dompurify through mermaid). + +### Fixed +- **dompurify 3.4.16 → 3.4.17 in `lib/diagram-render`** (GHSA-cg2g-xh64-v4q7, CVSS 9.2; GHSA-mrv2-3pqr-j5jr; GHSA-c6gr-qvv4-5v3f). The override moves and the committed bundle is rebuilt with Bun 1.4.2 (two builds, one sha256 `c2d37f9f…`), so the shipped page runs the fixed sanitizer instead of a bundled copy of the old one. Mermaid, excalidraw and React pins are unchanged. +- **@modelcontextprotocol/sdk 1.31.0 → 1.32.0** (GHSA-22jm-h49p-29qw, GHSA-46jr-4wx8-v453, GHSA-rvq5-wwqv-78pq, GHSA-6prh-2h8m-c8cw), **fast-uri 3.1.8 → 3.1.9** (GHSA-w74x-v6f9-j3xg, GHSA-wjr9-j795-jxcr, GHSA-333x-w4r6-6crp, GHSA-xhq7-xmqj-769h) and **ip-address 10.7.3 → 10.7.4** (GHSA-rxp7-6hxx-x8q5) through the existing `overrides` pins. +- **zod 4.5.4 → 4.6.7** (GHSA-mrhx-c7xc-m3w5, fixed in 4.6.6) and **body-parser 2.3.0 → 2.4.0** (GHSA-27r3-jxmh-p828) through the lockfile alone; both sit inside their consumers' ranges, so no override was added. body-parser 2.4.0 brings a nested qs 6.16.1. + +### Changed +- **`lib/diagram-render/scripts/build.ts`** empties the one `VITE_APP_FIREBASE_CONFIG` literal that `@excalidraw/excalidraw`'s npm bundle carries (excalidraw.com's public Firebase client key, which nothing in the package reads and the offline render page never uses). The build fails if the literal is not matched exactly once or any `AIza…` key survives, so an excalidraw bump that changes its shape cannot put the key back silently. Without this, every rebuild of the committed bundle failed the pre-gate secret scan. + +### Tests +- `test/diagram-render-drift.test.ts` asserts the bundle's DOMPurify is the `package.json` override and no other version, and that it carries no Google API key; its path constants are repo-relative segment runs so the pre-gate's import graph selects it for the bundle, `BUILD_INFO.json` and the build script. +- `test/deps-smoke.test.ts` refuses `ip-address` below 10.7.4 and checks every resolved version of the six packages against its OSV-fixed floor in both lockfiles (all six fail on main's lockfiles). OSV-Scanner 2.3.8 with the workflow's own arguments (`--config=.osv-scanner.toml --include-git-root --recursive ./`) reports no issues on this tree; the katex ignore is the only filtered advisory. + +## [1.91.240.0] - 2026-10-26 + +**Proactive skill suggestions default to the four core skills.** `proactive` now takes `core` (the default: only /office-hours, /review, /qa and /ship auto-invoke when a request matches), `all` (any matching skill, the old behavior) or `off`. `true` and `false` keep working as `all` and `off`. The sentiment research (GSTA-93) found auto-invocation of every skill is what makes gstack collide with other skill packs; the four core skills are where the praise is, so they keep the nudge. Garry, 2026-10-25: "do it". + +### Changed +- **`bin/gstack-config`**: `proactive` defaults to `core`; `set proactive` accepts `core`, `all`, `off`, `true` and `false` in any case, stores the YAML yes/no family (`yes`/`on`/`1`, `no`/`none`/`0`/`disabled`/`never`) as `all`/`off`, and warns on anything else, storing `core`. The config header documents the three values. +- **`SKILL.md.tmpl`** (the router): three branches, `off`, `core` and `all`; under `core` only the four core routing rules auto-invoke, the browser step and the Failures branches name `/browse` or `/investigate` for the user to type, and a skill the user names is always invoked. The opt-out line writes `off`, and `all` and `core` have their own lines. +- **Every skill's preamble** (`scripts/resolvers/preamble/generate-upgrade-check.ts`): the steady-state rule names the four core skills under `core`, scopes the setting to unasked suggestions, carves out only the skills the user names or the running skill invokes itself ("Skills the user names or this one invokes run", so `/ship` dispatching `/document-release` is not a suggestion, while an in-skill offer to run another skill still counts as one under `off`), reads only `core`, `all` and `off` because `gstack-skill-start` now reports the legacy spellings as those, and is exactly as long as before, so the 89 size pins in `test/fixtures/skill-md-size-allowlist.json` are unchanged. +- **`bin/gstack-skill-start`**: the one-time prompt offers "Keep the core four (recommended)", "Suggest every matching skill" and "Turn it off", writing `core`, `all` or `off`. `scripts/question-registry.ts` lists the same three options. +- **`bin/gstack-skill-start`**: the `PROACTIVE:` STATUS fallback when `gstack-config` is missing is `core`, not `true` (found by /ship's coverage audit, red at the first head); a stored value gstack does not know reads as `core`, the legacy `true`/`false` and the yes/no family read as `all`/`off`, and a hand-edited value is unquoted, lowercased and stripped of a trailing comment first, so STATUS only ever says `core`, `all` or `off` and a typed opt-out never fails open (found by /ship's red team: `proactive: "off"` read as `core`); the first-run and first-loop tips name only core skills and stay quiet under `off`; the one-time offer to add `## Skill routing` to the project's CLAUDE.md is not made under `off` (found by /ship's red team: the routing rules are the auto-invocation the user turned off); a user who stored `off` before the one-time proactive prompt is not asked (the prompt marker is written before STATUS reports `PROACTIVE_PROMPTED`). +- **`office-hours/sections/design-and-handoff.md.tmpl`**: the next-review handoff takes the recommend-only path under `off` as well as the legacy `false` (found by /ship's review); under `core` it still offers the review, because a step inside an invoked skill is part of that skill, which the router now says in so many words. + +### Added +- **`docs/proactive.md`**: the three values, who each is for, the legacy spellings, the commands, and the approved fallback (`off` if collisions continue). Linked from docs/skills.md. + +### Tests +- `test/proactive-config.test.ts`: the default in get, list and defaults; the five accepted values stored verbatim, an upper-case `OFF` stored as `off`, the yes/no family stored as `all`/`off`; the warning and `core` fallback on an unknown value (negative control: `maybe`). +- `test/proactive-core-set.test.ts`: every surface (config header and default, router template and render, preamble, one-time prompt and registry, docs, office-hours gate, tips) names the same four core skills and the same three values, with negative controls (no non-core skill in the core block, `true` no longer "the default", no `set proactive false` left). +- `test/proactive-skill-start.test.ts` runs the script: STATUS is `core` when unset or unknown, the stored value otherwise, `all`/`off` for the legacy spellings and for a quoted, upper-case or commented hand edit; the missing-`gstack-config` fallback equals the config default (regression test: failed at the first head); the CLAUDE.md routing offer and the first-run and first-loop tips fire under `core` and not under `off` (marker unwritten). +- Known gap, follow-up #3446: under `core`, the `## Skill routing` section gstack offers for a project's CLAUDE.md, and the "Proactively suggest" sentences in non-core skill descriptions (full catalog mode), still route every skill; docs/proactive.md says so. + +### Gate integrity +- No threshold, budget, timeout, trial count or case kind changed. Size pins unchanged (the preamble is byte-for-byte the same length). + +## [1.91.239.0] - 2026-10-26 + +**BROWSER.md now says that gstack's own browser still trusts the compromised eMudhra intermediates, and how to check (#3338).** Chrome blocked eight eMudhra intermediate CAs by CRLSet on 2026-10-17, the day their key compromise was reported, and scheduled the removal of their root, emSign ECC Root CA - G3, from the Chrome Root Store for 2026-10-27. gstack's default headless browser gets neither: it launches with Playwright's `--disable-component-update`, so it receives no CRLSet updates, and the root store built into the bundled Chromium still trusts the root. On 2026-10-24, `$B goto https://dv-g3b-emudhra.we-have-a-problem.com/` loaded the test page on gstack's pinned Playwright 1.62.1 (Chromium 151.0.7922.34), and Chrome for Testing builds from 158.0.8115.0 fail it with `ERR_CERT_AUTHORITY_INVALID`, so the Playwright bump waits for a release that bundles one of those builds. + +### Changed +- **`BROWSER.md`**: a new "TLS trust: the bundled Chromium's root store" section, at the end of the security stack, gives the warning, the plan (move to the first Playwright release whose Chromium is 158.0.8115.0 or later; until then, no signed-in work in this engine on an untrusted network) and the check: on a network that does not inspect TLS, after `$B stop` and once a known-good HTTPS site loads in headless mode, only `ERR_CERT_AUTHORITY_INVALID` on the test host means a build is fixed. Environment-dependent caveats (proxy variables, profiles, custom builds, headed CRLSet timing) are tracked in #3440. The table of contents, the fallback-engine introduction and the `cookie-import-browser` and `handoff` command rows link to the section. Aside is unaffected, because it is your own browser with its own updates. + +### For contributors +- The edits above the cookie workflow excerpt change no line count, so the `setup-browser-cookies/SKILL.md workflow` judge input is byte-identical (prompt SHA-256 `c851e635…`, 12,805 bytes, before and after). + +## [1.91.238.0] - 2026-10-26 + +**Five /ship frictions the unattended lanes hit (GSTA-102).** Each fix is one commit. + +### Fixed +- **`gstack-gate-diff ` in a clone with no local `` branch.** Before, `gstack-gate-diff main` in a checkout that cloned only its feature branch printed `GATE_ERROR=no_base ref=main fix=git fetch origin main`, and that fix did not help: the tool only tried the local branch, and in a single-branch clone a bare `git fetch origin main` updates FETCH_HEAD alone. Now `lib/gate-diff/git.ts` resolves the base to its remote-tracking branch (`origin/main`, origin first, then the other remotes) when no local branch exists, says so once on stderr, and the merge-base is the same commit. When the remote-tracking branch is missing too, the fix line fetches into it explicitly: `git fetch origin +main:refs/remotes/origin/main` (with `--unshallow` on a shallow clone), which works in every clone shape. `test/gate-diff-cli.test.ts` adds a single-branch clone fixture with no local `main` and no `origin/main`: it asserts the error, runs the printed fix verbatim, and expects the next run to list the fixture's RH-1 hunk; two more clones cover a second remote that carries the branch when origin does not, and an upstream-only clone of a master-default repository whose default base (no argument) now comes from the same remote list as the fix line. The refspec is forced (`+main:refs/remotes/origin/main`) so the line also repairs a tracking ref that exists but shares no history with HEAD. +- **`gstack-post` no longer flags the repository's own release versions as public IPs.** A four-part version is byte-for-byte a dotted quad, so "(1.91.191.0)" in a PR body raised `pii.ip_public` (MEDIUM) and stopped an unattended docs audit that cannot confirm a MEDIUM finding. The engine's version exemption only reads the declaration right before the span (`"version": "`, `## [`), so prose that merely names a release got no help. Now `lib/gstack-post.ts` reads the root `VERSION` value and every version in a root `CHANGELOG.md` release heading (`## [1.2.3.4]`, `## v1.2.3.4`, through the same `parseChangelog` gstack-restamp uses) and passes those exact strings to the scan's allowlist. Verdict: this narrows one detector by the repo's own release set, and nothing else. The allowlist is exact-string and context-free, so a released quad is exempt wherever it appears in the text, `host: 1.91.191.0` included; the set is the repo's own releases and nothing wider. `8.8.8.8` and `203.0.113.7` in prose, a quad the repo never released (`1.128.1.0`), and the same text outside a repository or in a repo whose VERSION is three-part all still need confirmation; `test/gstack-post.test.ts` carries those controls beside the four benign bodies (`(1.91.191.0)`, `v1.91.226.0`, a bare `1.91.191.0`, `release 1.91.190.0`) against a synthetic CHANGELOG in which `1.91.192.0` is not a release. The same post path also stops reading a ship receipt's all-digit tree hash (`tree=704992822996`) and an 11-digit GitHub Actions run id in prose or link text (`run 19368712345`, `[run 19368712345](…/actions/runs/19368712345)`) as `pii.phone.e164`: only the exact tokens in those positions are allowlisted, and `+14155551234`, a bare 11-digit number and a 10-digit number after `run` still need confirmation (GSTA-94 hit this on #3424). +- **`gstack-evidence` records the test count of gstack's shard runner and of the remote pregate lane.** `bun run test:free` / `test:ubicloud` run quiet shards that forward bun's `Ran N tests` line without its ` N pass` counts, so the bun parser read every full-suite run as `tests_ran: 0` and /ship's Step 16 saw a zero-run suite (GSTA-94 on #3424); the remote lane prints `PREGATE_REMOTE: tree=… tests=9660 failed=0` (a lane row under `lanes:` in the pregate table) and `requires-remote: … cleared by remote-gate tree=… tests=9660`, which no parser knew, so it was `tests_ran: unknown` and the ship receipt and the Review Readiness Dashboard never carried the number. Now a bun terminal line with no counts block above it is not a bun summary (bun always prints ` 0 pass` / ` 0 fail`, even for an all-skip run, which still grades ZERO-RUN), the shard runner's per-shard `[test:free] PASS — N tests` epilogues are summed for the last run in the output (a files-from run's `FILES-FROM RESULT: … tests=N` is the same sum; a run with a `FAIL —` or `FLAKY-PASS —` epilogue and no such total has lost a shard's count and is recorded as unknown, not a partial sum), and when the shard runner spoke its verdict stands for the whole tail (the tail is the stdout tail then the stderr tail, so a line's position cannot order bun's stderr counts block against the runner's stdout epilogues; a focused `bun test` run before the suite in the same command no longer records its count, or ZERO-RUN, for the 10k-test run that followed), and the remote lane's two lines are parsed as whole lines like every other runner pattern (a prefixed or quoted echo does not count; the later line wins when both appear); a paid-only selection's `tests=0` is ZERO-RUN, not a pass. Honest limit: the shard runner and the remote gate print only bun's terminal totals, which include skipped tests, so for those two the number is tests ran with skips included, not executed (the shard runner refuses a shard whose bun summary counts fewer files than planned, but a shard whose files all loaded and executed nothing still passes with that total). The exit code is still never changed by the parse. `test/evidence.test.ts` runs the wrapper over `test/fixtures/evidence/pregate-remote-failed.out`, the stdout of a real `gstack-pregate --stage tests --remote` run captured under gstack-evidence on 2026-10-26 (9658 tests, 1 failed, recorded as `tests_ran=9658` with `exit=1`, never FRESH), over a cleared lane's table (`tests_ran=9660`, FRESH), over the receipt line alone, over the two lines with differing counts in both orders, and over `test/fixtures/evidence/shard-runner-files-from.out`, the remote gate's own shard-runner stream for this branch (275 files, 16 shards, 10138 tests: the epilogue sum equals the `FILES-FROM RESULT` total, with and without that final line, and a forwarded `Ran N tests` line on stderr no longer zeroes it). +- **`gstack-restamp` refuses only the CHANGELOG duplicates the PR introduces.** `CHANGELOG_DUPLICATE_HEADING` compared the branch's headings with themselves, so a base branch whose released history already heads one version twice (gbrain's `master` carries two `## [0.38.0.0]` sections, and its own check requires every one of them to stay) could never be stamped. The check now takes the base branch's CHANGELOG, read at `origin/` and at the commit the branch's own lines are counted from (its fork point, or the predecessor's head in a gate-ahead stamp), the larger count per heading winning: a heading the base carries N times may appear N times on the PR, and only a further copy is refused, with a message that says how many the base has. When a reading is unavailable (the PR adds the CHANGELOG itself) the stage detail and the refusal name it. A duplicate the PR adds, on a clean base or beside an inherited one, is still refused with nothing written. Tests: a fixture whose `main` heads `1.2.2.0` twice stamps and keeps both copies, and still does after `main` collapsed the copies behind the PR's back; a PR that adds a third copy, one that adds the first duplicate on a clean base, and one whose new CHANGELOG on a base without one heads a version twice, are refused. `docs/troubleshooting.md` says which duplicates count. +- **`gstack-restamp --next` under a train no longer asks the lane to edit the CHANGELOG.** Its `next:` line told every stamp to "write the CHANGELOG entry text"; a lane that obeyed on #3422 added a release summary under the new heading, the train's verify refused the head (the CHANGELOG must equal the base's plus the PR's own entry re-headed) and the queue froze. Under `queue_mode stamp-at-merge` the line now says to commit and push only the stamp diff with no edit of the lane's own; the claim-at-open wording is unchanged, since that bump still writes its entry at /ship's CHANGELOG step. Test: the stamp-at-merge fixture's output carries no instruction to edit the CHANGELOG by hand, and the claim-at-open fixture still does. + +## [1.91.237.0] - 2026-10-26 + +**An unstamped PR can change a release output again (GSTA-96 on #3434).** Regenerating `agents-digest/gstack-AGENTS.md` after a generator change made the Version Gate classify the head as stamped (the digest is in `release_outputs`) and demand base + bump, while changing only `scripts/gen-agents-digest.ts` failed `test/agents-digest.test.ts`'s freshness check, so a digest rewording could not land before its slot. + +### Fixed +- **Version Gate under a train policy** (`lib/version-gate-train.ts`, `scripts/compare-pr-version.ts`): a changed `release_outputs` file no longer counts as a stamp when it is byte for byte what the policy's `release_tool` generates at the head's merge base's `VERSION` (the head's own, since `VERSION` is unchanged on this path; the base tip may have stamped since, and CREW rule 14 says not to merge it); the gate runs the head's tool in a throwaway worktree with that `VERSION` (the changed outputs removed first, a bare environment with `GSTACK_RESTAMP_VERSION` and `GSTACK_RESTAMP_STAGING=1` and none of the gate's tokens, no `node_modules` link, a 120 s limit enforced with SIGKILL so a tool that ignores TERM cannot hold the gate open, output read from a file, first 4 KB, so a lingering child cannot either) and compares byte for byte. `VERSION` and `package.json` version changes still classify the head as stamped, as does an output that differs (hand-edited, generated at a bumped version, or never written by the tool) or a policy with no `release_tool`; a tool that exits non-zero, is killed or cannot be staged is an error with the reason, never a pass; the staging worktree is removed and pruned on every path. `version-gate.yml` checks the head out with `persist-credentials: false`, since the head's tool now runs inside the gate. The freshness test stays strict and a stamp commit still changes only the stamp files, since the slot's restamp regenerates the output. `docs/ship-policy.md` states the rule under "Lane: report ready". + +### Tests +- `test/compare-pr-version.test.ts`: a generator change with its regenerated output at the merge base VERSION passes as unstamped (red without the fix), and stays unstamped after main stamps again; a tool whose background child outlives it does not hold the gate open, nor does one that traps TERM (`regeneratedAtBase` is exported for that test); a hand-edited output, an output generated at the bumped version, an output the tool never writes, a VERSION bump without the output, a failing release tool and a policy without `release_tool` all still refuse; the tool sees no `GH_TOKEN`; no worktree is left behind. + +## [1.91.236.0] - 2026-10-26 + +**The merge queue now refuses a PR that never ran /ship.** Garry, 2026-10-25: "we should really be using the gstack ship skill before merging PRs." `gstack-ship-queue ready` printed `SHIP_RECEIPT: none in the PR body` as information and returned ok, so every PR in the pool was READY'd without a /ship run. The receipt /ship writes into the PR body is now a precondition at `ready`, `train build` and `verify --train`, with no flag around it. + +### Changed +- **`gstack-ship-queue ready`** refuses with `QUEUE_RECEIPT_MISSING` when the PR body has no valid ```gstack-ship-receipt``` block and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR). The re-READY after a slot's stamp push accepts the receipt written for the pin when HEAD is the pin plus stamp files, or (from slot 2 on) the pin merged with main plus stamp files. The informational `SHIP_RECEIPT:` line still prints in the block, and it is one line whatever the receipt's strings contain (control characters are collapsed, so a crafted `base` cannot add a forged `MERGE_CONDITION:` line to `verify --train`'s output). +- **`gstack-ship-queue ready --screen` refuses a stale merge ref** with the new `QUEUE_SCREEN_STALE_REF` (GSTA-95 on #3431): GitHub stops recomputing `refs/pull//merge` while a PR conflicts with the base (a CHANGELOG-only conflict after a stamp counts), so a screen dispatched there planned 0 cases, reported success and `ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares the merge ref's head parent with the PR head before dispatching (a missing merge ref carries the same code), never adopts a finished screen that planned no cases (a superseded run skips plan-slices and uploads no `paid-plan` artifact; a manifest with nothing planned counts the same), and `train build` prints `SCREEN: # run planned no cases …; counted as not-run` and seats that slot unscreened (a plan it cannot read keeps the slot screened and prints `… counted as screened, unverified`). +- **`gstack-ship-queue train build`** refuses a pin whose PR has no current receipt with the same codes, naming the PR, before any write, push or dispatch, `--dry-run` included. **`verify --train`** refuses the slot the same way before `MERGE_CONDITION`, and accepts a receipt written for the slot's pinned head when the current head is its stamp commit (the stamp changes only stamp files, or merges main into the pin from slot 2 on), since no /ship runs on a stamp. +- **`gstack-ship-receipt result`** (new) is /ship's terminal step: it binds the posted receipt to HEAD, writes `/ship/runs/-/{ship-run.json,ship-receipt.md}` (a /ship record, not a `run` v1 manifest: `gstack-artifact validate` does not apply) and prints `GSTACK_RESULT: skill=ship status=complete run=`, `status=refused run=-` with the receipt code, or `status=incomplete run=-` when gh cannot read the PR, so a parent's merge check reads `ship-run.json` instead of parsing the PR body. The ship skill's new Step 22 runs it after the pr-body and handoff sections. +- **Unattended /ship may update an existing PR's body.** `gstack-skill-start`'s unattended block and `UNATTENDED_SHIP:` line, the ship skill's Step 18 and `docs/unattended.md` now say: when an open PR for the branch exists and HEAD is its pushed head, Step 18 takes the pr-body section's existing-PR path (body with the receipt first, and title); it never runs `pr-create` and never pushes a commit; anything else takes the handoff section. Before this the block said the receipt lived only in `.gstack/tmp/ship-receipt.md` while rule 22 and `ready` read it from the PR body. +- **`INSTALL_UNVERIFIED` with no verdict names the directory to install from.** An installer run from the skill's own folder records a verdict for no project, so /ship launched from the project found none and the fix said only "run the installer on this machine" (GSTA-85). Both twins (`lib/install-verdict.ts`, `bin/gstack-install-verdict.sh`) now say `run … scripts/install.sh --for ship from : the verdict is keyed by the project directory the installer runs in`. +- **Docs:** `docs/ship-policy.md#ship-before-ready` (the lane runs /ship before every READY and after every push; the stamp keeps the pin's receipt; the receipt is a process gate, not an authenticity check), the three codes in `docs/troubleshooting.md`, `docs/unattended.md` (/ship now prints the result line), and the ship skill's pr-body section says what refuses without a receipt. +- **Gate edit (size budgets):** `test/helpers/carve-guards.ts` raises ship's `maxSkeletonBytes` 81,000 → 81,600 and `maxSizeRatio` 1.6690 → 1.6740 for Step 22 and the Step 18 routing, offset in part by tightening Steps 14, 16, 18 and 20 prose (measured skeleton 81,535, union 314,027; the 2026-10-14 tightening had left 2 bytes of headroom). No threshold, trial count or case kind changed. + +### Tests +- `test/ship-queue-train.test.ts`: READY, train build (dry run and live) and verify --train refuse a missing, invalid, other-head and other-PR receipt and admit the current one; the pinned-head receipt admits a stamp commit; verify refuses `QUEUE_PROOF_MISSING` when gh cannot read the PR; `--screen` refuses a lagging or missing merge ref and never follows a 0-case screen. The train fixture's `refs/pull//merge` is now a real merge commit (`mergeRefCommit`), as GitHub keeps it. `test/gstack-ship-queue.test.ts`: `ready` at a stamp commit (slot 1, and slot 2 with main merged in) accepts the pin's receipt; a bare merge of main with no stamp on top, an empty commit over the merge, or an edit on the pin side under the slot-2 stamp, is `QUEUE_RECEIPT_STALE`. `gstack-ship-receipt result --pr ` refuses `QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head and prints `status=incomplete` when gh returns no head or the run record cannot be written; `ready --screen` names a failed `ls-remote` or fetch of the merge ref instead of blaming a conflict. Both install-verdict twins tell an unreadable verdict file from a missing one. `test/auq-error-fallback-hook.test.ts`: the unattended directive names the same `GSTACK_RESULT` writers as docs/unattended.md and `gstack-skill-start` (GSTA-91). `test/gstack-ship-receipt.test.ts`: the `bindReceipt` head-matching table (12-character floor, other PR, one-line `SHIP_RECEIPT:`), `result --body` refusals, `--head`, a non-git cwd and gh failures. The train fixture's `openPr` now writes a receipt for the head it opens (`shipped: false` leaves the body empty). `test/install-verdict.test.ts` pins the GSTA-85 fix in both twins; `test/gstack-skill-start.test.ts` pins the new unattended /ship block. + +## [1.91.235.0] - 2026-10-26 + +**An issue we intend to fix later now stays open under the `parked` label instead of being closed.** The Oct 11 triage closed 75 feature requests with a "not rejected" note, and GitHub showed them as closed with no fix. Those 75 are open again under the new `parked` label on garrytan/gstack ("Not fixed yet, not rejected: stays open; a comment says where it is tracked and what fixes it"), each with one comment saying where the work is tracked and what fixes it. The rule is written down: an issue closes only as fixed, duplicate or won't fix, each with its reason; a report that main already fixed closes as fixed, not won't fix; and a duplicate uses GitHub's own duplicate close reason. + +### Added +- **`docs/triage.md`**: the three issue states (open, parked, closed), how to park an issue (the label plus one comment naming where it is tracked and what fixes it), when the label comes off, the three close reasons with their GitHub close reason and label, and where the parked themes live. + +### For contributors +- **`CONTRIBUTING.md`**: an "Issue triage" section ahead of the PR wave process, pointing at docs/triage.md. +- **`TODOS.md`**: the Wave 9 feature-themes note says issues stay open under `parked` and points at their theme there (73 listed, 75 with #3061 and #3122), instead of closing with a link. + +## [1.91.234.0] - 2026-10-26 + +**Weekly tagged releases, and a `stable` channel that team-mode auto-update follows by default.** Main kept stamping a version per merge, a dozen a day, with no tag or release anywhere, and `./setup --team` installs moved to every one of them within the hour. Now a Monday-morning workflow tags the newest green commit on main as `v`, publishes a GitHub Release from the CHANGELOG headlines and fast-forwards a `stable` branch to it; team installs follow `stable` and move once a week (plus hotfixes), contributors keep following main, and one config key switches either way. Nothing about per-merge stamping or the merge train changed (docs/releases.md). + +### Added +- **`.github/workflows/weekly-release.yml`**, **`scripts/release-cut.ts`**: every Monday at 14:00 UTC, or by `workflow_dispatch` (`dry_run`, default true for a manual run; `ref` for a hotfix), the newest first-parent commit on main whose `free-tests` and `main-backstop` check runs succeeded, with every check run finished and none red and no active freeze in `refs/notes/gstack-trains`, is tagged `v`, released with one headline per CHANGELOG version since the previous release (capped at 60 KB), and becomes `refs/heads/stable`. Stable only moves forward along main; a candidate behind it, a tag naming another commit, or a frozen queue is `REFUSED`. A cut that stopped half-way (tag and `stable` pushed, release not created) is finished by the next run. A dry run prints the plan and the exact commands and changes nothing (nothing green is exit 0 for a dry run, with every skip named); a pull request that touches the workflow or the script runs that dry run against main as a read-only job. The cut job runs only from `refs/heads/main`, takes `ref` as a full 40-hex SHA, and has `contents: write` and `checks: read` only; both rulesets cover `refs/heads/main` alone, so the token can push the tag and `stable`. +- **`.github/workflows/evals-main-backstop.yml`**, **`scripts/train-backstop.ts`**: the backstop's verdict is published on the commit as the `main-backstop` check run from a code-free `checks: write` job (`--outputs` records the action): success for `covered` and `gated`, neutral for `superseded`, `deferred` and `inert`, failure for `frozen` and `failed`. The run's own conclusion is exit 0 for a superseded push (main moved past it before it was gated), so reading the run would have called a never-gated tree green; the release reads the check run instead. +- **`bin/gstack-update-channel.sh`**: resolves the channel (`gstack_update_channel`: `update_channel` `auto` is `stable` when `team_mode` is true, else `main`; `GSTACK_UPDATE_CHANNEL` overrides), and as a command (`--fetch-ref`) fetches the channel ref and prints `origin/stable` or `origin/main` for the /gstack-upgrade skill. +- **`bin/gstack-config`**: new key `update_channel` (`auto` | `stable` | `main`), validated on `set`, in the header, `list` and `defaults`. +- **`docs/releases.md`**: what a release is, what green means, the channels and how to switch, hotfixes, dry runs and every outcome line. + +### Changed +- **`bin/gstack-session-update`**: on the stable channel it fetches `+refs/heads/stable:refs/remotes/origin/stable` (team installs are single-branch clones) and fast-forwards to it, logging `CHANNEL stable target=`. Before the first release exists it follows main (`CHANNEL_FALLBACK stable->main`). An install already past the release stays put (`CHANNEL stable ahead_of_release`, then `UP_TO_DATE`): a depth-1 clone cannot relate the two commits, so the VERSIONs decide. The main channel's fetch, target and log lines are unchanged. +- **`bin/gstack-update-check`**: reads `/VERSION`, resolves the SHA with `ls-remote refs/heads/` and compares against it; the cache identity carries `#stable` so a channel switch never replays the other channel's outcome. With no `stable` on origin yet it follows main for that check, as the session updater and `gstack-upgrade` do, instead of caching `CHECK_FAILED` from a 404 until the first cut. +- **`bin/gstack-upgrade`**: fetches and fast-forwards to `stable` on that channel (`gstack-upgrade: channel stable ...` on stderr), follows the default branch with a note when origin has no `stable` yet (an `ls-remote` that fails is a fetch failure, not an empty answer), and treats a release whose VERSION is not higher than the installed one as current instead of diverged in a shallow clone. `--check` output lines are unchanged. +- **`gstack-upgrade/SKILL.md.tmpl`**: Step 4 resolves `UPSTREAM_REF` through the helper and uses it for the Bun-floor check, the fast-forward, the unpushed-commit gate and the reset fallback instead of a literal `origin/main`; the fallback block requires the carried `UPSTREAM_REF` (`:?`) instead of defaulting to `origin/main` in a fresh shell, and a team install already past the release stops with `AHEAD_OF_STABLE` instead of `FF_REFUSED` and an offer to reset backwards. The helper's `--fetch-ref` keeps `origin/stable` when the fetch fails for any reason other than a missing release, so a network blink never moves a team install to main; its unused `gstack_update_channel_ref` is gone. +- **README** Step 2 (team mode) says team mode follows `stable` and how to follow every merge instead. + +### Tests +- `test/release-cut.test.ts`: on a real bare origin with a fake `gh`, the newest green first-parent commit wins and every skip is named (pending or neutral `main-backstop`, red check run, missing or running `free-tests`, no `main-backstop` check run, an unrelated check run still going); an active freeze refuses and a cleared one does not; a cut creates the annotated tag, `stable` and the release with `--verify-tag` and the headline in the notes; re-running is `NOTHING_NEW`; a cut whose `gh release create` failed is finished by the next run with notes against the release before it; the CLI exits 0 on a dry run with nothing green and 1 on the real cut; a hotfix `--ref` promotes a later main commit with a compare link; stable never moves backwards; a side-branch ref and a clashing tag are refused; a thousand-version span stays under the notes cap. +- `test/update-channel.test.ts`: the resolution matrix (team mode × key × env override); the session-start hook on a depth-1 team install moves to stable (B) not main (C), follows main on `update_channel=main`, falls back to main with no stable on origin, and stays put when already past the release with nothing pending; `gstack-upgrade --check` and the upgrade follow stable then main after the key flips, say when no release exists, and call a depth-1 install past the release current; `gstack-update-check` on stable stays silent while main is ahead, asks `ls-remote` for `refs/heads/stable`, carries the channel in its cache identity, and follows main when origin has no stable yet; `--fetch-ref` keeps `origin/stable` when origin is unreachable. +- `test/weekly-release-workflow.test.ts`: the workflow's triggers, concurrency, the cut job's main-only guard, permissions and 40-hex `ref` check, and the read-only pull-request dry run. `test/evals-train-workflow.test.ts`, `test/train-backstop.test.ts`: the `backstop-check` job's action-to-conclusion map, its code-free `checks: write` split, and the CLI's `--outputs` line. +- `test/upgrade-template-pins.test.ts`, `test/gstack-upgrade-fresh-shell.test.ts`: the pinned fast-forward, unpushed-commit gate and reset markers name `"$UPSTREAM_REF"` instead of `origin/main`; the ordering invariants are unchanged. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion changed. The release workflow reads check runs and the trains notes ref; it dispatches nothing and runs no tests or evals. The backstop gates exactly as before; it additionally publishes its verdict as a check run. + +## [1.91.233.0] - 2026-10-26 + +**/plan-tune's inferred profile no longer pins at 1.0 after a few reviews, and it now records how many answers moved each dimension.** This closes the last two parts of #2623. One /plan-eng-review produces about five answers, and every answer added its full delta, so eight sessions of "fix now" took `architecture_care` to 0.998, as uninformative as the 0.5 it replaced. Now each UTC day moves a dimension at most as far as one strong answer, and eight days of the same answers read 0.808. The profile JSON (`--profile`) also records `contributing_events` per dimension, so a dimension with no evidence reads 0 there instead of a bare 0.5; /plan-tune's display and `--check-mismatch` don't read it yet (#3436). + +### Fixed +- **`bin/gstack-developer-profile`**: `--derive` sums each UTC day's deltas per dimension (by the row's `ts`) and clamps the sum to the largest single delta in the signal map (±0.06 today). Answers within a day net out before the clamp. Only an ISO 8601 date or date-time `ts` counts as dated, and a time without a zone is read as UTC; every other row shares one undated bucket, so a junk timestamp can't escape the bound. `diversity.days_span` counts the same UTC days. The bound ignores `session_id` on purpose: the PostToolUse hook and the agent log different ids for one review, and an unsubstituted `SESSION_ID` placeholder would merge every review into one bound or leave each answer unbounded. Several reviews on one day count as one. `--trace` uses the same accumulation, so it lists every contributing answer, each clamped day ("day 2026-04-01: +0.250 capped to +0.060") and a `TOTAL` line whose value matches `--derive`. +- **`bin/gstack-developer-profile`**: a logged choice named after an `Object.prototype` member, such as `constructor`, or a log line that parses to a non-object such as `null`, no longer makes every later `--derive` and `--trace` fail; signal lookups use own properties only. `--trace` replaces control characters in the logged fields it prints, so a logged `ts` can't forge a `TOTAL` line. + +### Added +- **`inferred.contributing_events`**: per dimension, the number of logged answers that moved it (an answer that writes two deltas to one dimension counts once). Ported from #2625 by @v639dragoon, with co-author credit. #2625's autonomy signal from `followed_recommendation` stays parked, as decided in its Oct 8 review. + +### Docs +- `docs/designs/PLAN_TUNING_V0.md` documents `signal_events`, `contributing_events` and the per-day bound in the profile schema, including why the bound ignores `session_id`. +- `TODOS.md`: the #2623 entry now covers only its remaining Gate 1 work, the `{skill}-{slug}` hinted fallback, tracked as #3442. + +### Tests +- `test/gstack-developer-profile.test.ts`: `contributing_events` counts unique rows when one answer writes two dimensions, and is all zero for an answer that matches no signal. The bound equals the signal map's largest delta. Five answers on one day move a dimension by exactly the bound in both directions, whether they carry one `session_id`, five different ones, the literal `SESSION_ID` placeholder, or one review logged by both the hook and the agent. The day is the UTC day of `ts` even when the machine's zone is west of UTC, and a `ts` without a zone, `T`- or space-separated, is read as UTC. `--trace` prints the clamped days and a total that matches `--derive`, and replaces control characters. A `constructor` choice, a `null` line and array-valued `question_id` or `user_choice` rows are ignored. Rows whose `ts` is not ISO 8601 (`TS`, `junk 1`, a number) share one undated bucket and add no days to `days_span`. Negative controls: the same answers on separate days, a second day after a capped first day, and an answer pair that nets out are not clamped. The original positive tests fail against the old bin; the day-bound, `constructor` and control-character tests fail against the per-session version; a one-sided clamp fails the bound tests, and local-day bucketing fails the UTC tests. +- The test helper `logQuestion` sets `GSTACK_QUESTION_LOG_NO_DERIVE=1`, so the background derive can't race a test's own `--derive` (from #2625). + +### Gate integrity +- No test expectation, threshold, budget, timeout, trial count or case kind changed. + +## [1.91.232.0] - 2026-10-26 + +**The setup-browser-cookies workflow judge now has a preregistered calibration corpus, and its structured-output calibration is recorded as held, so the judge stays on today's request (GSTA-91).** In train-4 gate 40173759210, the cookie judge (claude-sonnet-5-5, free-text transport) returned JSON with an unescaped quote. The repo's fix for that shape is the JSON-schema transport, and `test/fixtures/judge-calibration/README.md` lets a configuration switch only after a calibration lands. The cookie configuration had no corpus until now. Its comparison-1 run removed the parse errors (old 2 of 144 samples, new 0 of 72) and matched or improved every other measure. It is held on one landing condition: a new false pass on a07. a07 scored 4/4/4 on all three new samples and on all three `old-b` samples. `old-a`'s a07 panel is incomplete because one old sample returned invalid JSON (both valid `old-a` a07 samples also scored 4/4/4), and false passes are counted against `old-a` alone, so the result counts against the new request only. + +### Tests +- `test/fixtures/judge-calibration/cookie-workflow/`: 24 items. p01 is the production bundle (`buildCookieWorkflowJudgeInput`); a02 and a05 are real earlier revisions; the rest are hand-edited variants rendered by the same builder. Labels were preregistered and committed before any request with the schema was sent. A separate run (openai/gpt-6.1-sol) then labeled every item blind, and a third reviewer (google/gemini-4-argon) adjudicated the two disagreements: a01 kept pass, a08 changed to fail. `runs/` holds every sample and the result. The cost was 216 calls and $2.81, priced in `manifest.json` before dispatch as a separately funded entry. +- `scripts/judge-calibration-configs.ts`: the `cookie-workflow` configuration builds prompts with `buildWorkflowJudgePrompt(COOKIE_WORKFLOW_JUDGE, …)` and uses the eval's median floors of 4/3/4. Its new request differs from the old one only by `WORKFLOW_JUDGE_RESPONSE_SCHEMA`. +- `test/judge-calibration.test.ts` covers the new configuration and its pass rule, including the actionability floor. The wiring test now requires the cookie registration to send the schema exactly when the recorded cookie calibration lands. It doesn't land, so the registration stays unchanged and 15 workflow registrations use the schema. The cookie registration may only spread `COOKIE_WORKFLOW_JUDGE`, so the calibrated request stays the one production sends. +- Two new free tests: the calibration builds byte-for-byte the prompt the cookie eval sends, and `analyze()` reproduces the committed result from its labels, samples, stop record and request options. + +### Gate integrity +- No judge request, rubric, threshold, sample count, landing rule or case changed. The cookie judge keeps its free-text request. No gate exception is proposed. + +## [1.91.231.0] - 2026-10-26 + +**A fresh install is about 520 MB smaller: `./setup` installs production dependencies only.** Setup ran `bun install` without `--production`, so every user also got the test and eval toolchain from `devDependencies`, mostly the Claude Agent SDK's native binaries (both the glibc and musl builds on Linux, ~440 MB). Measured on a clean Ubuntu 24.04 container with a cold Bun cache (4 vCPU, two runs each): `node_modules` goes from 1,094 MB to 572 MB, the checkout from 1.74 GB to 1.22 GB, and the whole install with Chromium from 2.42 GB to 1.90 GB. `./setup` took 24.6 s and 20.0 s before, 21.1 s and 18.9 s after. + +### Changed +- **`setup`**: every dependency install (before the build, the Codex render and each host render) goes through one `install_runtime_deps` function: `bun install --production --frozen-lockfile`. Bun treats `--production` as frozen, so when `bun.lock` is behind `package.json` (a fork or a local edit) it refreshes the lockfile with `bun install --lockfile-only` and installs production-only again. **`bin/gstack-browser-ensure`** installs the same way when `node_modules` is missing, and its failure line suggests the production install rather than a full `bun install`. +- **`package.json`**: `@anthropic-ai/sdk` (loaded by `gstack-distill-free-text`, /plan-tune's dream cycle) and `xterm` and `xterm-addon-fit` (copied into the browser extension by `bun run build`) move to `dependencies`, because a user install runs them. The Claude Agent SDK, TypeScript, Prettier and `@types/bun` stay dev-only. +- **Contributors and CI keep the full install.** `./setup` treats a checkout that already has the dev toolchain (`node_modules/typescript`) as a contributor's and runs the full `bun install --frozen-lockfile` there, so dev packages keep following `bun.lock` after a pull (a production install would report "no changes" and leave them at old versions). `bin/dev-setup` now always runs `bun install` (it skipped it when `node_modules` existed, which would have left a checkout first set up by `./setup` without the dev toolchain). CI workflows already run `bun install --frozen-lockfile` themselves. CONTRIBUTING.md says which command installs what. + +### If you already have gstack installed +- An existing install keeps its dev dependencies and keeps getting the full install, because it already has the dev toolchain. To get the space back: `cd ~/.claude/skills/gstack && rm -rf node_modules && bun install --production --frozen-lockfile && rm -rf "$(bun pm cache)"/@anthropic-ai/claude-agent-sdk*`. The last step matters: Bun hardlinks `node_modules` into its global cache, so the Agent SDK's binaries stay on disk until their cache entries go. Don't do this in a checkout you develop gstack in. + +### Tests +- `test/setup-production-deps.test.ts`: `install_runtime_deps`, sliced out of `setup` and run against a recording `bun` that models Bun's frozen `--production`, passes `--production` on the frozen-lockfile call, refreshes a stale lockfile before retrying, and stops setup when the refresh fails. A checkout with the dev toolchain gets the full frozen install, and a failed full install stops setup too. A walk of `bun.lock`'s production closure (with required-peer and optional-peer controls) proves no production dependency brings in `typescript`, the marker setup checks. The real-Bun rows run with `HOME`, `XDG_CONFIG_HOME` and `BUN_CONFIG_*` isolated, so a developer's global `.bunfig.toml` cannot change them. Run with the real Bun on local `file:` packages: a fresh install leaves out devDependencies, a later production install keeps the ones a full install added, and a stale lockfile is refreshed while the install stays production-only. No other line in `setup` calls `bun install` (the pre-change line is the negative control). `bin/dev-setup`'s install block, run over an existing `node_modules`, still calls plain `bun install`. A scan of every shipped file (`bin`, `lib`, `browse/src`, `design/src`, `make-pdf/src`, `hosts`, `extension`, the resolvers, and the scripts `setup` and the build call by path) finds no import or `require` of a package outside `dependencies`. Modules the shipped code reaches by relative import (gen-skill-docs' `scripts/` helpers, and the `test/helpers/providers` modules `gstack-model-benchmark` loads) are scanned too. Negative controls: a planted Agent SDK import is caught, including one split over several lines and a re-export; a type-only import is not; and moving `@anthropic-ai/sdk` back to dev flags `gstack-distill-free-text`. The packages `bun run build` copies out of `node_modules` are production dependencies. +- `test/gstack-browser-ensure.test.ts`: with `node_modules` missing, the preflight runs the frozen production install, refreshes a stale lockfile before retrying, and ends a failed install in `BROWSER_UNAVAILABLE` with a production-install fix line. +- The install fixtures' stub `bun` (`test/helpers/install-fixture.ts`, `test/helpers/claude-overlay-fixture.ts`, `test/team-mode.test.ts`) now matches `install --production --frozen-lockfile`. The team-mode stub fails on any other argv; the two helper stubs hand other commands to the real `bun`, so for them the new argv is a match, not a regression check. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion was weakened. The four stub patterns (in three files) changed to the new argv only. + +## [1.91.230.0] - 2026-10-26 + +### Changed +- **The archetype the user picks on the /design-shotgun board now reaches /design-html.** Captions name the archetype id (`classic-hero`, `long-form-story`, `minimal-conversion`, `comparison`). On approval, approved.json also gets the chosen `archetype` and its `sections` outline, added with the Edit tool so no free text reaches a shell command. /design-html reads both in Case A, and the craft section uses them as given instead of choosing again or asking. +- **Accepted craft exceptions persist.** /design-html's finalized.json records each accepted `CRAFT_CHECK` finding in `craft_exceptions` (`id`, `count`, `reason`) and carries forward earlier ones. A later run passes that file with `--accepted`, so a finding the user already accepted is reported as accepted instead of flagged again. A higher count is still flagged as a new issue. + +### Added +- **`gstack-design-craft --accepted `**: a finding whose id an exception covers, at no more than the accepted count, prints `CRAFT_ACCEPTED: ` and doesn't fail the check. `CRAFT_RESULT` adds the accepted count. Exit codes: 0 when only accepted findings remain, 1 for unaccepted ones, and 2 for an unknown argument or a missing, unreadable or malformed exceptions file. A bad exceptions file still gets the full check with nothing accepted, after a warning, so a broken record can't switch the craft check off. A finalized.json with no `craft_exceptions` field, written before this change, means no exceptions. + +### Tests +- `design-html-slop-gate` is now selected by changes to `lib/design-craft.ts` and `bin/gstack-design-craft` (`test/helpers/touchfiles-data.ts`), which it already exercised but did not list. +- `test/design-craft.test.ts` also covers accepted exceptions: count coverage and overflow, duplicate ids carried forward (the highest count wins), a bad exceptions file still checked in full, the finalized.json block in design-html/SKILL.md parsed by the same reader the CLI uses, rejected malformed lists, CLI exit codes with `--accepted`, and the archetype handoff contract (the same four ids in /design-shotgun and the craft section, the approved.json step, Case A and the craft section's no-re-ask rule). + +## [1.91.229.0] - 2026-10-26 + +**The OpenSSF Scorecard workflow uploads its results with `github/codeql-action/upload-sarif` 4.39.0 (from 4.38.3), recreated from Dependabot's #3307.** Dependabot's branch can't take a version stamp at merge time, so the same one-line change ships from a crew branch. The new pin `6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee` is the commit that the annotated tag `v4.39.0` points to in github/codeql-action, and GitHub shows that commit as verified. 4.39.0's one breaking change raises the minimum CodeQL bundle to 2.20.7, which applies to `init` and `analyze`. This workflow uses only `upload-sarif` with Scorecard's own SARIF file. + +### Changed +- **`.github/workflows/scorecard.yml`**: `upload-sarif` pinned to `6f2ac81f…` (`# v4.39.0`), byte-for-byte the change in #3307. + +## [1.91.228.0] - 2026-10-26 + +**Skills start at once on Windows again: the workflow lease lock no longer depends on symlinks (#3330).** `bin/gstack-lease` took its lock with `ln -s "" .lock`. In Git for Windows' default symlink mode (`MSYS` unset), `ln -s` cannot link to a target that does not exist, so the lock was never taken. Every skill start then waited out all 300 tries (0.1 s apart, plus process start costs) before `gstack-skill-start` printed anything, and still wrote no lease. One report measured 499 s per skill start. The error also blamed a "live process" with an empty pid. + +### Fixed +- **`bin/gstack-lease`**: the lock is now a `.lock` directory taken with `mkdir`, which is atomic on every host gstack runs on, with an `owner` file naming the holder's pid and start identity. A taker writes a claim (`.lock.claim.`) before its `mkdir` and renames it in as the owner after, so a lock left ownerless by a taker killed between the two steps is broken once no claimant lives and the lock is at least 2 s old and unchanged while it was checked (a live taker between its two steps is never mistaken for a crash, and a new lock made meanwhile is never removed). A claim that records no start identity (Git Bash has none) is abandoned after 2 minutes, so a reused pid cannot keep an orphan forever. An owner that names no valid pid (0, zero-padded or not a number) counts as a dead holder, an owner file that exists but cannot be read keeps the lock, and an empty owner, a stray file at `.lock`, or an ownerless lock whose mtime is a minute or more in the future (clock skew) counts as ownerless instead of wedging every skill start. A lock is removed by renaming it aside first, so nobody sees it half-removed; once renamed it is released even if the renamed copy cannot be deleted, and `touch` prunes leftover copies and claims over 2 minutes old whose claimant is gone or recorded no identity. A late taker never overwrites an owner already in the lock it made. A waiter no longer writes a claim or runs `mkdir` while the lock is visibly held, and a live holder that never lets go bounds the wait at 300 tries or 30 s, whichever comes first (a non-numeric `GSTACK_LEASE_LOCK_TRIES` falls back to 300 instead of looping forever). Stale-holder semantics are unchanged: a lock is broken only when its holder is dead or its pid now belongs to another process, breakers still serialize on `.lock.break` and reread before removing, and unlock removes only the caller's own lock. A symlink `.lock` left by an older gstack is still read and broken the same way, and it excludes the new lock at the same path. The reverse does not hold: an older gstack's `ln -s` onto the directory lock links inside it, so a process that started before an upgrade of the same checkout can briefly share the lock (documented as a known limit, with a taker stalled over 2 minutes mid-take on a host without start identities). +- A dead holder's lock that cannot be renamed aside (a Windows file in use) is removed in place under `.lock.break`, and when that fails too the try is counted, so `touch` ends with exit 3 instead of spinning without sleeping. The message no longer calls a dead holder live. +- The 120-minute prune in `touch` no longer reaches into the lock: it removed the `owner` file of a holder that resumed past the window, which then left its own lock behind. +- When the lock cannot be taken although nobody holds it, `touch` now fails after ten quick retries with `gstack-lease: cannot take the lease directory lock ` instead of waiting out every try and naming an empty pid. `gstack-skill-start` stays fail-open, as before. +- `docs/capy-install.md` describes the directory lock. + +### Tests +- `test/gstack-lease-git-bash.test.ts` (new, in the curated windows-free-tests lane, launched through `lib/script-invocation.ts`, so Git Bash on `windows-latest`): `touch` with the default 300 tries takes the lock on its first try (no sleep between tries), writes its lease and leaves no lock. On `windows-latest` the old bin fails this case; a host whose `ln -s` fails like Git Bash's still takes the lock (with a control that the shim really fails `ln -s`); a lock held by a live Git Bash process is kept and named, and a dead holder's lock is broken. The last two fail against the old bin. +- `test/gstack-lease.test.ts`: the dead-holder, live-holder and dead-breaker cases run against both the directory lock and the legacy symlink lock. The race cases inject at `mkdir` instead of `ln`. New cases: a lock held by a running gstack-lease excludes a second taker, which names it; 16 parallel touches all land and leave nothing behind; an ownerless lock is kept while a claimant lives and broken once none does; a lock that cannot be taken while nobody holds it fails without sleeping between tries. Seven of these fail against the old bin. +- From /ship's coverage audit and review: the 16 parallel touches now also prove no two are inside the lock at once (a mutant whose `mkdir` always succeeds fails it); the exclusion case holds the lock until the test releases it instead of for a 3 s sleep; a young ownerless lock is kept and malformed locks (empty owner, pid 0 or 00, a non-numeric owner, a stray file, an mtime an hour ahead) are broken; an identity-less claim older than 2 minutes neither keeps an ownerless lock nor survives `touch` while an old claim with a live identity is honored; an unreadable owner file keeps the lock; `--help` prints the whole usage again; leftover renamed-aside copies are pruned; a live holder bounds `touch` by the 30 s deadline with the default and with a non-numeric tries value, without one `mkdir` on the held lock; the prune keeps a resumed holder's lock; a renamed-aside lock that cannot be deleted never takes a new taker's lock with it; a dead holder's lock that cannot be renamed aside ends `touch` instead of spinning. Each case that pins a fix fails against the bin before that fix. Also covered: a live or ownerless `.lock.break`, a failed owner rename, a lock released between a failed take and its check, and unlock's fallback. The Git Bash case now also checks that the live holder still owns the lock after a blocked `touch`. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion was weakened. Three existing race tests moved their fault injection from `ln` to `mkdir`, because the lock no longer calls `ln`. Their assertions are unchanged. Under /ship review, the exclusion case's wait for the holder's owner file rose from 2 s to 10 s and its fixed 3 s hold became a release file (no assertion changed), the ownerless-lock cases backdate their planted lock by a minute because an ownerless lock is now broken only once 2 s old (their assertions are unchanged; the new young-lock case pins the guard), and the exclusion and parallel cases got an explicit 30 s test timeout instead of Bun's 5 s default. + +## [1.91.227.0] - 2026-10-26 + +**Windows: gstack's record bins stop losing review, question, learning and timeline records when Git Bash path conversion is off (#3415).** Since GSTA-56 every shell bin started Bun with `--config=/dev/null`. Native `bun.exe` cannot open `/dev/null`: it exits 1 with `EINVAL` on Bun 1.3.10, 1.4.2 and 1.4.4 (measured on windows-latest). Git Bash hides this by rewriting the argument to `nul`, but only while MSYS path conversion is on. With it off (`MSYS_NO_PATHCONV` set to any value, even empty; `MSYS2_ARG_CONV_EXCL=*`; or Cygwin), `gstack-review-log`, `gstack-question-log` and `gstack-learnings-log` exited 1 with nothing written, and `gstack-timeline-log` exited 0 with nothing written. + +### Fixed +- **40 shipped shell files** (the bash bins, the Claude hook wrappers, `setup`, `scripts/build-cso.sh` and one migration) define `GSTACK_BUN_CONFIG` once and start Bun with `--config="${GSTACK_BUN_CONFIG:-/dev/null}"`. The value is `NUL` only when `OSTYPE` is `msys` or `cygwin` (Git Bash, MSYS2 and Cygwin, the check `gstack-doctor` already uses) and `cygpath` is on `PATH`; everywhere else it is `/dev/null`. The `cygpath` check is a builtin `command -v`, so it forks nothing. It means an `OSTYPE=msys` inherited by a Linux or macOS bash keeps `/dev/null`, instead of passing a relative `NUL` that Bun there resolves to a file in the repository. Bun 1.4.2 and later, the version gstack tests against, read `NUL` as an empty config whether or not conversion is on. Bun 1.3.10 on Windows exits 1 on `--config=NUL` and on the lowercase `nul` that Git Bash used to produce alike (measured from PowerShell on windows-latest; the #3085 entry recorded a panic). Versions between 1.3.3 and 1.4.1, which setup and `gstack-doctor` only warn about, were not measured. The `:-/dev/null` default covers a call that loses its definition: it still passes `/dev/null`, so POSIX is unchanged and Windows fails loudly as before, and never reads the repository's `bunfig.toml`. `bin/gstack-codex-probe` is sourced into the caller's shell, so it defines the variable inside the one function that starts Bun and still runs nothing at source time. +- **`bin/gstack-timeline-log`** and **`gstack-developer-profile --log-session`**: invalid input still exits 0 with nothing logged (the hook-path contract). Their validators now print `valid` or `invalid`, and any non-zero exit, which is Bun itself failing, is reported on stderr and returned instead of being skipped as invalid. The verdict is not an exit code because a Bun crash on Windows exits 3. When stamping a missing `ts` fails, `gstack-timeline-log` keeps the original event instead of appending an empty line. Their production callers (`gstack-skill-start`, `gstack-skill-end` and the `/office-hours` templates) still discard stderr and the exit status, so the report shows only when the bin is run directly. +- Unchanged: bun shebangs keep `--config=/dev/null` because a shebang cannot name a per-platform path, and skill prose (templates and resolvers) keeps it because `test/bun-entry-isolation.test.ts` holds skill sources to the literal spelling. TypeScript spawns keep `bunIsolationArgs()`, and `scripts/ubicloud/` keeps `/dev/null` because it runs on Linux. +- Not covered: callers that hand `bun.exe` a POSIX script path built from `pwd` or `$HOME` (the Claude hook wrappers, `autoplan/bin/phase-publication-hook`, `plan-ceo-review/bin/mode-handoff-hook`, and some `gstack-doctor`, `gstack-codex-probe`, `setup` and migration calls) only got the config spelling. With path conversion off they may still fail on the `/c/...` script path itself. The record bins convert their own paths with `cygpath -m`; the others were not verified on Windows with conversion off. Bun shebang entry points and Bun calls in skill prose still pass the literal `--config=/dev/null`, so with conversion off they fail with `EINVAL` as before. One example is the outside-voice probe in `scripts/resolvers/outside-voice.ts`, which then reports Claude as not installed. + +### Tests +- `test/bun-config-shell-bins.test.ts` (new; force-included in the Windows lane): + - On every platform, the four record bins run through `lib/script-invocation.ts` with conversion off in a repository whose `bunfig.toml` preload and `.env` are hostile. Each writes exactly one record, and neither the preload nor the `.env` state root takes effect. Invalid timeline input still exits 0 silently. + - On POSIX hosts a simulated Windows bash (`OSTYPE=msys`, a `cygpath`, and a `bun` stand-in that rejects `/dev/null` with EINVAL as `bun.exe` does) reproduces the bug: on main the four bins write nothing and the timeline control exits 0 (5 failures); with the fix they pass. The controls, without `OSTYPE=msys`, show each bin exits non-zero with nothing written. + - An inherited `OSTYPE=msys` with no `cygpath`, in a repository that commits a file named `NUL` with a preload, leaves each bin on `/dev/null`: the record is written and the preload never runs. This fails on the first version of this fix (4 failures). + - `gstack-developer-profile --log-session` reports a rejected config on stderr and exits non-zero, and still skips invalid input silently. This fails on main. + - A `bun` that exits 3 with no output, the way a Bun crash exits on Windows, makes both bins report and exit non-zero instead of skipping the event as invalid input. This fails with an exit-code verdict. +- `test/bun-isolation-launch.test.ts`: the shell-file spelling stays isolated with conversion off, and a win32-only control shows that the literal `--config=/dev/null` fails there with EINVAL. +- `test/bun-entry-isolation.test.ts`: shipped shell files must use the fail-closed spelling and define `GSTACK_BUN_CONFIG` with the canonical line before first use. A `local` definition covers only the rest of its function. Skill sources keep the literal spelling. +- `test/learnings-injection.test.ts`, `browse/test/learnings-injection.test.ts` and `test/setup-hook-canonical-paths.test.ts` now anchor on the new spelling. The two learnings-injection files also check that the anchored `bun -e` block exists. Before, a missing anchor sliced the script's last character and the negative assertions passed without checking anything. + +### Gate integrity +- `test/bun-entry-isolation.test.ts` is strengthened. On POSIX the required shell spelling still resolves to `/dev/null`. + - Now offenders: the literal `--config=/dev/null` in a shipped shell file, and an undefined or redefined `GSTACK_BUN_CONFIG`. + - The shell-caller detector also skips a `--config` value before deciding whether a call starts a program, so `bun --no-env-file --config= -e` no longer passes unchecked. The tree has no such call. + - Negative controls, each of which fails the check: the old spelling, a variable without the default, an empty `--config=`, a missing, late or altered definition, a `local` definition used from another function, and a reverted bin. + - No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.226.0] - 2026-10-26 + +**/review can re-review just its own fixes after a fixing pass, under the same rules as /ship (#3339).** Before, every /review fix cycle reran the whole review over the whole branch, so a large branch could hit the three-cycle stop on findings in code the earlier passes had already read. Now the pass after an edited pass starts with `gstack-review-log --start review --fix-range` and its checklist, specialists and QA read only what changed since that pass. The Step 4.8 adversarial review still reads the whole branch on every pass, because its rows carry their own freshness. A narrowed pass is never the final review: when it makes no edits, /review runs one full pass before writing the final record, and a fix-range record is never CURRENT. Garry's "do it" on 2026-10-24 for a fix-range re-review with a final full pass covers it. + +### Changed +- **`review/SKILL.md.tmpl`** Step 3: the first pass always starts full. The pass right after an edited pass starts with `--start review --fix-range`, and wherever that pass sets DIFF_BASE outside Step 4.8 it uses the printed `FIX_BASE=` instead. Step 4.8 and every other `` use (state-matrix detection, the gate, banned-terms and slop scans, the queue check) keep the base branch. State-matrix detection builds `origin/` from its argument, and the slop scan diffs `...HEAD`, which is empty while /review's fixes are uncommitted. If the logger refuses (for example, the first pass read uncommitted work, so no commit marks what it read), the pass starts full. +- **Step 5.8**: an edited pass now saves its own record with `converged:false` before the repeat, because the fix-range start takes its base from that record. Before, only the final pass was logged. Step 5.0 never treats this invocation's own records as prior reviews, so a defect the user skipped this invocation is not suppressed on the next pass and still counts as unresolved. "No edits" fills the final record only after a full pass; after a fix-range pass it repeats Steps 3–5 with a full start. Each logged pass finishes its own REVIEW_START. +- Nearby prose was tightened to pay for the growth (the slop-scan note, the Step 4.7 probe-reuse rule, the search-before-recommending list, the version-compare note, the final-report and Greptile lines). Every host's review SKILL.md shrank by 36 characters, and its size pin was lowered to match. The Claude skeleton is 80,581 bytes against its 80,900 cap. + +### Tests +- `test/review-fix-range.test.ts` adds five cases. Two check the rendered rules: the first pass's start block has no `--fix-range`, only the pass after an edited pass narrows, FIX_BASE replaces only DIFF_BASE outside Step 4.8, a refusal falls back to a full start, the edited pass is logged before the repeat and kept out of Step 5.0, and a fix-range pass is never final. Three run /review's real order on every pass against the real logger: core start, then the Step 4.8 attempt's start and finish, then fixes in the working tree, then the core finish with the pass's own token, and never a commit. A committed first pass narrows the next repeat to the uncommitted fixes, and the following repeat is refused back to a full pass that is CURRENT. A zero-edit fix-range row is UNVERIFIED even if it were logged. A first pass over uncommitted work can't be narrowed. +- `test/review-workflow-clarity.test.ts`: three expectations follow the new contract: the "No edits" marker, the edited pass saving its record with `converged:false`, and each pass finishing its own token instead of only the final one. The test formerly named "finish only the final core token without log-time capture" is now "each logged pass finishes its own core token without log-time capture". It still requires one `--finish REVIEW_START`, no `--start review` in Step 5.8 and no log-time token capture. + +### Gate integrity +- One gate change to review: /review may narrow a repeat pass. Verdict: this does not weaken what the final review covers, because a narrowed pass can't be the final record. The logger never marks a fix-range row CURRENT, Step 5.8 requires a full pass after a zero-edit fix-range pass, Step 4.8 stays full, and Step 5.0 can't suppress this invocation's skipped defects through its own intermediate records. Mutation checks on the rendered skill, each caught by the suite: + - letting a fix-range pass be final; + - dropping the full-pass condition on "No edits"; + - narrowing every repeat; + - dropping the refusal fallback; + - narrowing the first pass; + - not logging the edited pass; + - logging after the repeat; + - narrowing Step 4.8 and other base uses; + - letting Step 5.0 read this invocation's records. +- No threshold, budget, timeout, trial count or case kind changed. Size pins only went down. + +## [1.91.225.0] - 2026-10-26 + +**/design-html builds landing pages around one visitor action and pages that look finished, without inventing proof; adapted from Elaya's design skills (GSTA-92).** Freeform /design-html asked for purpose, audience and feel, then built. It never asked for the one action the page exists for or where the traffic comes from, and nothing caught a page that shipped with invented customers and numbers, a dead `href="#"`, a scroll-listener reveal, `100vh`, or no description, favicon or working skip link. Now every run reads a craft section at Step 1, and a free checker counts the tells at Step 4. Adapted from Elaya's [ai-design-skills](https://github.com/elayadesign/ai-design-skills) (landing-page-design, design-to-motion) and [redesign-skill](https://github.com/elayadesign/redesign-skill), MIT, © 2026 Elaya. Thank you, Elaya. + +### Changed +- **/design-html reads `sections/craft.md` on every run (Step 1).** The spec opens with `Surface: Persuade` or `Surface: other`. A Persuade page (landing, pricing, waitlist, campaign, comparison) asks at most three multiple-choice questions (primary action, traffic source, published or reference) and states the rest in one `Assumed:` line; picks one of four archetypes with a reason; keeps the hero budget (one proof signal only when real proof exists, never a metric row) and chooses the rest of the page from the archetype and objections; gives the primary action real invalid, pending, failure and success states. Every page: claims (testimonials, logos, results, prices, guarantees) come only from the user, and missing proof is left out or marked `[Needs proof: …]`; illustrative data looks real; a ship floor scoped to full documents (title, description, favicon, share tags on shareable pages, `og:image` only from a supplied URL, a skip link that reaches `
`, landmarks, alt text, inline validation, no dead links, `aria-current` on route navigation); scale snapping with readability floors and the nested-radius rule; balanced line breaks; motion rules for the one authored moment (content visible without script, `IntersectionObserver`, cheap properties only, no `scale(0)`, named curves, `100dvh`). +- **Step 4** runs `bin/gstack-design-craft` after the Slop Gate, whether or not the detector is installed, with the gate's contract: one fix pass, one rerun, the rest accepted with a reason, once more when the refinement loop ends. The hand-off opens with a "Before this can ship" block: proof markers, production dependencies, exceptions, and every claim the check changed. +- **/design-shotgun**: a landing, pricing or campaign screen's variants also differ in page archetype, keep the one primary action identical, and carry their section outline as a caption. + +### Added +- **`bin/gstack-design-craft`** (`lib/design-craft.ts`): prints `CRAFT_CHECK: ` per tell and `CRAFT_RESULT: clean` (exit 0) or ` issue(s)` (exit 1); exit 2 on a non-HTML, empty or unreadable file. Copy rules read decoded visible text; `alert()` counts only in scripts; head and navigation rules run only on a full document. Buzzwords report under the catalog's `marketing-buzzword` id. +- **NOTICE.md** and `licenses/MIT-elaya.txt`. Not carried over, because they conflict with gstack's design rules: Elaya's fixed fonts (Geist and Poppins are on gstack's overused-display list), palette, spacing values and icon sets, gradient hero text, mandatory scroll reveals and tagline section, and the island nav. + +### Tests +- `test/design-craft.test.ts`: every tell with its count; a finished page, inlined Pretext, a `100vh` line with its `100dvh` fallback, discount copy and in-page anchors stay clean; look-alike attributes, encoded markers, `!important` and reversed fallbacks; skip links judged by position and target in any language; the craft section's banned words and names all flagged; CLI exit codes. +- `design-html-slop-gate` also requires the craft check to run once or twice; the design-html carve guard requires the craft section read. + +## [1.91.224.0] - 2026-10-25 + +**`/context-restore` now says when commits landed after a checkpoint was saved, instead of offering finished steps as the plan.** `/context-save` recorded the branch, a timestamp and the modified files, but not the commit, so a checkpoint saved before a step was committed came back with that step still listed under Remaining Work. Ported from #3316 by @soyames, with their commit kept. + +### Fixed +- **`bin/gstack-slug --stamp-checkpoint`** also writes `head:`, the commit the save describes, next to `remote:`, `project_root:` and `worktree:`. A re-stamp replaces it rather than adding a second line. +- **`/context-restore`** compares `head:` with `HEAD` before presenting. When commits landed after the save it prints how many and lists up to ten, and puts every Remaining Work item under Verify first. A saved commit that is not in this checkout's history (saved on another branch, rebased, reset, or not fetched) is reported the same way. A malformed `head:` is checked as 40 or 64 hex digits before any git command sees it, `head:` is read from the frontmatter only, and outside git the comparison uses the saved `worktree:` before `project_root:`. Checkpoints saved before this keep their usual sorting with a one-line note. + +### Tests +- `test/context-restore-head.test.ts` runs the real save-path, stamp and head-check blocks from the generated SKILL.md files in fixture repos: unchanged, two commits landed, diverged after a reset, resumed from another branch, a nested linked worktree restored from outside git, a re-stamp after a new commit, pre-tracking, a `head:` that appears only in the body, and a `head: $(touch pwned)` that must never execute. + +## [1.91.223.0] - 2026-10-25 + +**/plan-tune's inferred profile finally moves: a clicked answer is logged as the question's registered option key, not its label.** #2623 showed every inferred dimension stuck at 0.5 on a 1,286-event log: the AskUserQuestion hook recorded labels like "A) Fix it now (recommended)", while the signal map that turns answers into dimension nudges is keyed by registered option keys like `fix-now`, so 0 of 1,286 answers matched. Now five hook-captured label answers to the registered architecture-finding question move `architecture_care` from 0.5 to 0.632, and `--trace` lists all five. + +### Fixed +- **`bin/gstack-question-log`**: on a hook-captured event (`source: hook`) for a registered `question_id`, the clicked label is mapped to that question's registered option key before it is written, and the label is kept in a new `choice_label` field. A label in `recommended` is mapped the same way, so `followed_recommendation` compares keys. Ad-hoc and `hook-` ids and unmatched or ambiguous labels are written as given. Rows from other writers (the agent's own log call, auto-decide, Codex import, typed free text) are unchanged, because readers such as the plan-ceo-review auto-decide check match the full mode names those rows carry. Existing logs are not rewritten. +- **`scripts/psychographic-signals.ts`**: new `resolveOptionKey(question_id, label)`. It strips the `A)` selector and `(recommended)` suffix, then matches the label's leading words against the question's own options only: each key's own words (`fix-now` → "fix now") plus a short cue list ("Add to TODOs" → `defer`, "Ship as-is" → `skip`, "HOLD SCOPE" → `hold`). The longest cue wins and a tie stays unmapped. + +### Tests +- `test/plan-tune.test.ts`: every registered option resolves from its own words, bare and as an `A) … (recommended)` label; 20 common phrasings, including the four plan-ceo-review mode names, map to their keys; negative controls (unregistered ids, a question with no options, a label naming no option, a mid-sentence cue, another question's key, a number that looks like a selector) stay unmapped. Two capture tests run the real bin: hook labels become keys with `choice_label` kept, while agent rows (including a `HOLD SCOPE` auto-decide row), ad-hoc ids, unmatched labels and free text are untouched, and a picked label follows a key recommendation. Both capture tests fail against the old bin. + +### Gate integrity +- No test expectation, threshold, budget, timeout, trial count or case kind changed. Existing tests that pin label rows from the agent, auto-decide and Codex import paths pass unchanged. + +## [1.91.222.0] - 2026-10-25 + +**Five flaky tests now fail only for real defects, and a judge reply that isn't valid JSON says so in its failure line (daily flaky-fix pass, GSTA-91).** `context-restore-fragment-match` failed 0/3 on main cb99df4, although every run restored the right checkpoint: to find the checkpoint whose title matches, the agent often prints all three, and the case failed whenever another checkpoint's body appeared anywhere in the transcript. `test/gstack-ff-update.test.ts` counted every `sleep 300` on the machine, so heartbeats from parallel test files changed its count (remote pregate at #3412's head: expected "1", received "3"). The `GSTACK_CAPY=1` install-admission case in `test/gstack-skill-start.test.ts` makes ten `gstack-skill-start` spawns under bun's 5 s default timeout and took 3.6–5.7 s on a 4-core machine. The QA-only fixture's late scenarios mocked a 1000 ms capture budget but ran each attempt's real setup inside it. The setup-browser-cookies workflow judge in train-4 gate 40173759210 returned JSON with an unescaped quote in its reasoning, and the flake record read only "JSON Parse error: Expected '}'". + +### Fixed +- **`test/helpers/context-restore-fragment.ts`** (new): `fragmentRestoreVerdict` reads only the agent's report (top-level assistant text and the final result), never tool output. The report must carry the payments checkpoint's body and no other checkpoint's body. `test/skill-e2e-context-skills.test.ts` uses it for the fragment-match case. +- **`test/helpers/llm-judge.ts`**: when a judge response is not valid JSON, `callJudge` throws a `SyntaxError` that names the judge output, its stop reason and length, and the retained `llm-judge-response-parse-error` event. It still fails closed and parses no score; the request is unchanged. + +### Tests +- `test/context-restore-fragment-verdict.test.ts` replays the three captured false reds from main (`test/fixtures/context-restore-fragment-match-captures.json`) as passes. Controls that must fail: captures rewritten to resume alpha or omega, synthetic runs that read and resume only alpha or omega, resume summaries that mix two checkpoints, and a payments body that appears only in tool output. Restoring the old any-byte surface fails all four positive controls. Focused paid runs of the case: 0/3 on main, 3/3 after (about $0.11 a trial). +- `test/llm-judge-frontier.test.ts` replays the exact captured cookie-judge reply: it fails closed with no score, one request and the new diagnosis. It fails on the previous helper. +- `test/gstack-ff-update.test.ts`: the reap case runs `gstack-ff-update` with a heartbeat interval unique to the test process and counts only sleepers of that interval, starting from zero. The old count failed with a loop starting unrelated `sleep 300` processes beside it (expected "5", received "14"); the new one passes, and still fails when the bin stops reaping its sleeper. +- `test/qa-only-fixture.test.ts`: in the late scenarios the fixture clock stands still from the start of each attempt until its runner starts, so the real setup work (directory copies and five `git` calls) costs no fixture time. The runner and cleanup stay on the real clock, as the deadline-evidence check requires. Each late capture must now get exactly the 1000 ms budget. A new `late-slow-retry` scenario stalls each attempt's setup for 1100 ms, longer than the whole budget, and its attempt 2 still has to succeed. Before, a loaded machine either used up the work budget in setup (`QA-only work budget exhausted`) or Bun's 1580 ms deadline killed a setup `git` child (exit `null` at `skill-e2e-qa-workflow.test.ts:133`); this is what failed late-retry in the 2026-10-24 and 2026-10-25 remote pregates. Under 24 busy loops on 4 cores: late-retry 1/20 → 20/20, late-failure-retry 2/20 → 20/20, late-timeout 6/20 → 20/20, late-slow-retry 20/20. Reverting the clock fails all four late scenarios unloaded; reverting the deadline fails `late-slow-retry`. +- `test/gstack-skill-start.test.ts`: the `GSTACK_CAPY=1` install-admission case gets an explicit 20 s timeout. Measured on a 4-core Capy machine with a temporary 60 s cap: 3.6–5.7 s through Capy's git shim (9 of 10 over 5 s), 2.6–4.9 s with the real git binary. Four of its ten spawns are full admissions at about 800 ms each. + +### Gate integrity +- One test budget changed, approved by GStack Minder on 2026-10-25: the install-admission case's timeout goes from bun's 5 s default to 20 s, about 3.5 times the 5.7 s worst case measured above. No assertion changed. +- The QA-only late scenarios register each attempt with its real outer budget (1000 + 80 + 5000 = 6080 ms, the value `outerMs` already asserts) instead of a shortened 1580 ms Bun deadline, which left cleanup 500 ms of real time under load. Measured child totals with the fix under 24 busy loops, 20 runs each: late-timeout 2.4–2.7 s, late-retry 4.1–4.3 s, late-failure-retry 4.7–5.0 s, late-slow-retry 6.2–6.6 s, against the parent's unchanged 15 s spawn timeout. The mocked 1000 ms capture budget, the 80 ms drain and every existing assertion are unchanged. Verdict: justified, because the deadline was a fixture artifact shorter than the budget the case declares, and the clock freeze, not the deadline, is what removes the load sensitivity. +- `context-restore-fragment-match`: the failure surface moves from the whole transcript to the agent's report. Alpha or omega bytes in tool output no longer fail the case; the payments body in tool output no longer counts as loaded, so the report must show it. A run that resumes another checkpoint, or mixes two, still fails. `test/test-of-test-ratchet.test.ts` gains one BASELINE entry, the replay test, with its reason, and the new helper joins the touchfiles of the nine context-skills cases, as the derived-closure rule requires. +- `scripts/harness-version.json` records `test/helpers/llm-judge.ts` as non-behavioral at HARNESS_VERSION 6: only a parse failure's message changes, before any score is parsed, so no case's verdict or pass-rate series changes. +- No threshold, trial count, case kind, rubric, judge request or prompt changed. + +## [1.91.221.0] - 2026-10-25 + +**The session-start updater no longer skips because of its own session's workflow lease, and the autostash tests stop failing their first pass in Free Tests.** Since #3385, `gstack-session-update` skips the fast-forward while another session holds a workflow lease, and it means to ignore the session that started it (`--exclude-ancestry-of`). The check runs in the forked background updater, though, and passed the hook's own `$$`, which has exited by then, so the ancestry walk excluded nothing. A Claude Code process that had already run a skill (and so held a lease) and then started a session again in the same process (`/clear`, resume or compaction) logged `SKIP leased pid=` and never updated. `test/session-update-autostash.test.ts` hit the same gap plus a fixture leak: its first two fixtures spawned the updater with the whole parent environment, so it read the test preload's `GSTACK_LEASE_DIR`, which every test file in the same Bun process shares. A lease another file left there gave "Received: SKIP leased pid=3196/1m" where UPDATING or PULL_FAILED was expected, in 13 Free Tests runs since #3385 merged (each passed on the serial retry). + +### Fixed +- **`bin/gstack-session-update`**: before it forks, the hook resolves its caller's harness pid with `gstack_harness_pid "$PPID"` (`bin/gstack-harness-pid.sh`), the same derivation `bin/gstack-skill-start` uses for the pid it leases, and the background lease check excludes that pid's ancestry. Leases held by other live sessions still skip the update. `bin/gstack-upgrade` runs its check in the foreground, so its `$$` is alive and it is unchanged. + +### Tests +- `test/session-update-autostash.test.ts`: every fixture now runs the updater with its own `GSTACK_STATE_ROOT` (the top of the state-root chain, instead of `GSTACK_STATE_DIR`, which an inherited `GSTACK_HOME` outranks) and its own `GSTACK_LEASE_DIR`. A negative control holds a live lease in the shared preload lease directory, confirms the ambient environment sees it, and shows the fixture still updates; it fails with "SKIP leased" without the fixture change. A positive case shows the fixture's own live lease still skips the update. +- `test/session-update-stages.test.ts`: the starting session's own lease (the test process, behind a `/bin/sh -c` wrapper as a hook runner would use) no longer blocks the update. It fails on main with `SKIP leased`. The other session-update test files already pass an explicit environment, so their leases resolve to the fixture install's own git dir. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion changed. The lease rule is narrower in one respect only: the starting session's own harness and its ancestors no longer count as holders, which is what `--exclude-ancestry-of` promised. + +## [1.91.220.0] - 2026-10-25 + +**A design doc edited in host plan mode no longer goes stale for the plan reviews (#542).** When /office-hours ran inside Claude Code's plan mode, the design doc lived twice: gstack's copy under `~/.gstack/projects//` and the host's plan file under `.claude/plans/`. Later edits went only to the plan file, so /plan-ceo-review and /plan-eng-review read the older gstack copy and called it the source of truth. The gstack design doc stays the single source (Garry, Oct 17: "designdoc"). Edits made in the plan copy are now written through to it, and every plan review is warned when the copy is newer. + +### Fixed +- **`office-hours/SKILL.md.tmpl`**: the closing design-doc check takes the host plan file that holds the doc. When that file is newer and differs, it is copied over the gstack doc (`PLAN_COPY: written through`), and the pairing is recorded beside the doc in `.plan-copy`. A repo copy under `docs/designs/` is rewritten from the doc under Phase 5's scan rule. An older plan file never overwrites the doc, and outside plan mode nothing is recorded. +- **`bin/gstack-design-doc-find`**: when the newest state-root doc has a recorded plan copy, the helper prints `DESIGN_PLAN_COPY` with the write-through command on stderr. When the copy is newer than the chosen doc and differs from it, it also prints `STALE_DESIGN_DOC` with the `cp` to run before reading. Stdout still prints only the doc path, so every caller of `{{DESIGN_DOC_DISCOVERY}}` (plan reviews, /autoplan, the spec-review prerequisite check) gets the lines with no change in rendered size. The write-through always targets the state-root doc, even when a newer repo copy won discovery. +- **`plan-ceo-review/SKILL.md.tmpl`, `plan-eng-review/SKILL.md.tmpl`**: the design-doc check runs a `STALE_DESIGN_DOC` write-through before reading and says so in one line. After a review edits the plan copy, it runs the `DESIGN_PLAN_COPY` write-through after the exit gate, before telemetry. +- To stay within the SKILL.md size pins, plan-eng-review drops its 15th cognitive pattern ("Error budgets", already named in pattern 7), and office-hours drops three explanatory sentences (cross-team discovery, the profile binary's mktemp details, the `sessions[]` array) and shortens one. + +### Tests +- `test/design-doc-plan-copy.test.ts` runs the real helper and the generated office-hours block against temp state roots: no recorded copy (silent), a copy in sync (named, no warning), a newer edited copy (warning; running its printed `cp` updates the doc and clears it), a doc edited after the copy (no warning), relative, non-markdown and missing recorded paths (ignored), and a repo copy that wins discovery (the write-through still targets the state-root doc). The office-hours block writes newer plan edits through, never lets an older plan file overwrite the doc, and records nothing outside plan mode. Both plan reviews carry the helper's two line names. + +### Gate integrity +- Size pins in `test/fixtures/skill-md-size-allowlist.json` only go down: plan-eng-review by 44 characters on 11 hosts, office-hours by 31 on 4 hosts. No pin was raised. No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.219.0] - 2026-10-25 + +**Local paid runs from `eval:bg` now set `EVALS_RUN_ID`, so the native-capture cases start instead of failing in 0 seconds.** CI sets `EVALS_RUN_ID` per slice, but `bun run eval:bg:pr --local` launched without it, and the qa-callers, ship-docsync and qa-functional cases refuse to start without it. In GSTA-70's local /ship run, 17 of the 26 selected behaviors failed that way for $0. A failure like that is a launch failure, not a verdict, so the local run told you nothing about those cases. + +### Fixed +- **`scripts/eval-bg.ts`**: the local backend launches through a new `launchLocal`. It exports `EVALS_RUN_ID` as the run-scoped log's name, reduced to `[A-Za-z0-9_.-]`, and keeps a value you exported yourself. The id is written on the log's second line, so you can find the run's native captures. `--help` says so. Dispatched CI runs are unchanged. + +### Tests +- `test/eval-detach-timeout-floor.test.ts`: a detached child launched by `launchLocal` sees a safe `EVALS_RUN_ID` named after its log, and an exported id is kept. A source check confirms that the local backend launches through `launchLocal`. + +## [1.91.218.0] - 2026-10-25 + +**`gstack-team-init required` now blocks skills when its gate can't run, instead of letting them through.** Issue #3319 (exGeni): the required-mode Skill hook is a `node -e` launcher. On a teammate's Mac or Linux machine without Node.js, the shell exits 127 with nothing on stdout. If `.claude/hooks/check-gstack.cjs` is missing, node exits 1. Claude Code treats any exit other than 0 or 2 as a non-blocking error, so in both cases the Skill call went ahead on exactly the machine the gate is for. A live Claude Code 2.1.310 session with node off PATH and no gstack install ran the skill. + +### Fixed +- **`bin/gstack-team-init`**: the check-gstack handler is registered with `"onFailure": "block"`, Claude Code's documented fail-closed switch (2.1.295+). A launcher that can't start, or exits with any code other than 0 or 2, now blocks the Skill call. The reason shown is `blocking because onFailure is "block"` plus the shell's `node: not found` (or node's `Cannot find module …check-gstack.cjs`). Exit 2 (deny) and exit 0 (allow) are unchanged. Re-running `required` upgrades an existing registration in place, and the manual-registration fallback names the field. +- The required CLAUDE.md section tells the agent what those two block reasons mean (install Node.js; the hook file is missing) and not to work around them. `required` prints that every developer needs node on PATH and Claude Code 2.1.295+, and the README says the same. + +### Compatibility +- Measured with a live `claude -p` Skill call per version. 2.1.295 and 2.1.310 block when node is missing or the hook file is gone, still deny with exit 2 when gstack is absent, and still run the skill when gstack is installed. 2.1.294 ignores the unknown field without rejecting the settings file: the hook still loads and denies as before, and a machine without node is still let through there, as it was before this change. +- Copilot CLI documents command `preToolUse` hooks as already failing closed on a crash or non-zero exit, so this change does not alter its outcome. Its handling of the extra field was not measured (no Copilot login on the test machine). + +### Tests +- `test/gstack-team-init-hook-schema.test.ts`: the handler is `{ type, command, onFailure: 'block' }`. The registered command exits 127 with no stdout when node is off PATH and 1 when check-gstack.cjs is missing, the failures only `onFailure` turns into a block. Re-running upgrades a handler registered without the field to exactly one handler with it. +- `test/team-init-hook-shell-neutral.test.ts`: through the platform's default shell, the command without node exits neither 0 nor 2 with empty stdout, and the bin registers the handler with `onFailure: 'block'`. The PowerShell case and its retries are unchanged. +- `test/team-mode.test.ts`: the legacy-upgrade assertion pins the new field. + +### Gate integrity +- One gate edit, and it tightens the gate: the required-mode Skill hook goes from fail-open to fail-closed when its launcher fails. Nothing that used to block now allows. Negative controls: with `onFailure` removed from the bin, 4 tests fail (the three new registration and upgrade tests plus the team-mode pin); with it dropped only from the in-place upgrade path, 2 fail. The live runs with gstack installed (2.1.294, 2.1.310) still run the skill, so the allow path is not broken. No threshold, budget, timeout, retry or trial count changed. + +## [1.91.217.0] - 2026-10-25 + +**Windows free tests now also run on every push to main.** `windows-free-tests.yml` ran on pull requests and dispatch only: 194 PR runs, 6 dispatches and 0 pushes in its last 200 runs. Merge trains dispatch only `free-tests.yml` on the train branch. So no Windows run ever saw a train's combined tree or main after a merge, and a Windows break caused by two individually green PRs only surfaced on the next unrelated PR. Found while working on GSTA-68. + +### Fixed +- **`.github/workflows/windows-free-tests.yml`**: adds `push: branches: [main]`, the same trigger free-tests.yml already has. The run uses the existing per-run concurrency group, and `record_durations` stays dispatch-only (on a push, `inputs.record_durations` is empty, so the lane runs and the recorder skips). Cost: about 40 job-minutes on GitHub's free `windows-latest` runners per merge. + +### Tests +- `test/windows-native-workflows.test.ts` pins the new trigger set and requires the push trigger to be limited to main. + +## [1.91.216.0] - 2026-10-25 + +**/sync-gbrain refuses an active autopilot before it touches the brain's source list (#3360, part 2; ported from #3362 by @exGeni).** The code stage checked for a running `gbrain autopilot` only right before its destructive walk. By then it had already cleaned up legacy sources, renamed a source and run `gbrain sources add`, so a refused run still registered a source it never synced. + +### Fixed +- **`bin/gstack-gbrain-sync.ts`**: the code stage runs the autopilot check before Step 0a, so a refusal leaves the source list untouched. The check before the walk stays, because autopilot can start while the steps in between run. Dry-run, read-only and deny policies and the local-engine skip still return earlier and are unchanged. Which processes count as an autopilot (the machine-wide probe, #3360 part 1) is unchanged. + +### Tests +- `test/gstack-gbrain-sync.test.ts` runs the real script against a fake `gbrain` and a `pgrep` that reports an autopilot: the stage is refused and the fake saw no `sources add`, `rename`, `remove` or `sync`. On main the same run issues `sources add`. + +## [1.91.215.0] - 2026-10-25 + +**An unattended `/ship` leaves its receipt in a file the parent can read, and the parent is told to read that file (GSTA-88).** Before, Step 18's handoff section wrote the ship receipt only to stdout, and `docs/unattended.md` told the parent to read it from the PR body with `gstack-ship-receipt read --pr --json`. An unattended run must not push, so there is no PR: the parent got a usage error or `RECEIPT_MISSING`, or, if an older PR existed, read that PR's receipt with exit 0 and a stale `head`. Now the handoff writes `.gstack/tmp/ship-receipt.md`, the parent reads it with `read --body` in the child's checkout, and it compares the receipt's `head` to the branch SHA either way. + +### Fixed +- **`ship/sections/handoff.md.tmpl`**: the receipt write carries `--out .gstack/tmp/ship-receipt.md`. The completion-message step drops `(\`pr: none\`)`, which was wrong (the block omits `pr`; only the `SHIP_RECEIPT:` line shows `pr=-`), and the "nothing precedes the identifiers" clause, which repeated "identifiers first". Ship's skeleton plus sections is 313,151 bytes, 13 below main and under its 313,281 cap; no cap or pin moved. +- **`bin/gstack-skill-start`**: an unattended `/ship` prints one status line, `UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md`. No other skill or session kind prints it, and no instruction block was added. The unattended block's `/ship` record sentence names the file for a run that did not push and the PR body only once a PR exists. +- **`docs/unattended.md`**: for `/ship`, read `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json` in the child's checkout when the run did not push, use `read --pr --json` only once a PR exists, and in both cases compare the receipt's `head` to the branch SHA, because `read` does not check that the receipt is current. + +### Tests +- `test/gstack-ship-receipt.test.ts` runs the handoff template's write command as written in a fixture repository, then reads the file back with `read --body`: `head` is HEAD, `session_kind` is `unattended` and there is no `pr`. It also checks that the template and the generated section carry `--out` and that `docs/unattended.md` names the same file with `read --body`. +- `test/gstack-skill-start.test.ts` checks that the `UNATTENDED_SHIP` line appears exactly once for an unattended `/ship`, alongside only the `unattended-session` block, and never for `/review`, `/qa`, `/autoplan` or for `/ship` in an interactive, spawned or headless session. + +## [1.91.214.0] - 2026-10-25 + +**`gstack-brain-sync --publish-removals --yes` no longer prints `list: unbound variable` when it finishes, and it now removes its temp list and releases the sync lock.** The command's EXIT trap named the function's local `$list`. When a publish succeeded, the function returned before the script exited, so the trap ran after `list` had gone out of scope. Under `set -u` it stopped at the unbound variable and never reached the cleanup, which left `brain-sync-removals.*` in `$TMPDIR` and `.brain-sync.lock.d` in `~/.gstack` until a later run cleared the lock as stale (found by GSTA-44). + +### Fixed +- **`bin/gstack-brain-sync`**: `--publish-removals` writes the temp file paths into its EXIT/INT/TERM trap when it installs the trap (quoted with `printf %q`), so the cleanup and lock release work however the function ends. The drain's trap in `--once` also names locals, but every path in that function ends with `exit` while they are still in scope, so it's unchanged. The other trap sites (the drain's lock-only trap and `gstack-brain-restore`'s, which uses a global) don't have the bug. + +### Tests +- `test/brain-sync-removals.test.ts`: a new case runs `--publish-removals`, then `--publish-removals --yes` twice (publishing, then nothing to publish) with a private `TMPDIR`. After each run, stderr has no `unbound variable`, the exit status is 0, `TMPDIR` is empty and the lock directory is gone. It failed before the fix. + +## [1.91.213.0] - 2026-10-25 + +**The CI image and the Ubicloud free-suite VMs move off Node.js 22.20.0 to 22.23.4, and both now check the Node archive's SHA-256 before unpacking it (#3354, port of #3355 by @michael-sumner).** 22.20.0 is affected by the October 21, 2026 Node.js security releases, including the Brotli decoder out-of-bounds write (CVE-2026-111497, high) and the TLS hostname bypass for leading-zero IPv4 hosts (CVE-2026-106069, high). 22.23.4 is the patched 22.x release. #3355 bumped `.github/docker/Dockerfile.ci`, but `scripts/ubicloud/setup-free-suite.sh` pins Node separately and would have kept installing 22.20.0 on every remote free-suite run. + +### Fixed +- **`.github/docker/Dockerfile.ci`**: `NODE_VERSION` is 22.23.4 (Michael Sumner's commit). The archive is checked against `NODE_SHA256`, the linux-x64 hash from the release's SHASUMS256.txt, whose signature verifies against the Node.js release keyring, before it is unpacked. +- **`scripts/ubicloud/setup-free-suite.sh`**: pins the same 22.23.4 and hash, and downloads the archive to a temporary file and verifies it instead of piping `curl` straight into `sudo tar`. + +### Tests +- `test/ci-image-cli-pin.test.ts` gains four cases: Dockerfile.ci pins an exact Node version and a SHA-256, both files verify before they unpack, and `setup-free-suite.sh` pins the same version and hash as the image. On #3355's head alone, all four fail; on main, three fail. +- The Bun ordering check in `setup-free-suite.sh` now looks for `sha256sum -c` after the Bun download instead of anywhere in the file, because the Node check now comes first. +- The test names `Dockerfile.ci` as `join(__dirname, '..', '.github', …)`, the form pregate's literal scan resolves, so a change to the image now selects it (before, pregate refused a Dockerfile.ci-only change with `PREGATE_NO_LANE`). + +### Gate integrity +- One accepted-input change to review: the Bun ordering check searches for its `sha256sum -c` from the Bun download onward. It now accepts one input it used to reject: a file with some other `sha256sum -c` before the Bun download and the Bun check after it, which is this file. The property it guards, Bun verified after download and before unpack, is unchanged. A file whose only `sha256sum -c` sits before the download still fails, and so does removing the Bun hash or moving it after `unzip` (negative controls run). No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.212.0] - 2026-10-25 + +**/careful no longer asks about words inside a commit message (#1060; ported from #3221 by @Bhumika-1432006).** The careful hook matched its MEDIUM patterns against the whole command, so `git commit -m "fix: prevent drop table in migration"` asked for approval as an SQL DROP. The hook now masks git commit's own literal message before those checks, including the `-m "$(cat <<'EOF' … EOF +)"` form agents use most. Our commits on top of the contributor's scanner fix four problems found in review: the inline awk program made the whole hook a bash syntax error on bash 3.2 (macOS `/bin/bash`), so every Bash call failed the hook there; a newline after the message was dropped, so `-m x⏎truncate …` stopped asking; any `-m '…'` after a commit was masked, including another command's argument; and the heredoc form was never masked. + +### Fixed +- **`careful/bin/mask-commit-message.awk`** (new, from the contributor's scanner): reads the command as one string and keeps its newlines. It splits at unquoted `;` `&` `|` `(` `)` and newlines (a redirection's `&` does not split) and masks `-m`, `-am`, `--message` and `--message=` only in a segment whose first word is `git` with a `commit` word after it. Single-quoted messages, double-quoted messages without `$`, backtick or backslash, and a double-quoted `$(cat <<'DELIM' … DELIM )` with a quoted delimiter are masked. Quotes, backslashes and comments are parsed as bash does. The command is left unmasked when it contains any other command, parameter or process substitution, a backtick, `$'…'`, a heredoc, an unterminated quote, or a pipe after a masked message (git prints the message on stdout). +- **`careful/bin/check-careful.sh`**: runs the program with `awk -f` from the hook's own directory, so bash 3.2 parses the hook again. A missing program or an awk error leaves the command unmasked. The HIGH deny tier, the PowerShell table and the obfuscation tripwire still read the unmasked command. + +### Tests +- `test/hook-scripts.test.ts` keeps the contributor's 10 cases and adds 31. Masked (no prompt): every flag form, `git -C repo commit`, `--amend`, multi-line messages and four heredoc spellings (`'EOF'`, `"MSG"`, `<<-` with a tab, a quoted word in the body). Still asking: a command on the next line, a quote that spans a line break, another command's `-m`, `echo git commit -m '…'`, an escaped quote, a backslash-newline, a heredoc with an unquoted delimiter, a substitution that closes the message early, a backtick, `git commit -F - <<'EOF'`, `$'…'`, a pipe after the commit (direct, after `2>&1`, or from a subshell), a process substitution and a parameter expansion. A partial install without the awk file still asks. +- The same cases pass with mawk and the one-true-awk (`original-awk`, the awk macOS ships). In a `bash:3.2` container the hook as submitted in #3221 exits 2 with `unexpected EOF while looking for matching '"'` on every command; with this change it answers normally. + +### Gate integrity +- One accepted-input change to review, in a safety hook: MEDIUM checks (and custom `careful-patterns.txt` rules) no longer see the masked text of git commit's own literal message. That is a weakening by design, limited to text that bash passes to git as a literal argument. The negative controls listed under Tests still ask. Commands that combine a commit with anything that could execute the message, or that the scanner cannot read with certainty, are checked unmasked, exactly as on main. No threshold, budget, timeout, trial count or case kind changed. +- Known limit, still a false positive as on main: `git commit -F - <<'EOF'` messages and commits after an environment assignment or `sudo` are not masked. + +## [1.91.211.0] - 2026-10-25 + +**`gstack-diff-scope` sees Python tests and nested test directories (#3335).** A branch that changed `components/orch/services/shared/tests/test_native_store_custody.py` reported `SCOPE_TESTS=false`, because the Tests rule only knew `*.test.*`-style suffixes and `test/`, `tests/`, `spec/` and `__tests__/` at the repository root. /ship and /review use that flag to route the testing specialist, so a backend change whose tests follow pytest's `test_*.py` convention skipped it. The test-bootstrap resolver already counted these files, so the two detectors disagreed. + +### Fixed +- **`bin/gstack-diff-scope`**: a changed path is a test when it matches `TEST_PATH_RE`, the same regex the test-bootstrap resolver uses for its TESTFILES count (`scripts/resolvers/testing.ts`): test dirs at any depth, `test_*.py`, `tests.py`, `_test.`, `.test.`/`.spec.` JS and TS, `_spec.rb` and `*Test.java`/`.kt`. `conftest.py` counts too. The existing globs stay. + +### Tests +- `test/diff-scope.test.ts` adds the issue's three paths, `pkg/test_store.py`, Django's `tests.py`, `conftest.py`, and nested `__tests__/` and `spec/` dirs; all eight fail on main. Controls that stay `SCOPE_TESTS=false`: `latest.py`, `contest_rules.py`, `attest_signer.py` and a `testing/` dir. A new test checks that `TEST_PATH_RE` is byte-identical to the resolver's regex, so the two can't drift again. + +## [1.91.210.0] - 2026-10-25 + +**/autoplan works when Claude Code's project directory is a subfolder of the repository (#3296).** When the git root sits above the project (for example the root is `$HOME` and the session starts in `$HOME/projects/app`), a worktree session's journal lives in `.claude/worktrees/` under that subfolder. The publication guard only looked for `/.git/worktrees`, found nothing, and denied every phase Read with `foreign_cwd`, so the reporter had to restart the session from the repository root. + +### Fixed +- **`autoplan/bin/phase-publication-hook.ts`**: `linkedWorktrees` walks up from the project directory to the first `.git` and reads that repository's worktree registry. A `.git` file (the project is itself a linked worktree or a submodule) stops the walk with no owners, as before. Git's two-way link is still required: the registry entry must name the worktree's `.git` file and that file must name the same entry back. No `git` process is started inside the hook. + +### Tests +- `test/autoplan-publication-hook.test.ts` builds the reporter's layout with real `git worktree add`: a subfolder project directory (and a deeper one) owns the worktree under it, and the root still does. It fails on main. Controls that still get no owners: a nested independent repository (its own `.git` stops the walk), a project directory inside a linked worktree, a registry entry whose worktree names another repository, and a directory with no `.git` above it. + +### Gate integrity +- One accepted-input change to review: from a project directory below the repository root, the guard now accepts the journal of a linked worktree of that same repository, which it already accepted when the project directory was the root. The seven existing `foreign_cwd` refusals in `test/autoplan-publication-guard.test.ts` (no link, one-sided links, another repository, a submodule gitdir, a project that is itself a worktree, a bare repository) still deny. No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.209.0] - 2026-10-25 + +**The free test suite no longer rewrites a contributor's global git pre-push hook (#3353).** On a machine with a global `core.hooksPath`, `test/redact-prepush-target.test.ts` and `test/fs-utils.test.ts` ran `gstack-redact install-prepush-hook` with the parent environment. Git resolved the hooks directory to the global one, so the install moved the developer's own `pre-push` to `pre-push.local` and left a managed wrapper pointing into the test checkout, which breaks every later push once that checkout is gone. The same config also failed 4 tests in the target file, 5 in `test/redact-prepush-hook.test.ts` and 1 in `fs-utils`. CI has no global hooks path, so it stayed green. + +### Fixed +- **`test/helpers/git-env.ts`** (new): `gitEnv()` is the one fixture environment for git and hook installs: `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_COUNT=0` and no `GIT_CONFIG_PARAMETERS`. Config written inside the fixture repo still applies. +- `test/redact-prepush-target.test.ts`, `test/redact-prepush-hook.test.ts` and `test/fs-utils.test.ts` pass it to every fixture git, install and hook spawn. `test/redact-prepush-scan-range.test.ts` and `test/redact-prepush-rebase-force-push.test.ts` drop their private copies (#2967) and import it. + +### Tests +- A new case in `test/redact-prepush-target.test.ts` sets a global, system and environment `core.hooksPath` to a hook that fails, builds the fixture, and checks the developer's hooks directory is untouched, the managed hook landed in the fixture repo, and a push never runs the developer's hook. It fails without the shared environment. +- Rerun of the issue's repro with `HOME` pointing at a gitconfig with a global hooks dir: on main, two files rewrote it and three files had failures; with this change all six `redact-prepush-*` and `fs-utils` files pass and the directory is unchanged. + +## [1.91.208.0] - 2026-10-25 + +**The Capy install guide says that the team-mode session-start updater also waits for live workflow leases.** With #3385 (#3363), `bin/gstack-session-update` skips its background fast-forward while another session holds a live workflow lease, the same leases `bin/gstack-upgrade` already honors. `docs/capy-install.md` described only the upgrade side. + +### 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. + +### Fixed +- **`.github/workflows/free-tests.yml`**: in free-suite and quick-bun-default, the Playwright install gets its own 5-minute timeout (a warm install takes about 15 s), and that install step alone gets one bounded retry, also 5 minutes. This retries infrastructure, never a test: a second failure fails the job. The browser cache is keyed on the Playwright version instead of `bun.lock`, with no prefix fallback, so a lockfile bump keeps the browser and a Playwright bump never restores a stale revision. quick-bun-default, which had no browser cache, now has one. +- **`.github/workflows/free-tests.yml`**: free-suite and quick-bun-default run `git checkout -q -B main` right after checkout on every run whose ref is not main (PRs and train dispatches). Their clone then has the same local `main` a push run gets, so a PR run catches what the main push would. +- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_DEFAULT` is 1.4.4, the Bun that Capy images ship, that gbrain pins, and that carries the CVE-2026-111497 fix. quick-bun-default runs the quick tier on it. The 1.4.2 minimum, engines.bun, Dockerfile.ci, every other CI lane and the verified archives (1.4.2, 1.4.3, 1.4.4) are unchanged. `docs/capy-install.md` and `docs/troubleshooting.md` name the new default. + +### Tests +- `test/free-tests-workflow-wiring.test.ts` requires the version-keyed cache, the 5-minute install, its single retry as the only `continue-on-error` step in each job, and the local-main step. Both new cases fail against the previous workflow. +- `test/gstack-capy-install.test.ts` reads the install default from `GSTACK_BUN_DEFAULT` wherever it meant "the install default", so the next bump doesn't need a literal sweep, and so does the `test/runtime-pins.test.ts` case that reads gstack's own CI pins. `test/bun-resolve.test.ts` moves its gbrain-like project pin from 1.4.3 to 1.4.4, gbrain's real pin. + +### Gate integrity +- One gate edit to review: the Playwright install step in the required Free Tests lane is `continue-on-error`, and a retry step that is not marked that way repeats it. Verdict: not weakening. A shard still fails unless one of the two installs succeeds, and every test, threshold, timeout and trial count stays the same. The local-main step strengthens PR runs. No test assertion was removed. + +## [1.91.188.0] - 2026-10-24 + +**Pregate maps extensionless .jsx imports to their tests (#3340).** On a Next.js or webpack JSX repo, `gstack-pregate` failed `lanes` with "has no test lane" for every component imported without an extension, so /ship's receipt refused and the only way out was shipping without one. The import resolver tried `.ts`, `.tsx`, `.js`, `.mjs` and `.cjs` but never `.jsx`, and a directory import found `index.ts` or `index.js` but not `index.jsx`, `index.tsx` or `index.mjs` (it fell back to selecting on the whole directory). Reported by adam-balazs. + +### Fixed +- **`lib/pregate/import-graph.ts`**: `RESOLVE_EXT` adds `.jsx`, `.mts`, `.cts`, `/index.tsx`, `/index.jsx` and `/index.mjs`. The existing entries keep their order, so a `.js` file still wins over a `.jsx` sibling. Relative, `paths` alias and `baseUrl` imports share the list, so all three resolve the new forms. + +### Tests +- `test/pregate-selection.test.ts` has three new cases: an extensionless import of a `.jsx`, `.mts` and `.cts` file; a directory import that reaches `index.jsx`, `index.tsx` and `index.mjs` and leaves the directory's other files unselected; and a jsconfig `paths` alias to a `.jsx` file, with a `.js` sibling that still wins. All three failed before the change. + +### Docs +- `docs/pregate.md` lists the extensions and index forms an extensionless import tries, in order. + +## [1.91.187.0] - 2026-10-24 + +**Four docs now say what the code does: the unattended run contract, the gate reply grammar, the session-kind override and voice triggers.** All four were reported by readers who checked the docs against the code. `docs/unattended.md` promised that every workflow skill ends an unattended run with a run directory and a `GSTACK_RESULT` line, but only `/autoplan` has the steps and helpers that write one (#3281, thanks @exGeni). `gstack-gate --help` still taught the reply grammar from before 1.91.107.0, without `rec` or `as-is`, while the render it relays leads with `rec` (#3282, thanks @exGeni). `docs/OPENCLAW.md` said only `spawned` is honored as a `GSTACK_SESSION_KIND` override, but `unattended` is honored too (#3287, thanks @exGeni). The README said natural phrases activate the right skill, without saying that only `voice-triggers:` aliases reach a host and that the plain `triggers:` list does not (#2118, thanks @jinzheio). + +### Fixed +- **`docs/unattended.md`**: the terminal-line section lists who writes the line today: `/autoplan` (the only skill that writes a run directory), the workflow commands `gstack-pregate`, `gstack-regen`, `gstack-restamp` and `gstack-ship-queue` (each naming its own record), and a refused launch (`status=refused run=-`). For `/ship`, `/review`, `/qa` and the other workflow skills it says not to wait for a run directory and names each skill's own record to read instead. It also says `/eval-plan` has no `gate.json` writer yet and that `/cso` skips the shared unattended rules. +- **`bin/gstack-gate --help`** and the `/autoplan` owner brief's answer hint (`lib/owner-brief.ts`) show the current grammar: `rec` (alias `all`), `rec except `, `