Merge remote-tracking branch 'origin/main' into gsta-70-eval-bg-run-id

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
garrytan committed 2026-10-25 21:21:45 +00:00
commit 69a8aeac53
162 files changed
+6570 -717

No files matched your search

+6 -1
View File
@@ -90,8 +90,13 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL https://cli.github.
# and "gnupg" was renamed to "gpg" on Ubuntu 24.04 anyway, so NodeSource's script
# fails before it can add its own repo. Direct tarball download is network-simpler
# (one host: nodejs.org) and doesn't touch apt at all.
ENV NODE_VERSION=22.20.0
# The hash comes from the release's signed SHASUMS256.txt, and the archive is
# checked before it is unpacked. scripts/ubicloud/setup-free-suite.sh pins the
# same version and hash (test/ci-image-cli-pin.test.ts keeps them equal).
ENV NODE_VERSION=22.23.4
ARG NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce
RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz \
&& echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \
&& tar -xJ -C /usr/local --strip-components=1 --no-same-owner -f /tmp/node.tar.xz \
&& rm -f /tmp/node.tar.xz \
&& node --version \
+63 -6
View File
@@ -238,12 +238,43 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# Push runs check out main with `checkout -B main`, so the clone holds a
# local main; PR runs are a detached merge ref and train dispatches sit on
# train/<nonce>. A fixture that made its own main passed its PR and then
# failed every push to main (#3237, run 38999168852). Give every run the
# push layout so a PR run catches what the main push would.
- name: Match main's local branch layout on PR and dispatch runs
if: github.ref != 'refs/heads/main'
run: git checkout -q -B main
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.3
bun-version: 1.4.4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Read the Playwright version for the browser cache key
id: playwright
run: echo "version=$(node -p 'require("playwright-core/package.json").version')" >> "$GITHUB_OUTPUT"
# Keyed on the Playwright version, which fixes the Chromium revision: a
# lockfile bump that keeps Playwright reuses the browser, and a Playwright
# bump never restores a stale revision.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: linux-playwright-chromium-${{ steps.playwright.outputs.version }}
# The cache restores the browser; --with-deps still runs apt for system
# libraries. On 2026-10-21 apt stalled on an archive.ubuntu.com fetch and
# held free-suite (12) for the whole 20-minute job timeout (run
# 39641712129). Each attempt gets 5 minutes (a warm install takes about
# 15 s) and the install alone is retried once. This retries infrastructure,
# never a test.
- name: Install Playwright Chromium
id: playwright_install
timeout-minutes: 5
continue-on-error: true
run: npx playwright install --with-deps chromium
- name: Retry the Playwright Chromium install once
if: steps.playwright_install.outcome == 'failure'
timeout-minutes: 5
run: npx playwright install --with-deps chromium
- name: Configure the bundled Chromium sandbox helper
run: |
@@ -294,6 +325,15 @@ jobs:
with:
persist-credentials: false
# Push runs check out main with `checkout -B main`, so the clone holds a
# local main; PR runs are a detached merge ref and train dispatches sit on
# train/<nonce>. A fixture that made its own main passed its PR and then
# failed every push to main (#3237, run 38999168852). Give every run the
# push layout so a PR run catches what the main push would.
- name: Match main's local branch layout on PR and dispatch runs
if: github.ref != 'refs/heads/main'
run: git checkout -q -B main
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
@@ -314,16 +354,33 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Read the Playwright version for the browser cache key
id: playwright
run: echo "version=$(node -p 'require("playwright-core/package.json").version')" >> "$GITHUB_OUTPUT"
# Keyed on the Playwright version, which fixes the Chromium revision: a
# lockfile bump that keeps Playwright reuses the browser, and a Playwright
# bump never restores a stale revision.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: linux-playwright-${{ hashFiles('bun.lock') }}
restore-keys: |
linux-playwright-
key: linux-playwright-chromium-${{ steps.playwright.outputs.version }}
# Cache restores browser binaries; install is still required for system
# deps and is a fast no-op for already-present browsers.
# The cache restores the browser; --with-deps still runs apt for system
# libraries. On 2026-10-21 apt stalled on an archive.ubuntu.com fetch and
# held free-suite (12) for the whole 20-minute job timeout (run
# 39641712129). Each attempt gets 5 minutes (a warm install takes about
# 15 s) and the install alone is retried once. This retries infrastructure,
# never a test.
- name: Install Playwright Chromium
id: playwright_install
timeout-minutes: 5
continue-on-error: true
run: npx playwright install --with-deps chromium
- name: Retry the Playwright Chromium install once
if: steps.playwright_install.outcome == 'failure'
timeout-minutes: 5
run: npx playwright install --with-deps chromium
- name: Configure the bundled Chromium sandbox helper
+5
View File
@@ -23,6 +23,11 @@ name: Windows Free Tests
on:
pull_request:
branches: [main]
# Also on main pushes, as free-tests.yml does: trains dispatch only the
# Linux free workflow, so without this no Windows run ever sees a train's
# combined tree or main after a merge.
push:
branches: [main]
workflow_dispatch:
inputs:
record_durations:
+470
View File
@@ -10,6 +10,476 @@
### 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 <n> --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 <n> --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.<ext>`, `.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/<name>` under that subfolder. The publication guard only looked for `<project dir>/.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=<p>/<age>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 `<repo>/.claude/worktrees/<name>` 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 `<repo>/.claude/worktrees/<name>` (or a subdirectory of it), the session counts with `<repo>`. 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 `<state root>/projects/<slug>/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): `<workflow.yml>/<job>` 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=<sha>`. 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 <FIX_BASE>` 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/<base>` and `<base>` 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=<name> status=gate_pending run=<dir>`. 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=<dir>`. `/eval-plan` ends at its spend approval with `GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>`, 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 <n> --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 `<name>`), 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=<name>` 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 <install> --exclude-ancestry-of <hook pid>`. Any live lease from another session logs `SKIP leased pid=<pid>/<age>` 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 <n> \`<command>\``, 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 <name>` 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 <name>` 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 `<N|null>` 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 <remote-url> <allow|off>`** 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 [<remote-url>]` prints `<tier>\t<allow|off>`, 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 `<tier>\t<allow|off>` 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<i>.
- **`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<i> 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/<owner>/<repo>`, 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 <pr>,...`**: 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<i> is accepted only as main's CHANGELOG plus lines the pin itself added. Every other file must still equal T<i> 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 <path>`. It re-heads an existing `## [Unreleased]` section as `## [<version>] - <UTC date>` 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 <tokens>`, `<id><option>` tokens and `as-is`. Display text only; the parser is unchanged and `all` still parses the same as `rec`.
- **`docs/troubleshooting.md#gate-reply-unparsed`** gives the same grammar in its fix line.
- **`docs/OPENCLAW.md`**: the `GSTACK_SESSION_KIND` section says `spawned` and `unattended` are honored, and links to `unattended.md`.
- **`README.md`**: the voice input section says which phrases a host sees (`voice-triggers:` aliases, as router lines on Claude Code and in each description elsewhere) and that `triggers:` is metadata that does not make a skill activate.
## [1.91.186.0] - 2026-10-24
**Approving an autoplan run no longer fails one time in ten because its run id looks like a card number.** Today's daily flaky-fix pass found `test/capy-captain-flow.test.ts` failing once and passing on the serial retry in 7 of the 57 Free Tests runs from 2026-10-21 to 2026-10-23, one of them a push to main (39878113535). Every failure had the same assertion: `gstack-approvals import` refused the approval row with "field ask: pii.cc". The default autoplan run id is `run-YYYYMMDDHHMMSS`, the import writes it into the row's ask and source, and the credit-card rule saw those 14 digits as a card number whenever they passed the Luhn check, which a timestamp does about one time in ten. A real user's approval import failed the same way.
+1 -1
View File
@@ -544,7 +544,7 @@ Key routing rules:
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test)
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
+14 -3
View File
@@ -66,6 +66,8 @@ No vendored files in your repo, no version drift, no manual upgrades. Every Clau
Swap `required` for `optional` if you'd rather nudge teammates than block them.
`required` blocks skills with a hook that runs on [Node.js](https://nodejs.org/), so every teammate needs Node.js on PATH, on every OS. With Claude Code 2.1.295 or later the hook also blocks when it can't run (no Node.js, or a missing `.claude/hooks/check-gstack.cjs`); older Claude Code lets skills through in that case.
### OpenClaw
OpenClaw spawns Claude Code sessions via ACP, so every gstack skill just works
@@ -513,9 +515,18 @@ The sprint structure is what makes parallelism work. Without a process, ten agen
### Voice input (AquaVoice, Whisper, etc.)
gstack skills have voice-friendly trigger phrases. Say what you want naturally —
"run a security check", "test the website", "do an engineering review" — and the
right skill activates. You don't need to remember slash command names or acronyms.
Many gstack skills list speech-to-text aliases (`voice-triggers:` in their
templates): "security check" for `/cso`, "run QA" or "test the app" for `/qa`,
"tech review" for `/plan-eng-review`. Say one of those naturally and the right
skill can be picked without the slash command name. On Claude Code the aliases
are routing lines in the root `/gstack` router; on Codex and the other hosts
they are appended to each skill's description, which is what those hosts match
on.
The separate `triggers:` list in a skill's frontmatter is gstack metadata, not a
matching surface. Claude Code and Codex choose a skill from its name and
description, so a phrase that appears only under `triggers:` does not activate
the skill (Codex output drops the field; the GBrain host keeps it).
## Uninstall
+6 -1
View File
@@ -189,7 +189,7 @@ quality gates that produce better results than answering inline.
- User asks to review design of a plan → invoke `/plan-design-review`
- User asks about developer experience of a plan, API/CLI/SDK design → invoke `/plan-devex-review`
- User wants all reviews done automatically, "review everything" → invoke `/autoplan`
- User reports a bug, error, broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → invoke `/investigate`
- User reports a bug, error, failing test or broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → check the cause by **Failures** below, then fix it or invoke `/investigate`
- User asks to test the site, find bugs, QA, "does this work", "check the deploy" → invoke `/qa`
- User asks to just report bugs without fixing → invoke `/qa-only`
- User asks to review code, check the diff, pre-landing review, "look at my changes" → invoke `/review`
@@ -244,6 +244,11 @@ Generic “second opinion”, “outside review”, or “cross-model review”
- Voice trigger "quality check", "test the app", "run QA" → invoke `/qa`
- Voice trigger "bug report", "just check for bugs" → invoke `/qa-only`
**Failures: check the cause, then route.** A failing test, error or stack trace shows where a failure surfaces, not its root cause.
- **Cause supplied** (the user names a specific cause and a narrow fix): make one read-only check of the evidence it rests on, such as the named file, test or log. If that confirms the cause, make the fix and run the focused test that covers it. If it does not, invoke `/investigate` before any further tracing or edit.
- **Cause unknown:** make one read-only triage pass over the supplied evidence and the directly implicated test and code. Do not reproduce, test hypotheses or search wider. If that pass establishes the root cause and a narrow fix, make the fix and run the focused test that covers it. If the cause, scope or intended behavior is still unclear, invoke `/investigate` before any broader diagnosis or edit.
**Route only to skills in your available-skills list.** A rule above whose skill is
not listed there is turned off on this install; skip it, never try to invoke it.
+6 -1
View File
@@ -65,7 +65,7 @@ quality gates that produce better results than answering inline.
- User asks to review design of a plan → invoke `/plan-design-review`
- User asks about developer experience of a plan, API/CLI/SDK design → invoke `/plan-devex-review`
- User wants all reviews done automatically, "review everything" → invoke `/autoplan`
- User reports a bug, error, broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → invoke `/investigate`
- User reports a bug, error, failing test or broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → check the cause by **Failures** below, then fix it or invoke `/investigate`
- User asks to test the site, find bugs, QA, "does this work", "check the deploy" → invoke `/qa`
- User asks to just report bugs without fixing → invoke `/qa-only`
- User asks to review code, check the diff, pre-landing review, "look at my changes" → invoke `/review`
@@ -99,6 +99,11 @@ quality gates that produce better results than answering inline.
- User asks to tune question sensitivity, "stop asking me that" → invoke `/plan-tune`
- User asks for code quality dashboard, "health check" → invoke `/health`{{VOICE_TRIGGER_ROUTING}}
**Failures: check the cause, then route.** A failing test, error or stack trace shows where a failure surfaces, not its root cause.
- **Cause supplied** (the user names a specific cause and a narrow fix): make one read-only check of the evidence it rests on, such as the named file, test or log. If that confirms the cause, make the fix and run the focused test that covers it. If it does not, invoke `/investigate` before any further tracing or edit.
- **Cause unknown:** make one read-only triage pass over the supplied evidence and the directly implicated test and code. Do not reproduce, test hypotheses or search wider. If that pass establishes the root cause and a narrow fix, make the fix and run the focused test that covers it. If the cause, scope or intended behavior is still unclear, invoke `/investigate` before any broader diagnosis or edit.
**Route only to skills in your available-skills list.** A rule above whose skill is
not listed there is turned off on this install; skip it, never try to invoke it.
+1 -1
View File
@@ -1 +1 @@
1.91.186.0
1.91.218.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.186.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.218.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+13 -4
View File
@@ -627,12 +627,21 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[
* A worktree session gets the repository root as CLAUDE_PROJECT_DIR while its
* journal is rooted in the linked worktree. Only git's own two-way link makes
* a directory that worktree: <root>/.git/worktrees/<name>/gitdir names
* <worktree>/.git, and that file names the same admin entry back.
* <worktree>/.git, and that file names the same admin entry back. A project
* directory below the root (#3296) uses the first .git found walking up; a
* .git file there means a linked worktree or submodule, which has no registry.
*/
export function linkedWorktrees(projectDir: string): string[] {
let entries: fs.Dirent[];
const admin = path.join(projectDir, '.git', 'worktrees');
try { entries = fs.readdirSync(admin, { withFileTypes: true }); } catch { return []; }
let entries: fs.Dirent[], admin: string | undefined;
try {
for (let dir = path.resolve(projectDir); ; dir = path.dirname(dir)) {
const dotGit = fs.statSync(path.join(dir, '.git'), { throwIfNoEntry: false });
if (dotGit) { if (dotGit.isDirectory()) admin = path.join(dir, '.git', 'worktrees'); break; }
if (path.dirname(dir) === dir) break;
}
if (!admin) return [];
entries = fs.readdirSync(admin, { withFileTypes: true });
} catch { return []; }
return entries.filter(entry => entry.isDirectory()).slice(0, 256).flatMap(entry => {
try {
const link = path.join(admin, entry.name);
+1 -1
View File
@@ -1068,7 +1068,7 @@ subcmd_publish_removals() {
sync_active || { echo "BRAIN_SYNC: artifacts sync is not active; nothing to publish" >&2; exit 1; }
gstack_brain_lock_acquire "$GSTACK_HOME" try || { echo "BRAIN_SYNC: another sync is running; try again" >&2; exit 1; }
list=$(mktemp "${TMPDIR:-/tmp}/brain-sync-removals.XXXXXX") || exit 1
trap 'rm -f "$list" "$list.gone"; gstack_brain_lock_release' EXIT INT TERM
trap "rm -f $(printf '%q ' "$list" "$list.gone"); gstack_brain_lock_release" EXIT INT TERM
removal_candidates "$list" || { echo "BRAIN_SYNC: could not list removed files" >&2; exit 1; }
n=$(grep -c . "$list" 2>/dev/null || true)
if [ "$n" -eq 0 ]; then
+1 -1
View File
@@ -18,7 +18,7 @@
# at or above the minimum, and one CI job runs the quick tier on it.
GSTACK_BUN_FLOOR="1.3.3"
GSTACK_BUN_TESTED="1.4.2"
GSTACK_BUN_DEFAULT="1.4.3"
GSTACK_BUN_DEFAULT="1.4.4"
# gstack_bun_archive_sha256 TARGET [VERSION] — the SHA-256 of
# https://github.com/oven-sh/bun/releases/download/bun-v$VERSION/bun-TARGET.zip
+8
View File
@@ -42,6 +42,12 @@ MIGRATIONS=false
API=false
AUTH=false
# Test-file paths, the same rule the test-bootstrap resolver counts as
# TESTFILES (scripts/resolvers/testing.ts; test/diff-scope.test.ts keeps the two
# identical). Nested test dirs and Python's test_*.py / tests.py were missed by
# the root-anchored globs alone (#3335).
TEST_PATH_RE='(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$'
_print_flags() {
echo "SCOPE_FRONTEND=$FRONTEND"
echo "SCOPE_BACKEND=$BACKEND"
@@ -126,7 +132,9 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
case "$f" in
*.test.*|*.spec.*|*_test.*|*_spec.*) m_tests=true ;;
test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) m_tests=true ;;
conftest.py|*/conftest.py) m_tests=true ;;
esac
if [[ "$f" =~ $TEST_PATH_RE ]]; then m_tests=true; fi
# Docs
case "$f" in
+1 -1
View File
@@ -22,7 +22,7 @@ const HELP = `gstack-gate — one gate list, one reply grammar
decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO]
decisions.jsonl rows (pending, or applied when a reply is given)
Reply grammar: \`all\` · \`<id><option>\` tokens (\`d3b uc1a\`) · \`all except <tokens>\`. Bare yes/no is rejected.
Reply grammar: \`rec\` (alias \`all\`) · \`rec except <tokens>\` · \`<id><option>\` tokens (\`d3b uc1a\`) · \`as-is\` (the plan as written). Bare yes/no is rejected.
gate.json: {"schema_version":1,"run":"<id>","gate_rev":1,"items":[{"id":"d1","title":"…","kind":"auto|approval","recommended":"a","options":[{"key":"a","text":"…"},…]}]}
${exitTableHelp()}`;
+182 -8
View File
@@ -15,10 +15,22 @@
# rather than bypass a set policy.
#
# gstack-gbrain-repo-policy set <remote-url> <read-write|read-only|deny>
# Persist a tier for the given remote. Exits 0 on success.
# Persist a tier for the given remote. Exits 0 on success. Changing a
# remote's tier clears its Memorable recall opt-in.
#
# gstack-gbrain-repo-policy memorable get [<remote-url>]
# Print "<tier>\t<allow|off>": the tier (as `get` prints it) and whether
# Memorable recall is opted in for this remote. Read-only lookup for the
# recall hook: a corrupt store is a hard error (exit 2), never quarantined.
#
# gstack-gbrain-repo-policy memorable set <remote-url> <allow|off>
# Opt a read-only remote in to (or back out of) Memorable recall. Off by
# default. Refused unless the remote's tier is read-only: a deny tier
# always vetoes recall, and read-write never needed an opt-in.
#
# gstack-gbrain-repo-policy list
# Print every entry as "<key>\t<tier>", sorted by key.
# Print every entry as "<key>\t<tier>", sorted by key, with a third
# column "memorable=allow" on remotes opted in to Memorable recall.
#
# gstack-gbrain-repo-policy normalize <url>
# Print the normalized (canonical) key for a given remote URL.
@@ -33,8 +45,11 @@
# {
# "_schema_version": 2,
# "github.com/foo/bar": "read-write",
# "github.com/baz/qux": "deny"
# "github.com/baz/qux": "deny",
# "_memorable_recall": { "github.com/foo/bar": "allow" }
# }
# The keys _schema_version and _memorable_recall are reserved; other keys
# name remotes (including the _unattributed sentinel memory-ingest uses).
#
# Tier semantics:
# read-write — agent may search AND write new pages from this repo.
@@ -43,6 +58,11 @@
# decision.)
# deny — no gbrain interaction at all.
#
# Memorable recall opt-in: the recall hook skips deny and read-only remotes.
# A read-only remote listed in _memorable_recall with exactly "allow" is
# recalled anyway; any other value or shape counts as off. Every store that
# makes the remote read-only (see the merge below) must opt it in.
#
# Legacy migration:
# On any read of a file missing `_schema_version` (or with version < 2),
# legacy `allow` values are atomically rewritten to `read-write`, and
@@ -187,6 +207,24 @@ ensure_file() {
fi
}
# memorable_optin FILE KEY — "allow" when FILE's _memorable_recall map holds
# exactly the string "allow" for KEY; any other value or shape is "off".
memorable_optin() {
jq -r --arg key "$2" '(._memorable_recall | if type == "object" then .[$key] else null end) as $v
| if $v == "allow" then "allow" else "off" end' "$1"
}
# jq filter that removes $key's opt-in (and an emptied map).
DROP_MEMORABLE_OPTIN='if (._memorable_recall | type) == "object" then (._memorable_recall |= del(.[$key])) | (if ._memorable_recall == {} then del(._memorable_recall) else . end) else . end'
# _schema_version and _memorable_recall hold store metadata; a remote that
# normalizes to one of them cannot be stored.
reject_reserved_key() {
case "$1" in
_schema_version|_memorable_recall) die "'$1' is a reserved key in the policy store, not a remote" ;;
esac
}
_tier_rank() {
case "$1" in deny) echo 0 ;; read-only) echo 1 ;; read-write) echo 2 ;; *) echo 3 ;; esac
}
@@ -295,13 +333,146 @@ cmd_set() {
local key
key=$(normalize "$url")
[ -z "$key" ] && die "cannot normalize remote URL: $url"
reject_reserved_key "$key"
ensure_file
local tmp
local previous opted tmp
previous=$(jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE")
opted=$(memorable_optin "$POLICY_FILE" "$key")
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp"
if [ "$previous" != "$tier" ]; then
jq --arg key "$key" --arg tier "$tier" ".[\$key] = \$tier | $DROP_MEMORABLE_OPTIN" "$POLICY_FILE" > "$tmp"
else
jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp"
fi
mv "$tmp" "$POLICY_FILE"
chmod 0600 "$POLICY_FILE"
echo "Set $key → $tier"
if [ "$previous" != "$tier" ] && [ "$opted" = "allow" ]; then
echo "Cleared the Memorable recall opt-in for $key (the tier changed; opt in again with: gstack-gbrain-repo-policy memorable set $key allow)"
fi
}
# memorable_store_answer FILE KEY — "<tier> <allow|off>" for KEY in one store,
# read from a single snapshot of the file with no write, migration or
# quarantine (tier is none when the file or the key is absent; a legacy
# "allow" reads as read-write in memory). Dies (exit 2) on anything else: a
# path that is present but not a readable regular file, a path whose
# existence cannot be confirmed (an unsearchable ancestor), content that is not exactly one JSON object, or a
# present key whose value is not a known tier.
memorable_store_answer() {
local store="$1" key="$2" up raw
if ! [ -e "$store" ] && ! [ -L "$store" ]; then
# Absent only if the nearest existing ancestor is a searchable directory:
# an unsearchable one hides an existing store from every test above.
up=$(dirname "$store")
while ! [ -e "$up" ] && ! [ -L "$up" ] && [ "$up" != "/" ] && [ "$up" != "." ]; do up=$(dirname "$up"); done
if [ -d "$up" ] && [ -x "$up" ]; then
printf 'none off\n'
return 0
fi
die "cannot confirm whether $store exists ($up is not a searchable directory) — refusing the Memorable recall lookup."
fi
if ! [ -f "$store" ] || ! [ -r "$store" ]; then
die "policy store $store exists but is not a readable file — refusing the Memorable recall lookup. Inspect or remove it."
fi
raw=$(cat -- "$store") || die "cannot read policy store $store — refusing the Memorable recall lookup."
printf '%s' "$raw" | jq -e -s -r --arg key "$key" '
if length == 1 and (.[0] | type) == "object" then .[0] else error("not one JSON object") end
| (if has($key) then
(.[$key] | if . == "allow" then "read-write"
elif . == "deny" or . == "read-only" or . == "read-write" then .
else error("invalid tier") end)
else "none" end) as $tier
| ((._memorable_recall | if type == "object" then .[$key] else null end) == "allow") as $opt
| "\($tier) \(if $opt then "allow" else "off" end)"' 2>/dev/null \
|| die "policy store $store is corrupt (not one JSON object, or an invalid tier for $key) — refusing the Memorable recall lookup. Inspect with: gstack-gbrain-repo-policy list; re-run /setup-gbrain to rebuild the store."
}
# memorable get — tier plus the effective Memorable recall opt-in, for the
# recall hook. Pure read: each store is read once, never migrated, never
# quarantined, and any doubt about a present store is a hard error (exit 2),
# because reading it as "no policy" would silently lift a deny or read-only
# veto. The tier merges as get does (the ~/.gstack store contributes only deny
# or read-only). The opt-in counts only when the merged tier is read-only and
# every store that restricts the remote (deny or read-only) opts it in, so an
# opt-in can never relax a restriction another store imposed.
cmd_memorable_get() {
local url="${1:-}"
require_jq
if [ -z "$url" ]; then
url=$(git config --get remote.origin.url 2>/dev/null || true)
fi
local key
key=$(normalize "$url")
if [ -z "$key" ]; then
printf 'unset\toff\n'
return 0
fi
local answer p_tier p_opt l_tier="none" l_opt="off"
answer=$(memorable_store_answer "$POLICY_FILE" "$key") || exit 2
read -r p_tier p_opt <<<"$answer"
if [ -n "$_gstack_legacy_root" ]; then
answer=$(memorable_store_answer "$_gstack_legacy_root/gbrain-repo-policy.json" "$key") || exit 2
read -r l_tier l_opt <<<"$answer"
fi
local merged="$p_tier"
case "$l_tier" in
deny|read-only)
if [ "$(_tier_rank "$l_tier")" -lt "$(_tier_rank "$merged")" ]; then merged="$l_tier"; fi ;;
esac
if [ "$merged" != "read-only" ]; then
[ "$merged" = "none" ] && merged="unset"
printf '%s\toff\n' "$merged"
return 0
fi
local optin="allow"
case "$p_tier" in deny|read-only) [ "$p_opt" = "allow" ] || optin="off" ;; esac
case "$l_tier" in deny|read-only) [ "$l_opt" = "allow" ] || optin="off" ;; esac
printf 'read-only\t%s\n' "$optin"
}
cmd_memorable_set() {
local url="${1:-}" value="${2:-}"
if [ -z "$url" ] || [ -z "$value" ]; then die "usage: memorable set <remote-url> <allow|off>"; fi
case "$value" in
allow|off) ;;
*) die "invalid value '$value' (must be allow or off)" ;;
esac
local key
key=$(normalize "$url")
[ -z "$key" ] && die "cannot normalize remote URL: $url"
reject_reserved_key "$key"
if [ "$value" = "allow" ]; then
local current
current=$(cmd_memorable_get "$url" | cut -f1)
case "$current" in
read-only) ;;
deny) die "$key is deny: a deny tier always vetoes Memorable recall, so an opt-in is refused. Change the tier first if you mean it: gstack-gbrain-repo-policy set $key read-only" ;;
*) die "$key is $current, not read-only: the opt-in only applies to read-only remotes (read-write is already recalled; an unset remote has no veto)" ;;
esac
fi
ensure_file
local tmp
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
if [ "$value" = "allow" ]; then
jq --arg key "$key" '._memorable_recall = ((._memorable_recall | if type == "object" then . else {} end) + {($key): "allow"})' "$POLICY_FILE" > "$tmp"
else
jq --arg key "$key" "$DROP_MEMORABLE_OPTIN" "$POLICY_FILE" > "$tmp"
fi
mv "$tmp" "$POLICY_FILE"
chmod 0600 "$POLICY_FILE"
echo "Set $key memorable recall → $value"
if [ "$value" = "allow" ] && [ "$(cmd_memorable_get "$url" | cut -f2)" != "allow" ]; then
echo "gstack-gbrain-repo-policy: $LEGACY_POLICY_FILE also makes $key read-only and does not opt it in, so recall stays off. Opt in there too: GSTACK_STATE_ROOT=$_gstack_legacy_root gstack-gbrain-repo-policy memorable set $key allow" >&2
fi
}
cmd_memorable() {
case "${1:-}" in
get) shift; cmd_memorable_get "$@" ;;
set) shift; cmd_memorable_set "$@" ;;
*) die "usage: memorable {get [<remote-url>]|set <remote-url> <allow|off>}" ;;
esac
}
cmd_list() {
@@ -310,7 +481,9 @@ cmd_list() {
return 0
fi
ensure_file
jq -r 'to_entries[] | select(.key != "_schema_version") | "\(.key)\t\(.value)"' "$POLICY_FILE" | sort
jq -r '(._memorable_recall | if type == "object" then . else {} end) as $m
| to_entries[] | select(.key != "_schema_version" and .key != "_memorable_recall")
| "\(.key)\t\(.value)" + (if $m[.key] == "allow" then "\tmemorable=allow" else "" end)' "$POLICY_FILE" | sort
}
cmd_normalize() {
@@ -323,8 +496,9 @@ case "${1:-}" in
get) shift; cmd_get "$@" ;;
set) shift; cmd_set "$@" ;;
list) shift; cmd_list "$@" ;;
memorable) shift; cmd_memorable "$@" ;;
normalize) shift; cmd_normalize "$@" ;;
--help|-h|help) sed -n '2,54p' "$0" | sed 's/^# \{0,1\}//' ;;
"") die "usage: gstack-gbrain-repo-policy {get|set|list|normalize|--help}" ;;
--help|-h|help) sed -n '2,73p' "$0" | sed 's/^# \{0,1\}//' ;;
"") die "usage: gstack-gbrain-repo-policy {get|set|memorable|list|normalize|--help}" ;;
*) die "unknown subcommand: $1" ;;
esac
+13 -7
View File
@@ -1225,6 +1225,18 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
return skipStageForLocalStatus("code", localStatus, t0);
}
// #1734: refuse on an active autopilot BEFORE any source-metadata change
// (Step 0b rename, Step 1 `sources add`), so a refused stage leaves the
// brain's source list untouched. The check right before the destructive walk
// below stays: autopilot can start while the steps in between run.
const refusedAutopilot = (signal: string | null): StageResult => ({
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
summary: `refused: gbrain autopilot active (${signal}). Stop autopilot, then re-run /sync-gbrain.`,
detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" },
});
const apEarly = detectAutopilot(gbrainEnv);
if (apEarly.active) return refusedAutopilot(apEarly.signal);
// Step 0a: Best-effort cleanup of pre-pathhash legacy source (v1.x form).
// Earlier /sync-gbrain versions registered `gstack-code-<slug>` (no path
// suffix). On a multi-worktree repo, those collapsed onto a single id
@@ -1308,13 +1320,7 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
// --allow-reclone. Both surface a visible reason and fail the stage so the
// verdict shows ERR rather than silently skipping protection.
const apBeforeWalk = detectAutopilot(gbrainEnv);
if (apBeforeWalk.active) {
return {
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
summary: `refused: gbrain autopilot active (${apBeforeWalk.signal}). Stop autopilot, then re-run /sync-gbrain.`,
detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" },
};
}
if (apBeforeWalk.active) return refusedAutopilot(apBeforeWalk.signal);
const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone);
if (!reclone.allow) {
return {
+15
View File
@@ -186,11 +186,26 @@ function gitProject(cwd: string): GitProject | null {
const up = prefix.split("/").filter(Boolean).map(() => "..");
project = { root: resolve(cwd, ...up), main: basename(common) === ".git" ? dirname(common) : common };
}
} else {
// A merged branch's worktree is removed but its session history stays:
// count those sessions with the repository the worktree was created from.
const parent = parentRepoOfClaudeWorktree(cwd);
if (parent) project = gitProject(parent);
}
PROJECT_MEMO.set(cwd, project);
return project;
}
/**
* Claude Code creates worktrees at `<repo>/.claude/worktrees/<name>`. Given a
* cwd inside one (the worktree root or any subdirectory), the repository root
* it was created from; null for any other path.
*/
export function parentRepoOfClaudeWorktree(cwd: string): string | null {
const m = cwd.match(/^(.+?)[\\/]\.claude[\\/]worktrees[\\/][^\\/]+(?:[\\/].*)?$/);
return m ? m[1] : null;
}
function getGitRemote(root: string): string | null {
const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], {
encoding: "utf-8",
+6 -3
View File
@@ -29,7 +29,7 @@ import * as path from 'node:path';
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
import { buildContext, describeRefusal } from '../lib/pregate/context';
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
@@ -140,13 +140,14 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
if (stallTimeoutMs !== undefined && (!Number.isFinite(stallTimeoutMs) || stallTimeoutMs < 0)) { process.stderr.write('gstack-pregate: --stall-timeout needs a number of seconds (0 disables)\n'); return EXIT.usage; }
let remote: { spendCapUsd: number } | null = null;
if (rest.includes('--remote')) {
if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the base branch\'s remote_gate and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; }
if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the active policy\'s remote_gate (the base branch\'s copy, or the user-owned file when the base carries none) and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; }
const cap = Number(flag(rest, '--spend-cap') ?? process.env.GSTACK_SPEND_CAP_USD ?? NaN);
if (!Number.isFinite(cap) || cap <= 0) { process.stderr.write('gstack-pregate: --remote spends money; pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; }
remote = { spendCapUsd: cap };
} else if (flag(rest, '--spend-cap') !== undefined) { process.stderr.write('gstack-pregate: --spend-cap applies only with --remote\n'); return EXIT.usage; }
const base = resolveBaseBranch(root, flag(rest, '--base'));
const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2] });
const userConfigPath = defaultUserConfigPath(root);
const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2], userConfigPath });
if ('refused' in loaded) {
const refusal = describeRefusal(loaded.refused, base);
process.stderr.write(refusal.line + '\n');
@@ -281,6 +282,8 @@ async function main(argv: string[]): Promise<number> {
if (bad) { process.stderr.write(`gstack-pregate: ${bad}; see gstack-pregate --help\n`); return EXIT.usage; }
const root = repoRoot();
if (!root) { process.stderr.write('gstack-pregate: not inside a git repository\n'); return EXIT.usage; }
const dotenv = loadedDotenvFiles(process.cwd(), process.execArgv);
if (dotenv.length) { process.stderr.write(resultLine('gstack-pregate', 'POLICY_SOURCE_UNAVAILABLE', `bun loaded ${dotenv.join(', ')} from the working directory, and a checkout's .env must not choose the state root or slug behind the policy; run gstack-pregate directly (its shebang passes --no-env-file) or as bun --no-env-file`) + '\n'); return EXIT.refused; }
if (cmd === 'run') return await cmdRun(root, rest, json);
if (cmd === 'verify') return cmdVerify(root, rest, json);
if (cmd === 'clear') return cmdClear(root, rest);
+9 -3
View File
@@ -3,6 +3,10 @@
# Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}'
#
# Before reading a diff: gstack-review-log --start review
# Fix-range repeat (#3339): gstack-review-log --start review --fix-range
# prints the token, then FIX_BASE=<sha>; refuses (exit 1) unless the latest
# pass completed with fixes on unchanged content and its commit is an
# ancestor of HEAD. Its row is never CURRENT; a later full pass must follow.
# After that pass: gstack-review-log '{...,"completed":true,"converged":true}' --finish TOKEN
# With a findings file: gstack-review-log '{...}' --findings findings.jsonl
# status/unresolved/issues_found/critical_gaps are DERIVED from the file (B7);
@@ -72,9 +76,11 @@ fi
SESSION_KIND=$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive)
export GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" GSTACK_STAMP_SESSION_KIND="$SESSION_KIND"
if [ "$INPUT" = --start ]; then
GSTACK_REVIEW_SKILL="${2:-}" bun --no-env-file --config=/dev/null -e '
case "${3:-}" in ''|--fix-range) ;; *) echo 'Usage: gstack-review-log --start SKILL [--fix-range]' >&2; exit 2 ;; esac
GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config=/dev/null -e '
const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB);
console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL));
try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); }
catch (e) { console.error(e.message); process.exit(1); }
'
exit $?
fi
@@ -86,7 +92,7 @@ if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then
exit $?
fi
usage() {
echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL | --check-shared-libs TOKEN < finding.json' >&2
echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] | --check-shared-libs TOKEN < finding.json' >&2
echo 'Exit codes: 0 written · 1 rejected · 2 usage' >&2
exit 2
}
+11
View File
@@ -53,6 +53,7 @@ LOG_FILE="$STATE_DIR/analytics/session-update.log"
PENDING_FILE="$STATE_DIR/session-update-pending"
HOOK_CHECK="$(cd "$(dirname "$0")" && pwd)/gstack-hook-check"
FF_UPDATE="$(cd "$(dirname "$0")" && pwd)/gstack-ff-update"
LEASE="$(cd "$(dirname "$0")" && pwd)/gstack-lease"
RESCUE_FILE="$STATE_DIR/session-update-rescues"
# Path spelling (Git Bash /c/x vs native C:/x) for the held-reason prefix strip.
. "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" 2>/dev/null || { gstack_path_shell() { printf '%s\n' "$1"; }; gstack_path_native() { printf '%s\n' "$1"; }; }
@@ -362,6 +363,16 @@ fi
) &
HB_PID=$!
# ── Workflow leases (#3363): a live gstack session on this installation
# (bin/gstack-lease) means neither the fast-forward nor a pending setup
# retry runs under it, the rule bin/gstack-upgrade enforces as UPGRADE_BUSY.
# The throttle file is not written, so the next session start tries again.
if [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$LEASE" ] \
&& BUSY=$("$LEASE" live --root "$GSTACK_DIR" --exclude-ancestry-of $$ 2>/dev/null); then
log_entry "SKIP leased $(printf '%s\n' "$BUSY" | awk 'NF { printf "%spid=%s/%s", n++ ? " " : "", $1, $2 }')"
exit 0
fi
# ── Fetch, check the incoming release's Bun floor, then fast-forward (E1) ──
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "")
+15 -3
View File
@@ -322,6 +322,7 @@ echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE"
# GSTACK_SESSION_KIND=spawned override must light this up too.
[ "$_SESSION_KIND" = "spawned" ] && echo "SPAWNED_SESSION: true" || true
[ "$_SESSION_KIND" = "unattended" ] && echo "UNATTENDED_SESSION: true" || true
[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md" || true
# ---------------------------------------------------------------------------
# Artifacts sync (the former "Artifacts Sync (skill start)" fence, verbatim
@@ -604,7 +605,7 @@ Key routing rules:
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test)
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
@@ -639,10 +640,21 @@ fi # end interactive-onboarding guard (kind != spawned/unattended)
# final gate is written and never approved, nothing is published or synced.
# It also carries the host adaptation a harness without a Skill tool, slash
# commands or AskUserQuestion needs, printed here so every host's render has it
# without growing any skill's prose.
# without growing any skill's prose. The record sentence is per skill: only
# skills with steps that write GSTACK_RESULT are told to end with it, and the
# rest name the record the parent reads instead (docs/unattended.md, #3281).
if [ "$_SESSION_KIND" = "unattended" ]; then
_UA_NONE="This skill writes no run directory and prints no GSTACK_RESULT line, so do not invent either; end at its first pending item and list that item, with every recommended option you took, in your final report."
case "$SKILL_NAME" in
autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>\`." ;;
eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>\`, where <outdir> is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in <outdir>." ;;
ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt from .gstack/tmp/ship-receipt.md when nothing was pushed, and from the PR body (\`gstack-ship-receipt read --pr <n> --json\`) only once a PR exists." ;;
review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;;
qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;;
*) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;;
esac
_emit_block unattended-session <<EOI
You are running unattended: a parent agent started this run and will read its artifacts (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json; see docs/unattended.md), not your prose. Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended) and append it to decisions.jsonl with kind "auto". A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate are appended with kind "approval" and status "pending": never choose them. The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=<name> status=gate_pending run=<dir>\`; you never approve it, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
EOI
fi
+3 -1
View File
@@ -5,6 +5,8 @@
# Reads all *-reviews.jsonl files across branches, parses specialist fields,
# and outputs hit rates. Tags specialists as GATE_CANDIDATE (0 findings in 10+
# dispatches) or NEVER_GATE (security, data-migration, state-machines — insurance policy).
# Fix-range passes (scope "fix-range", #3339) are skipped: a narrowed dispatch
# says nothing about a specialist's hit rate on a whole branch.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
@@ -40,7 +42,7 @@ let reviewed = 0;
for (const line of lines) {
try {
const e = JSON.parse(line);
if (!e.specialists) continue;
if (!e.specialists || e.scope === 'fix-range') continue;
reviewed++;
for (const [name, info] of Object.entries(e.specialists)) {
if (!stats[name]) stats[name] = { dispatched: 0, findings: 0 };
+26 -8
View File
@@ -102,7 +102,13 @@ Do not skip skills, ignore gstack errors, or work around missing gstack.
Using gstack skills: After install, skills like /qa, /ship, /review, /investigate,
and /browse are available. Use /browse for all web browsing (Aside first, the bundled gstack browser as fallback).
Use the resolved install path above for gstack file paths
(default: ~/.claude/skills/gstack).'
(default: ~/.claude/skills/gstack).
The Skill gate in .claude/settings.json runs .claude/hooks/check-gstack.cjs with
Node.js and blocks when it cannot run. If a skill is blocked with "node: not
found", tell the user to install Node.js (https://nodejs.org); if it is blocked
with "Cannot find module ... check-gstack.cjs", the hook file is missing from
this checkout. Do not work around either block.'
fi
# Check if CLAUDE.md already has a gstack section
@@ -176,15 +182,20 @@ HOOK_EOF
GENERATED+=(".claude/hooks/check-gstack.cjs")
echo " + .claude/hooks/check-gstack.cjs — enforcement hook"
# Add hook to project-level settings.json
# Add hook to project-level settings.json. The command exits 0 (allow) or
# 2 (deny) only when node runs check-gstack.cjs. Without node the shell exits
# 127, and a require error exits 1; Claude Code treats any other exit as a
# non-blocking error and lets the Skill call through, so the handler is
# registered with onFailure: block (Claude Code 2.1.295+) to fail closed
# (#3319). Older Claude Code ignores the field and keeps the old behavior.
HOOK_COMMAND="node -e \"require(require('path').resolve(process.env.CLAUDE_PROJECT_DIR || '.', '.claude', 'hooks', 'check-gstack.cjs'))\""
if ! command -v bun >/dev/null 2>&1; then
echo " ! bun not found — manually add this PreToolUse hook (matcher Skill|skill) to .claude/settings.json:"
echo " ! bun not found — manually add this PreToolUse hook (matcher Skill|skill, \"onFailure\": \"block\") to .claude/settings.json:"
echo " $HOOK_COMMAND"
elif GSTACK_SETTINGS_PATH="$SETTINGS" GSTACK_HOOK_COMMAND="$HOOK_COMMAND" bun --no-env-file --config=/dev/null -e "
const fs = require('fs');
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
const command = process.env.GSTACK_HOOK_COMMAND;
const handler = { type: 'command', command: process.env.GSTACK_HOOK_COMMAND, onFailure: 'block' };
let settings = {};
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
@@ -196,7 +207,8 @@ HOOK_EOF
// Claude tool name maps to, so a bare 'Skill' never fired there (#2229).
// A registration an older team-init wrote (the .sh hook, matcher Skill)
// is replaced in place: exactly one check-gstack handler remains, and
// unrelated hooks in the same entry are kept.
// unrelated hooks in the same entry are kept. Re-running upgrades a
// handler registered without onFailure.
const MATCHER = 'Skill|skill';
const isOurs = h => h && typeof h.command === 'string' && h.command.includes('check-gstack');
let placed = false;
@@ -207,12 +219,12 @@ HOOK_EOF
const kept = [];
for (const h of entry.hooks) {
if (!isOurs(h)) kept.push(h);
else if (!placed) { kept.push({ type: 'command', command }); placed = true; }
else if (!placed) { kept.push({ ...handler }); placed = true; }
}
entry.hooks = kept;
return kept.length > 0;
});
if (!placed) settings.hooks.PreToolUse.push({ matcher: MATCHER, hooks: [{ type: 'command', command }] });
if (!placed) settings.hooks.PreToolUse.push({ matcher: MATCHER, hooks: [{ ...handler }] });
const tmp = settingsPath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
@@ -229,7 +241,7 @@ HOOK_EOF
echo " - .claude/hooks/check-gstack.sh — replaced by check-gstack.cjs"
fi
else
echo " ! could not update .claude/settings.json — add this PreToolUse hook (matcher Skill|skill) manually:"
echo " ! could not update .claude/settings.json — add this PreToolUse hook (matcher Skill|skill, \"onFailure\": \"block\") manually:"
echo " $HOOK_COMMAND"
fi
fi
@@ -248,3 +260,9 @@ echo ""
echo "Each developer then runs:"
echo " git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack"
echo " cd ~/.claude/skills/gstack && ./setup --team"
if [ "$MODE" = "required" ]; then
echo ""
echo "The Skill gate runs on Node.js and blocks when it cannot run, so every"
echo "developer also needs node on PATH and Claude Code 2.1.295 or later."
echo "Older Claude Code ignores onFailure: without node there, skills are not blocked."
fi
+50 -1
View File
@@ -25,8 +25,16 @@
// the caller must handle), exit 2 on bad args / unresolvable base /
// broken version source.
//
// write --version <X.Y.Z.W> [--version-path <p>] [--regen-digest]
// write --version <X.Y.Z.W> [--version-path <p>] [--regen-digest] [--changelog <p>]
// Validates the 4-digit pattern, writes VERSION + package.json.version.
// --changelog <p> (opt-in): re-head the branch's `## [Unreleased]`
// entry in <p> as `## [<version>] - <UTC date>` at the top, with
// lib/changelog-check's reheadChangelog — the same function
// gstack-restamp uses, so a later restamp finds the heading already in
// place (or moves it again as the branch's own stamp). No [Unreleased]
// section, or an absent file, is a no-op. Duplicate headings refuse with
// exit 2 before anything is written. restamp never passes the flag: under
// stamp-at-merge it stays the only owner of the re-heading.
// --regen-digest (opt-in): additionally run the target repo's own
// scripts/gen-agents-digest.ts when present, so the version-stamped
// committed digest stays freshness-green through the bump. Executes
@@ -67,6 +75,7 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { bunIsolationArgs } from "../lib/bun-isolation";
import { checkChangelog, parseChangelog, reheadChangelog } from "../lib/changelog-check";
import {
extractVersion,
isJsonVersionPath,
@@ -404,6 +413,42 @@ function regenAgentsDigest(versionPath: string, optedIn: boolean): boolean | nul
}
}
interface ChangelogPlan { rel: string; text: string | null; detail: string }
/**
* --changelog: computed before any write, so a CHANGELOG the re-heading
* cannot handle (duplicate headings) refuses with nothing written. Only an
* existing [Unreleased] section moves; released sections are never renamed.
*/
function planChangelog(cwd: string, args: string[], version: string): ChangelogPlan | null {
if (!args.includes("--changelog")) return null;
const rel = argVal(args, "--changelog");
if (!rel || rel.startsWith("--")) fail("write: --changelog needs a path (for example --changelog CHANGELOG.md)", 2);
assertRepoContained(cwd, rel, "--changelog");
const p = join(cwd, rel);
if (!existsSync(p)) return { rel, text: null, detail: `${rel} absent` };
const text = readFileSync(p, "utf-8");
if (!parseChangelog(text).sections.some((s) => s.unreleased)) return { rel, text: null, detail: "no [Unreleased] section" };
const r = reheadChangelog(text, { from: null, to: version, date: new Date().toISOString().slice(0, 10), file: rel });
if (r.error) fail(`write: ${r.error.path}: ${r.error.message}. Nothing written.`, 2);
if (!r.moved) return { rel, text: null, detail: `already headed ${version}` };
const errors = checkChangelog(r.text, version, rel);
if (errors.length) fail(`write: ${errors[0]!.path}: ${errors[0]!.message}. Nothing written.`, 2);
return { rel, text: r.text, detail: `Unreleased -> ${version}` };
}
function writeChangelog(cwd: string, plan: ChangelogPlan | null): { changelog: { path: string; moved: boolean; detail: string } } | Record<string, never> {
if (!plan) return {};
if (plan.text !== null) {
try {
writeFileSync(join(cwd, plan.rel), plan.text);
} catch {
fail(`write: the version files were written but ${plan.rel} was not. Re-run write with the same --version and --changelog.`, 3);
}
}
return { changelog: { path: plan.rel, moved: plan.text !== null, detail: plan.detail } };
}
function cmdWrite(args: string[], cwd: string): void {
const version = argVal(args, "--version");
if (!version) fail("write requires --version <X.Y.Z.W>", 2);
@@ -431,6 +476,7 @@ function cmdWrite(args: string[], cwd: string): void {
// npm rejects forever — with no drift state to catch it (a JSON source is
// self-consistent by construction).
const jsonV = npmVersion(version!);
const changelogPlan = planChangelog(cwd, args, jsonV);
let manifestWritten = false;
let lockSynced: string[] = [];
try {
@@ -466,6 +512,7 @@ function cmdWrite(args: string[], cwd: string): void {
// here: a JSON-manifest version source implies no plain VERSION file
// for the digest generator to stamp.
agentsDigest: null,
...writeChangelog(cwd, changelogPlan),
}) + "\n",
);
return;
@@ -473,6 +520,7 @@ function cmdWrite(args: string[], cwd: string): void {
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
const hasPkg = existsSync(pkgPath);
const changelogPlan = planChangelog(cwd, args, version!);
writeFileSync(versionPath, version + "\n");
const digestRegen = regenAgentsDigest(versionPath, args.includes("--regen-digest"));
let lockSynced: string[] = [];
@@ -511,6 +559,7 @@ function cmdWrite(args: string[], cwd: string): void {
packageLock: lockSynced.length > 0,
// null = not the gstack repo (no digest to regen); true/false = regen outcome
agentsDigest: digestRegen,
...writeChangelog(cwd, changelogPlan),
}) + "\n",
);
}
+38
View File
@@ -33,3 +33,41 @@ export function emptyBunConfig(platform: NodeJS.Platform = process.platform): st
emptyConfig = file;
return file;
}
export interface BunExeLookup {
env?: NodeJS.ProcessEnv;
execPath?: string;
isBun?: boolean;
isFile?: (p: string) => boolean;
}
/**
* The bun.exe a Windows parent spawns its bun children with, or null when
* none is installed: the running bun.exe itself, then BUN_INSTALL\bin, each
* absolute PATH entry (plus, beside npm's bun.cmd shim, the bun.exe it
* forwards to), then USERPROFILE\.bun\bin. The terminal agent and the native
* cookie-import worker (cookie-import-native.ts) both resolve through it; it
* lives here because this file is part of the native import's source-bound
* qualification (NATIVE_CODE_INPUTS), and terminal-agent-control.ts is not.
*/
export function findWindowsBunExe(opts: BunExeLookup = {}): string | null {
const win = path.win32;
const env = opts.env ?? process.env;
const execPath = opts.execPath ?? process.execPath;
const isBun = opts.isBun ?? !!process.versions.bun;
const isFile = opts.isFile ?? ((p: string) => { try { return fs.statSync(p).isFile(); } catch { return false; } });
// A compiled browse.exe also reports versions.bun, so check the name too.
if (isBun && /^bun\.exe$/i.test(win.basename(execPath))) return execPath;
const dirs = (env.PATH || env.Path || '').split(';').filter(d => d && win.isAbsolute(d));
const candidates = [
...(env.BUN_INSTALL ? [win.join(env.BUN_INSTALL, 'bin', 'bun.exe')] : []),
...dirs.flatMap(d => [
win.join(d, 'bun.exe'),
// npm global install: PATH holds only the bun.cmd shim; the real
// binary it forwards to lives under node_modules.
...(isFile(win.join(d, 'bun.cmd')) ? [win.join(d, 'node_modules', 'bun', 'bin', 'bun.exe')] : []),
]),
...(env.USERPROFILE ? [win.join(env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []),
];
return candidates.find(isFile) ?? null;
}
+2 -9
View File
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
import { release } from 'node:os';
import path from 'node:path';
import { CookieImportError, normalizeCookieDomain, type PlaywrightCookie } from './cookie-import-browser';
import { emptyBunConfig } from './bun-child-config';
import { emptyBunConfig, findWindowsBunExe } from './bun-child-config';
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity';
type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge';
@@ -107,14 +107,7 @@ export async function importNativeCookies(options: {
} catch (error) {
throw new CookieImportError('The native browser build could not be checked safely. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified');
}
const bunCandidates = [
...(process.env.BUN_INSTALL ? [path.join(process.env.BUN_INSTALL, 'bin', 'bun.exe')] : []),
...(process.env.PATH || process.env.Path || '').split(path.delimiter).filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, 'bun.exe')),
...(process.env.USERPROFILE ? [path.join(process.env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []),
];
const bunExecutable = bunCandidates.find(candidate => {
try { return statSync(candidate).isFile(); } catch { return false; }
});
const bunExecutable = findWindowsBunExe();
if (!bunExecutable || !qualified.some(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion)) {
throw new CookieImportError('This browser build or supervisor runtime has not passed native qualification. Sign in manually in the gstack browser.', 'native_unqualified');
}
+18 -2
View File
@@ -22,7 +22,7 @@ import { safeUnlink, isProcessAlive } from './error-handling';
import { restrictFilePermissions, mkdirSecure } from './file-permissions';
import { atomicWriteSync } from '../../lib/fs-atomic';
import { readPidCmdline, readPidStartTime } from './xvfb';
import { emptyBunConfig } from './bun-child-config';
import { emptyBunConfig, findWindowsBunExe, type BunExeLookup } from './bun-child-config';
function agentProcessInfo(pid: number): { startTime: string; commandLine: string } {
if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' };
@@ -149,6 +149,22 @@ export function bunChildFlags(): string[] {
return ['--no-env-file', `--config=${emptyBunConfig()}`];
}
/**
* The bun executable to spawn the agent with. On Windows a bare 'bun' under
* the Node polyfill resolves (via cross-spawn) to npm's bun.cmd shim, run as
* `cmd.exe /c bun ...`. That breaks two ways: windowsHide only hides cmd.exe,
* and the detached cmd.exe has no console, so bun.exe allocates a fresh
* visible one (a window that steals focus on every spawn); and proc.pid is
* cmd.exe's, so the agent's startup check (record.pid === process.pid) never
* matches and it exits after 2s, which the 60s watchdog then respawns.
* Spawning bun.exe directly fixes both. Falls back to 'bun' when no .exe is
* found, which is the old behavior.
*/
export function resolveBunExecutable(opts: BunExeLookup & { platform?: string } = {}): string {
if ((opts.platform ?? process.platform) !== 'win32') return 'bun';
return findWindowsBunExe(opts) ?? 'bun';
}
export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null {
const meta = searchHints.metaDir || __dirname;
const exec = searchHints.execPath || process.execPath;
@@ -208,7 +224,7 @@ export function spawnTerminalAgent(opts: {
writeAgentRecord(stateDir, reservation);
let proc: any;
try {
proc = (Bun as any).spawn(['bun', 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], {
proc = (Bun as any).spawn([resolveBunExecutable(), 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], {
cwd: opts.cwd || process.cwd(),
env: {
...process.env,
+1 -1
View File
@@ -233,7 +233,7 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => {
'utf-8',
);
expect(CONTROL_SRC).toContain('terminal-agent.ts');
expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/);
expect(CONTROL_SRC).toMatch(/\.spawn\(\[resolveBunExecutable\(\),\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/);
expect(CONTROL_SRC).toContain('BROWSE_OWNER_PID: String(opts.ownerPid)');
});
});
@@ -0,0 +1,85 @@
/**
* Windows terminal-agent console flash: under the Node polyfill a bare 'bun'
* resolved to npm's bun.cmd shim and ran as `cmd.exe /c bun ...`. The detached
* cmd.exe had no console, so bun.exe opened a visible one on every spawn, and
* proc.pid was cmd.exe's, so the agent failed its startup record check and
* exited, and the 60s watchdog respawned it. spawnTerminalAgent now spawns
* bun.exe directly.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { findWindowsBunExe } from '../src/bun-child-config';
import { resolveBunExecutable } from '../src/terminal-agent-control';
const files = (...paths: string[]) => (p: string) => paths.some(f => f.toLowerCase() === p.toLowerCase());
describe('resolveBunExecutable', () => {
test('POSIX keeps bare bun', () => {
expect(resolveBunExecutable({ platform: 'linux', isFile: () => true })).toBe('bun');
expect(resolveBunExecutable({ platform: 'darwin', isFile: () => true })).toBe('bun');
});
test('Windows under bun.exe reuses its own executable', () => {
expect(resolveBunExecutable({
platform: 'win32', isBun: true, execPath: 'C:\\Tools\\bun.exe', env: {}, isFile: () => false,
})).toBe('C:\\Tools\\bun.exe');
});
test('a compiled browse.exe is not mistaken for bun', () => {
expect(resolveBunExecutable({
platform: 'win32', isBun: true, execPath: 'C:\\gstack\\browse\\dist\\browse.exe',
env: { PATH: 'C:\\bun\\bin' }, isFile: files('C:\\bun\\bin\\bun.exe'),
})).toBe('C:\\bun\\bin\\bun.exe');
});
test('npm global install resolves through the bun.cmd shim to the real bun.exe', () => {
const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm';
expect(resolveBunExecutable({
platform: 'win32', isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe',
env: { PATH: `C:\\Windows\\System32;${npm}` },
isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`),
})).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`);
});
test('BUN_INSTALL wins over PATH, and USERPROFILE\\.bun is the last resort', () => {
const isFile = files('C:\\bi\\bin\\bun.exe', 'C:\\p\\bun.exe', 'C:\\Users\\u\\.bun\\bin\\bun.exe');
const base = { platform: 'win32', isBun: false, execPath: 'node.exe', isFile };
expect(resolveBunExecutable({ ...base, env: { BUN_INSTALL: 'C:\\bi', PATH: 'C:\\p' } })).toBe('C:\\bi\\bin\\bun.exe');
expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\p' } })).toBe('C:\\p\\bun.exe');
expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\none', USERPROFILE: 'C:\\Users\\u' } })).toBe('C:\\Users\\u\\.bun\\bin\\bun.exe');
});
test('falls back to bare bun when no bun.exe exists', () => {
expect(resolveBunExecutable({
platform: 'win32', isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false,
})).toBe('bun');
});
test('spawnTerminalAgent spawns through the resolver, not a bare bun', () => {
const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/terminal-agent-control.ts'), 'utf8');
const spawnSite = source.slice(source.indexOf('export function spawnTerminalAgent'));
expect(spawnSite).toContain('spawn([resolveBunExecutable(), \'run\'');
expect(spawnSite).not.toMatch(/spawn\(\[\s*'bun'/);
});
});
describe('findWindowsBunExe is the one bun.exe lookup', () => {
test('returns null, not a bare bun, when no bun.exe is installed', () => {
expect(findWindowsBunExe({ isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false })).toBeNull();
});
test('the native cookie-import worker finds an npm-global bun.exe through the same lookup', () => {
const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm';
expect(findWindowsBunExe({
isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe', env: { PATH: npm },
isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`),
})).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`);
});
test('cookie-import-native has no bun.exe candidate list of its own', () => {
const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/cookie-import-native.ts'), 'utf8');
expect(source).toContain('const bunExecutable = findWindowsBunExe();');
expect(source).not.toContain("'bun.exe'");
});
});
+27 -10
View File
@@ -224,6 +224,23 @@ esac
WARN=""
PATTERN=""
# Mask quoted -m/-am/--message argument content in git commit commands so that
# descriptive commit messages do not trigger false positives (#1060). The
# quote-aware scanner lives in mask-commit-message.awk beside this hook: as a
# heredoc inside a command substitution, its quote characters are mis-parsed by
# bash 3.2 (macOS /bin/bash), which made this whole hook a syntax error there.
# A missing program or an awk failure leaves the command unmasked.
CMD_MEDIUM="$CMD"
CMD_MEDIUM_LOWER="$CMD_LOWER"
_MASK_AWK="$_HOOK_DIR/mask-commit-message.awk"
case "$CMD" in *git*commit*) _MAYBE_COMMIT=1 ;; *) _MAYBE_COMMIT=0 ;; esac
if [ "$_MAYBE_COMMIT" -eq 1 ] && [ -f "$_MASK_AWK" ]; then
if _cmd_masked=$(printf '%s' "$CMD" | awk -f "$_MASK_AWK" 2>/dev/null); then
CMD_MEDIUM="$_cmd_masked"
CMD_MEDIUM_LOWER=$(printf '%s' "$CMD_MEDIUM" | tr '[:upper:]' '[:lower:]')
fi
fi
# --- PowerShell and cmd table (#3067) ---
# Runs on the PowerShell tool's command, and on the part of any command from a
# nested `powershell`/`pwsh`/`cmd` launcher onward (`pwsh -c "..."`,
@@ -303,50 +320,50 @@ if [ -n "$_WIN_SCAN" ]; then
fi
# rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive)
if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: recursive delete (rm -r). This permanently removes files."
PATTERN="rm_recursive"
fi
# DROP TABLE / DROP DATABASE
if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_LOWER" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
WARN="Destructive: SQL DROP detected. This permanently deletes database objects."
PATTERN="drop_table"
fi
# TRUNCATE
if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_LOWER" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
WARN="Destructive: SQL TRUNCATE detected. This deletes all rows from a table."
PATTERN="truncate"
fi
# git push --force / git push -f / plus-refspec force (git push origin +ref)
if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD" 2>/dev/null \
&& grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD_MEDIUM" 2>/dev/null \
&& grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: git force-push rewrites remote history. Other contributors may lose work."
PATTERN="git_force_push"
fi
# git reset --hard
if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: git reset --hard discards all uncommitted changes."
PATTERN="git_reset_hard"
fi
# git checkout . / git restore .
if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: discards all uncommitted changes in the working tree."
PATTERN="git_discard"
fi
# kubectl delete
if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: kubectl delete removes Kubernetes resources. May impact production."
PATTERN="kubectl_delete"
fi
# docker rm -f / docker system prune
if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: Docker force-remove or prune. May delete running containers or cached images."
PATTERN="docker_destructive"
fi
@@ -386,7 +403,7 @@ $_GSTACK_HOME_DIR/projects/$SLUG/careful-patterns.txt"
_PAT_RC=0
printf '' | grep -qE -- "$_PAT" 2>/dev/null || _PAT_RC=$?
[ "$_PAT_RC" -eq 2 ] && continue # invalid ERE — skip the line
if grep -qE -- "$_PAT" <<< "$CMD" 2>/dev/null; then
if grep -qE -- "$_PAT" <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Project rule matched: $_PAT"
PATTERN="project_rule"
break
+125
View File
@@ -0,0 +1,125 @@
# mask-commit-message.awk — read by careful/bin/check-careful.sh (#1060).
# Prints the command with the quoted message argument of git commit
# (-m, -am, --message) replaced by an empty quoted string. The command is
# scanned as one string: newlines are kept, since bash runs each line as its
# own command, and quote state carries across lines.
#
# Only git commit's own arguments are masked: the scanner splits the command
# at unquoted ; & | ( ) and newlines, and masks a message flag only in a
# segment whose first word is git and that has a commit word after it. The &
# of a redirection (2>&1, &>) does not split. Any pipe after a masked message
# prints the command unchanged, because git prints the message on stdout.
# Quotes, backslashes and comments follow bash: a backslash escapes the next
# character outside single quotes, and nothing inside single quotes; an
# unquoted # starting a word runs to the end of its line. An unterminated
# quote or escape prints the command unchanged.
#
# A message written as "$(cat <<'EOF' ... EOF )" (a quoted delimiter, so the
# body is never expanded) is masked whole. Any other command, parameter or
# process substitution, backtick, $'...' string or heredoc changes how bash
# reads the quotes after it, so the command is printed unchanged.
# heredoc_msg S N I — S[I] opens a double-quoted "$(cat <<'DELIM' ... DELIM )"
# message: the index after its closing quote, else 0.
function heredoc_msg(s, n, i, p, rest, strip, delim, e, line) {
rest = substr(s, i + 1)
if (!match(rest, /^\$\(cat[ \t]+<<-?[ \t]*/)) return 0
strip = substr(rest, 1, RLENGTH) ~ /<<-/
p = i + 1 + RLENGTH
rest = substr(s, p)
if (match(rest, /^'[A-Za-z_][A-Za-z0-9_]*'/) || match(rest, /^"[A-Za-z_][A-Za-z0-9_]*"/)) delim = substr(rest, 2, RLENGTH - 2)
else if (match(rest, /^\\[A-Za-z_][A-Za-z0-9_]*/)) delim = substr(rest, 2, RLENGTH - 1)
else return 0
p += RLENGTH
while (p <= n && substr(s, p, 1) ~ /[ \t]/) p++
if (substr(s, p, 1) != "\n") return 0
p++
for (;;) {
if (p > n) return 0
e = index(substr(s, p), "\n")
if (!e) return 0
line = substr(s, p, e - 1)
if (strip) sub(/^\t+/, "", line)
p += e
if (line == delim) break
}
while (p <= n && substr(s, p, 1) ~ /[ \t\n]/) p++
if (substr(s, p, 2) != ")\"") return 0
return p + 2
}
function handle_msg(s, n, i, j, c, safe) {
if (i > n) return i
if (substr(s, i, 1) == "'") {
j = i + 1
while (j <= n && substr(s, j, 1) != "'") j++
if (j > n) return i
result = result "''"
return j + 1
}
if (substr(s, i, 1) == "\"") {
if ((j = heredoc_msg(s, n, i)) > 0) { result = result "\"\""; return j }
j = i + 1; safe = 1
while (j <= n && (c = substr(s, j, 1)) != "\"") {
if (c == "$" || c == "`" || c == "\\") safe = 0
j++
}
if (!safe || j > n) return i
result = result "\"\""
return j + 1
}
return i
}
# flag S I — length of the message flag (with its trailing blanks) at S[I], or 0
function flag(s, i, rest, m) {
rest = substr(s, i)
if (rest ~ /^--message=/) return 10
if (match(rest, /^(--message|-am|-m)[ \t]+/)) return RLENGTH
return 0
}
{ cmd = (NR > 1) ? cmd "\n" $0 : $0 }
END {
s = cmd
n = length(s); sq = 0; dq = 0; bs = 0; i = 1
atword = 1; words = 0; git = 0; commit = 0; masked = 0; piped = 0; unsure = 0
while (i <= n) {
c = substr(s, i, 1)
if (bs) { result = result c; bs = 0; i++; continue }
if (sq) { if (c == "'") sq = 0; result = result c; i++; continue }
two = substr(s, i, 2)
if (c == "`" || two == "$(" || two == "${") unsure = 1
if (dq) {
if (c == "\\") bs = 1
else if (c == "\"") dq = 0
result = result c; i++; continue
}
if (two == "<<" || two == "$'" || two == "<(" || two == ">(") unsure = 1
if (atword && c == "#") {
while (i <= n && substr(s, i, 1) != "\n") { result = result substr(s, i, 1); i++ }
continue
}
if (c == "&" && (substr(s, i - 1, 1) ~ /[<>]/ || substr(s, i + 1, 1) == ">")) { atword = 0; result = result c; i++; continue }
if (c ~ /[;&|()\n]/) {
if (c == "|" && masked && substr(s, i + 1, 1) != "|" && substr(s, i - 1, 1) != "|") piped = 1
atword = 1; words = 0; git = 0; commit = 0
result = result c; i++; continue
}
if (c == " " || c == "\t") { atword = 1; result = result c; i++; continue }
if (atword) {
atword = 0; words++
rest = substr(s, i)
if (words == 1 && rest ~ /^git([ \t\n;&|()]|$)/) git = 1
else if (git && !commit && rest ~ /^commit([ \t\n;&|()]|$)/) commit = 1
else if (commit && (len = flag(s, i)) > 0) {
result = result substr(s, i, len); i += len
j = handle_msg(s, n, i)
if (j > i) masked = 1
atword = (j == i); i = j
continue
}
}
if (c == "\\") bs = 1
else if (c == "'") sq = 1
else if (c == "\"") dq = 1
result = result c; i++
}
printf "%s", (sq || dq || bs || piped || unsure) ? s : result
}
+3 -1
View File
@@ -152,7 +152,9 @@ gstack itself uses this: `/ship` Step 14.5 dispatches the `/document-release`
subagent with this prefix before final commit, verification and publication.
Its ship-owned scope overrides generic spawned auto-choice: risky or uncertain
documentation changes return as blockers for the parent, without interactive
questions or automatic approval. Deliberately narrow: only `spawned` is honored — `headless`
questions or automatic approval. Deliberately narrow: only `spawned` and `unattended` are honored
(`unattended` is a separate, explicitly selected contract for a parent that
reads a run's artifacts; see [unattended.md](unattended.md)) — `headless`
already has `GSTACK_HEADLESS`, and letting an env var force `interactive`
over CI markers would be a misclassification footgun. Empty or other values
are reserved and ignored (fall through to ambient detection). Note that hook
+7 -3
View File
@@ -109,7 +109,11 @@ and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after
a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start
self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy)
while a lease held by a live process outside its own chain exists, naming each
holder as `pid <p> (<age>m <command>)`. A lease whose pid now belongs to another
holder as `pid <p> (<age>m <command>)`. The team-mode SessionStart hook
(`bin/gstack-session-update`) honors the same leases: it logs
`SKIP leased pid=<p>/<age>m` to `analytics/session-update.log` and tries again at
the next session start, without writing its hourly throttle stamp. A lease whose
pid now belongs to another
process, or an identity-less lease from an older gstack past 120 minutes, is
stale: `bin/gstack-lease live` reclaims it (`gstack-lease: reclaimed stale
lease …`). Stale needs proof: two well-formed identities from the same backend
@@ -141,7 +145,7 @@ session holds no lease) and a reclaim keeps the lease. `bin/gstack-lease list` s
packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes
for this platform). CI exact pins for this platform are alternatives: a
repository may test several Bun versions (gstack's own CI pins 1.4.2 and
runs one job on 1.4.3), so a Bun equal to any one of them satisfies CI;
runs one job on 1.4.4), so a Bun equal to any one of them satisfies CI;
CI ranges and every non-CI pin stay required. One resolver picks the Bun (`gstack_bun_resolve` in
`bin/gstack-bun-version.sh`; `resolveBun` in `lib/pregate/runtime.ts` is
its TypeScript twin), and the installer, `--check`, the doctor, the bun
@@ -160,7 +164,7 @@ session holds no lease) and a reclaim keeps the lease. `bin/gstack-lease list` s
exact pin when gstack carries a verified archive for it
(`GSTACK_BUN_VERIFIED`: 1.4.2, 1.4.3, 1.4.4); with only CI exact pins, the install
default when it is one of them, else the highest verified one; else the
install default `GSTACK_BUN_DEFAULT` (1.4.3, above the 1.4.2 minimum; one
install default `GSTACK_BUN_DEFAULT` (1.4.4, above the 1.4.2 minimum; one
CI job runs the quick free tier on it).
`GSTACK_BUN_INSTALL_VERSION=<v>` overrides the default; a version without a
verified archive or below the minimum stops the install
+34 -5
View File
@@ -94,10 +94,11 @@ It refuses the hand-off, with one rate-limited line in
- the prompt carries a HIGH-tier credential shape (checked on the raw bytes
and on the decoded string values, so a JSON-escaped key does not slip by),
or is larger than 1 MiB;
- the repo's per-remote trust policy is `deny` or `read-only` (judged by the
session's working directory, so a session that touches other repositories
is not covered), or that policy could not be looked up at all (git could
not read the repository, the store is unreadable): the lookup fails closed;
- the repo's per-remote trust policy is `deny`, or `read-only` without the
recall opt-in below (judged by the session's working directory, so a
session that touches other repositories is not covered), or that policy
could not be looked up at all (git could not read the repository, the store
is unreadable or corrupt): the lookup fails closed;
- the receipt cannot be written, or the hook's 4.5 s budget cannot afford the
next step (the secret scan of a very large prompt, or the vendor spawn).
@@ -163,6 +164,34 @@ whatever it stored are untouched: `memorable disable` stops capture,
The entry also comes out with `gstack-uninstall` (named in its summary) and
survives `./setup --no-team`, which only tears down team-mode hooks.
## Recall in a read-only repo (per-remote opt-in)
`read-only` keeps a repo's content out of the brain, and by default it also
keeps that repo's prompts away from Memorable. If you chose `read-only` only
to stop code ingest, and you have no objection to Memorable seeing prompts
from that repo, opt that one remote in:
```bash
gstack-gbrain-repo-policy memorable set github.com/acme/client-a allow
gstack-gbrain-repo-policy memorable get github.com/acme/client-a # read-only allow
gstack-gbrain-repo-policy memorable set github.com/acme/client-a off
```
The opt-in is off by default and covers only the remote you name (the SSH and
HTTPS spellings of it are one key). It is refused on a `deny` remote, and a
`deny` vetoes recall even if an opt-in was written into the store by hand.
Changing the remote's tier clears it, so moving a repo back to `read-only`
later never silently re-opens recall. When `~/.gstack` also makes the remote
read-only (see [state roots](state-root.md)), that store has to opt it in too.
A corrupt, empty or unreadable policy store (or a dangling symlink in its
place) skips recall for every repo until you fix it; the hook never resets it
to "no policy" and never rewrites it. The lookup checks every configured
`remote.origin.url`, not the URL after `url.*.insteadOf` rewriting, and any
denied or not-opted-in read-only one skips recall. `gstack-gbrain-repo-policy list` shows
opted-in remotes with a `memorable=allow` column. Nothing about gbrain
ingest changes: the repo stays `read-only` for `/sync-gbrain` and every
other gbrain writer.
## If you run gbrain
Memorable can keep its procedures in your own gbrain database instead of its
@@ -189,7 +218,7 @@ and `/sync-gbrain` are unrelated to it.
(roughly 4 seconds after gstack's own work). That is usually network.
- **`hook-errors.log` names a refusal.** `refused:redaction-high` means a
credential shape was in the prompt; `trust policy ... deny or read-only`
means the repo is protected; `receipt-unwritable` means
means the repo is protected (see the per-remote opt-in above); `receipt-unwritable` means
`~/.gstack/security` is not writable.
- **`enable` says the stable install predates this bridge.** Run `./setup`
(or `/gstack-upgrade`) so the hook registered at `~/.claude/skills/gstack`
+56 -13
View File
@@ -19,7 +19,8 @@ Three bins, all over `lib/`:
A pin file names commands. Its presence never authorizes execution: both tools
read the committed copy on `origin/<base>` (a PR cannot smuggle a command into
its own gate) and run commands only with `--allow-repo-commands`, which `/ship`
its own gate; `gstack-pregate run` alone also reads a user-owned file outside
the repository when the base has none, see [pregate v1](#pregate-v1)) and run commands only with `--allow-repo-commands`, which `/ship`
passes. Standalone, `gstack-regen check` lists the commands and exits 3
(`REPO_COMMANDS_NOT_ALLOWED`); `gstack-pregate` marks `regen` and `lanes`
`incomplete`, never `pass`. `--policy-from head` (a PR that introduces or
@@ -59,12 +60,13 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| Key | Meaning |
|---|---|
| `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH); nothing detected, a Dart SDK missing from PATH, or a `.dart` file with no `pubspec.yaml` above it is `incomplete` (the runnable groups still run, so a real failure still fails) |
| `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH; `cargo test -p <name>` per Rust crate, run from the root of the workspace the crate is a member of (Cargo's `members` globs, `exclude` and `package.workspace`), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its `test` script) runs from that package, with package-relative paths, through the package manager its `packageManager` field or the nearest lockfile up to the repo root names: `pnpm exec`, `yarn`, `bun x`, else `npx`; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a `.dart` file with no `pubspec.yaml` above it, a `.rs` file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, `_` and `-`), is `incomplete` (the runnable groups still run, so a real failure still fails) |
| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences) |
| `empty_lanes` | extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a `join()` of path segments, an import, or a `dependencies` entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without `.md`, and so does licence text under any vendored name (`roboto-LICENSE.txt`, `LICENSE-MIT`, `licenses/*.txt`). Source code and files under a test directory never qualify. A test that reads the file still selects that test |
| `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) |
| `warn` | check ids downgraded to warn; `secrets` and required lanes never |
| `strays.allow` | intentional root files or new top-level directories |
| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no workflow lane |
| `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) |
| `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) |
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
@@ -72,8 +74,36 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides (see [supervision](#supervision)) |
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
Only the `origin/<base>` copy is honoured, so a downgrade is reviewed before it
takes effect. `gstack-pregate validate` / `init` cover the working-tree file.
Only the `origin/<base>` copy or the user-owned file below is honoured, so a
downgrade is reviewed (or is the user's own) before it takes effect. A repo that
keeps `.gstack/` out of git (a shared repo where gstack is one contributor's
tool) can hold the same file at `<state root>/projects/<slug>/pregate.json`. The
state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`,
else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `<slug>` is what
`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository
(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none
(never under `--policy-from head`), it lives outside the repository so a PR still
cannot reach it, and the record names it (`policy user <path>`) and fingerprints
its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
ship receipt treat an edited or deleted user file, or one that appears (or a
link on its path that starts pointing nowhere) after a run under the defaults,
as `PREGATE_STALE`. A path that is relative, resolves inside the repository, or
reaches it through any symlink on the way (a state root linked into the
checkout, or a link hop inside it, even when the last hop points back outside)
is ignored, since a PR could commit it or choose the file; `--explain` says
so, or says `(absent)` when no file is there. The slug and state root come from
the caller's environment, never the checkout's `.env`: the bin's shebang passes
`--no-env-file`, and a bare `bun gstack-pregate` launched beside any `.env` file
refuses (`POLICY_SOURCE_UNAVAILABLE`). `verify` reads both paths from the
record, not from its own environment.
It covers `gstack-pregate run` only: `sealed` and `price_file` are read from the
base branch's copy, so the user file refuses them (`PREGATE_CONFIG_INVALID`)
rather than holding a policy nothing reads. A user file that exists but cannot
be read, or a symlink on its path that points nowhere, refuses the same way
instead of falling back to defaults. A base copy git cannot read (and
`git ls-tree` cannot confirm is absent) refuses with `POLICY_SOURCE_UNAVAILABLE`
instead of handing the run to the user file.
`gstack-pregate validate` / `init` cover the working-tree file.
`gstack-pregate init` writes a draft built from the repo, not a fixed example.
It prints the languages it found and the runner each language's tests would
@@ -115,13 +145,15 @@ Two stages with separate clocks, both printed:
`TIMING_ASSERTIONS: found=<n> escaped=<n> files=<n>` summary. It warns
(`PREGATE_TIMING_ASSERTION`); it does not block. See [timing](#timing).
- `guards`: each guard in `.gstack/pregate.json` whose `when` globs match a
- `guards`: each guard in the active policy (`.gstack/pregate.json`, or the
user-owned file) whose `when` globs match a
touched file (renames included) runs its `command` in its own process, under
the verified launch Bun, with its own `timeout_s` (default `--timeout`). A
non-zero exit fails pregate naming the guard and its `fix`
(`PREGATE_GUARD_FAILED`); a timeout is `incomplete`; without
`--allow-repo-commands` the selected guards are listed and the check is
`incomplete`. Guards are read from the base branch's copy and cannot be
`incomplete`. Guards are read from the base branch's copy (or the
user-owned file when the base carries none) and cannot be
downgraded to warn. See [guards](#guards).
**Stage 2, test execution:**
@@ -135,8 +167,18 @@ Two stages with separate clocks, both printed:
one has no lane until you declare `"**/integration_test/**": []` (and
`"**/test_driver/**": []`) under `dependencies` and let CI run them; Dart files, device tests
included, are source code, so a `dependencies` `[]` declaration clears them and `empty_lanes`
does not), transitively through
does not); in a Rust crate (the nearest Cargo.toml with a `[package]` name) a `.rs` file is a
test when it sits under `<crate>/tests/`, holds a `#[cfg(test)]` (any cfg predicate naming
`test`) or a test attribute (`#[test]`, `#[tokio::test]`), and every `.rs` file of the crate and its Cargo.toml select the crate's tests and
those of every crate whose manifest depends on it, directly or through other crates (cargo
runs a crate's tests, not a file, so the lane is the crate; a crate with no tests still has
no lane, and a workspace-only Cargo.toml or Cargo.lock needs a `dependencies` entry),
transitively through
non-test files; deleted and renamed files select the tests that named them.
An extensionless JS/TS import (relative, a tsconfig/jsconfig `paths` alias or
`baseUrl`) tries the exact path, then `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`,
`.cjs`, `.mts`, `.cts`, then `index.ts`, `index.tsx`, `index.js`, `index.jsx`
and `index.mjs` inside the directory; the first file that exists wins.
A touched source file with no lane is `FAIL lanes — <file> has no test lane`,
never a pass. Markdown that no test depends on is a docs/no-tests empty lane
instead (`docs/no-tests: N documentation file(s)`), including when the base
@@ -168,9 +210,10 @@ Two stages with separate clocks, both printed:
that step's `working-directory`, else the job's or workflow's
`defaults.run.working-directory`): local ones run through the runner under
`set -o pipefail`, judged on the runner's summary line (zero tests ran is a
failure, a `| tail`-hidden exit is still an exit); a platform lane this
failure, a `| tail`-hidden exit is still an exit; cargo's `test result:` lines, one per
test binary and doc-tests included, are summed); a platform lane this
machine cannot run becomes a `requires-remote` obligation naming
`workflow.yml/job`. `--explain` prints each test's provenance and every
`workflow.yml/job`, unless `local` names that lane. `--explain` prints each test's provenance and every
uncertain mapping (a planner-computed matrix without a `selection` manifest
selects conservatively). Test files matching `serial_patterns` (default
`**/*.serial.test.*`) run one process per file after the unit files. The
@@ -183,7 +226,7 @@ Two stages with separate clocks, both printed:
runner process (the unit-file group, each serial file, each guard without
its own `timeout_s`), not their total; the stall timeout catches a hung or
silent one sooner.
- Under 8 cores, a base-branch `remote_gate` or a remote `test_backend`
- Under 8 cores, a `remote_gate` in the active policy or a remote `test_backend`
(`gstack-config set test_backend ubicloud`) records the local test tier as a
`requires-remote` obligation instead of running it: see [remote gate](#remote-gate).
@@ -230,7 +273,7 @@ test-tier command:
Per-file lines come from a progress adapter where the runner's reporter
provides them: `bun test` (its `<file>:` headers) is the only adapter today;
a one-file run (each serial file) reports its file from the launch for any
runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test) get heartbeats
runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test, cargo test) get heartbeats
only. A heartbeat follows each 60 s with no progress line;
- is killed as **stalled** when its log does not grow for the stall timeout
(`--stall-timeout <s>`, else `stall_timeout_s`, else 300; `0` disables): the
@@ -249,8 +292,8 @@ misses a descendant that was reparented or started detached.
<a id="remote-gate"></a>
## Small machines and the remote gate
Under 8 cores (`os.availableParallelism()`), when the base branch's
`.gstack/pregate.json` declares `remote_gate` or `gstack-config get
Under 8 cores (`os.availableParallelism()`), when the active policy (the
base branch's `.gstack/pregate.json`, or the user-owned file) declares `remote_gate` or `gstack-config get
test_backend` is not `local`, the lanes check runs nothing locally: it records
the selected local files as a `requires-remote` obligation with the command to
run, `pregate.json/remote_gate` (the declared command) or
+27 -6
View File
@@ -337,7 +337,9 @@ gstack-ship-queue train build --prs 41@<sha>,42@<sha>,43@<sha>
It prints `ORDER:` (the slot order to announce: screened PRs first in READY
order, then unscreened; when `max_size` or more unscreened PRs wait they form
their own train and `HELD:` lists them), one `PREFIX: T<i> <commit>
their own train and `HELD:` lists them; `--order <pr>,<pr>,...` naming every
pinned PR once replaces that rule, for a PR that must merge before another,
and the train's later `NEXT:` rebuilds keep it), one `PREFIX: T<i> <commit>
tree=<tree>` per prefix and one `SLOT:` per PR. `--dry-run` adds the
`ESTIMATE:` line and writes, pushes and dispatches nothing. Otherwise it
writes `train.json`, pushes `train/<nonce>`, dispatches `free_workflow` there
@@ -355,7 +357,11 @@ merging #<a>,#<b>; nothing dispatched, no branch pushed; fix: …` line names
the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red,
missing, cancelled or on another SHA). In a merge, version files take the
prefix's side and the CHANGELOG merges by section, since the stamp at merge
rewrites both.
rewrites both; a PR whose CHANGELOG drops or renames a released heading (or
carries a released entry's text in a section of its own) is a CHANGELOG
conflict, never merged with the damage. READY receipts and train records are
keyed on origin's GitHub owner/repo, so lanes whose origin uses another URL
form (git.capy.ai, ssh, a token URL) share the coordinator's queue.
**3. Coordinator: status and verdict.**
@@ -406,13 +412,13 @@ EVAL_POLICY records it. Bisect results never turn a train green.
```bash
git fetch origin main
git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only
bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3), s(1)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
git add CHANGELOG.md && git commit --no-edit
gstack-restamp --next --base main --pr 42 --expect-base <main sha> --expect-order <order> --allow-repo-commands
git add CHANGELOG.md VERSION package.json <release outputs> && git commit -m "v<X> stamp" && git push
```
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. `verify` accepts the resulting head because its tree equals T<i> modulo stamps; the `head:` line says "T<i> with main merged in".
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. When the helper says the CHANGELOG needs a hand merge, take main's CHANGELOG unchanged and put your own entry above it. `verify` accepts the resulting head because it contains main and its tree equals T<i> modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the `head:` line says "T<i> with main merged in".
**5. Coordinator: verify, merge, verify again.** For each slot in order:
@@ -423,8 +429,9 @@ gstack-ship-queue verify --pr 41 --train <nonce> # VERIFY_TRAIN: #41 merged; m
```
Before a merge, main's tree must equal the prefix before the PR modulo stamps
and the PR must be at its pin (stamps aside) or at T<i> modulo stamps (its pin
with main merged in); after it, main's tree
and the PR must be at its pin (stamps aside) or contain main and be T<i>
modulo stamps (its pin with main merged in; its CHANGELOG must be main's with
only lines from the pin's own CHANGELOG diff above it); after it, main's tree
must equal the prefix after it. Anything else is `QUEUE_TRAIN_TREE_MISMATCH`
(or `QUEUE_TRAIN_MOVED`). When nothing of the train has merged, that is a plain
refusal: build a new train on current main. When earlier slots already
@@ -471,6 +478,20 @@ which carry the workflow consequences the skill prose would otherwise spell out:
The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`),
written by the pr-body section of `/ship` after the body is composed.
## What `/land-and-deploy` reads from it
Step 3.4 runs `show-effective --base <base>` before the merge and prints one
`VERSION_CHECK:` verdict for the PR head:
| Policy | Stops when | Passes |
|---|---|---|
| `queue_mode: stamp-at-merge` | the head's VERSION is not the base's version plus one bump, or the changelog's top two headings at the head are not that version and then the base's top entry (`STOP stamp stale`: run `gstack-restamp --next` on the branch, push the stamp, rerun) | an exact stamp; other open PRs' tentative stamps are ignored |
| `queue_mode: claim-at-open` (and no policy file) | another open PR holds the same VERSION, or the base is already at or above it (`VERSION drift detected`: rerun `/ship`) | any unique VERSION above the base, including a MICRO bump or one below a sibling's claim |
An invalid policy, an unreadable claim list or a missing VERSION is `unavailable`:
the skill reports it and CI's version gate is the backstop. A base at or above the
branch VERSION still stops.
<a id="stamp-at-merge"></a>
## `queue_mode: stamp-at-merge`
+3
View File
@@ -104,6 +104,9 @@ lives in `projects/<slug>/`. `bin/gstack-slug` picks the slug from the git
and the ssh, https and ssh-with-port spellings of one repository share one.
- Without a remote, the project root's directory name is used.
A bucket can also hold a user-owned `pregate.json` policy, for a repo that keeps
`.gstack/` out of git (see [pregate v1](pregate.md#pregate-v1)).
The first slug gstack resolves in a directory sticks (it is cached under
`slug-cache/`), so a project keeps its bucket when it adds a remote later.
+36 -14
View File
@@ -170,8 +170,10 @@ the readiness dashboard and the PR body, and never count it as a pass.
findings.
- **Every review gate.** These rules apply to every gate that summarises a
review, including the `execution` gate behind `/codex` consult and challenge.
A reply whose last `Verdict:` line says `not ready` but carries no readable
severity prints `FINDINGS: unspecified` and exits 3 like a blocking finding.
A reply whose last `Verdict:` line says `not ready` is `findings` and exits
3 even when readable advisory severities (P2 or P3) were parsed; `FINDINGS:`
names the highest one. A not-ready reply with no readable severity still
prints `FINDINGS: unspecified` and exits 3.
- **No findings.** A review with no tag and no label is `clean` only when its
conclusion says so: the last sentence (or its last ", and" clause), or the
`because` clause of its `Recommendation:` line, opens with `NO_FINDINGS`,
@@ -1590,7 +1592,7 @@ next `--check` passes.
### `gstack-capy-install: GSTACK_BUN_INSTALL_VERSION=<v> is not a verified Bun at or above gstack's supported minimum ...; fix: ... (BUN_INSTALL_VERSION_INVALID)`
**Meaning.** `GSTACK_BUN_INSTALL_VERSION` overrides the Bun the installer
downloads when no exact project pin decides (`GSTACK_BUN_DEFAULT`, 1.4.3). It
downloads when no exact project pin decides (`GSTACK_BUN_DEFAULT`, 1.4.4). It
must be listed in `GSTACK_BUN_VERIFIED` (a SHA-256-verified archive) and sit at
or above the supported minimum (`GSTACK_BUN_TESTED`). Nothing was installed.
@@ -2440,9 +2442,9 @@ reply with the printed `gate_rev`.
**Meaning.** Part of the reply matched no gate item or option. Nothing was
applied; the unparsed tokens are listed.
**Fix.** Reply with `all`, with `<id><option>` tokens (`d3b uc1a`), or with
`all except <tokens>`. A bare `yes` or `no` is ambiguous across multi-option
items and is never guessed.
**Fix.** Reply with `rec` (alias `all`), with `rec except <tokens>`, with
`<id><option>` tokens (`d3b uc1a`), or with `as-is` (the plan as written). A
bare `yes` or `no` is ambiguous across multi-option items and is never guessed.
<a id="answer-by-required"></a>
### `ANSWER_BY_REQUIRED`
@@ -2472,7 +2474,13 @@ file is described in [docs/ship-policy.md](ship-policy.md); the queue flow in
**Meaning.** The policy is read from the committed copy on `origin/<base>`,
and that ref does not resolve here (a shallow or detached gate checkout, or a
fetch that never happened). The tool exits 3 and never falls back to the
working-tree file silently.
working-tree file silently. For `gstack-pregate` the same refusal covers a base
`.gstack/pregate.json` git cannot read (a timeout, an oversized blob, a missing
or corrupt object) that `git ls-tree` cannot confirm is absent; it never falls
through to the user-owned file. It also refuses when Bun loaded a `.env` file
from the working directory (a bare `bun gstack-pregate`), since a branch could
then pick the user policy: run `gstack-pregate` directly or as
`bun --no-env-file`.
**Fix.** `git fetch origin <base> --depth=1`, then rerun. To run against the
branch's own file deliberately, pass `--policy-from head --allow-repo-commands`;
@@ -2875,7 +2883,11 @@ or `REALITY: surface-check finding bun upgrade --version absent on 1.3.14 bin/gs
Both tools read their pin file (`.gstack/generated.json`, `.gstack/pregate.json`)
from `origin/<base>`, never the working tree, and run repo-declared commands only
under `--allow-repo-commands` or inside `/ship`. Details: docs/pregate.md.
under `--allow-repo-commands` or inside `/ship`. When the base carries no
`.gstack/pregate.json`, `gstack-pregate run` reads
`<state root>/projects/<slug>/pregate.json` instead (outside the repository; see
docs/pregate.md for the state root and slug).
Details: docs/pregate.md.
<a id="regen-registry-invalid"></a>
### `REGEN_REGISTRY_INVALID`
@@ -2930,10 +2942,16 @@ repository's lockfile), then rerun `gstack-regen check` or the pre-gate.
**Meaning.** `.gstack/pregate.json` has an unknown key, a wrong type, a glob
that escapes the repository, a runner without `{files}`, or `secrets` in `warn`.
The user-owned `<state root>/projects/<slug>/pregate.json` also refuses when it
sets `sealed` or `price_file` (both are read only from the base branch's copy)
or exists but cannot be read (a dangling symlink on its path counts).
**Fix.** `gstack-pregate validate`. `secrets` and required-lane obligations
cannot be downgraded; every other check id in `warn` is honoured only from the
base branch's copy.
base branch's copy or the user-owned file. For the user-owned file, edit it
directly at the path the message names. A user file inside the repository, or
reached through a symlink into it, is ignored rather than read (`--explain`
names it).
<a id="pregate-secrets"></a>
### `PREGATE_SECRETS`
@@ -2978,7 +2996,7 @@ allowlisted tool config no test names, are the assets-and-tool-config empty lane
not this error.
**Fix.** Add a test that names the file, or declare its lane (an explicit `[]`
for files with no behaviour) under `dependencies` in `.gstack/pregate.json`.
for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or the user-owned file the message names).
Other non-Markdown documentation goes in `empty_lanes` on the base branch.
Dart files under `integration_test/` or `test_driver/` need a device, so pregate
never runs them: declare `"**/integration_test/**": []` (and
@@ -3017,7 +3035,7 @@ declare `runner`), raise `--timeout`, or keep the tree still, then rerun.
**Meaning.** A test command the lanes check (or a guard, or the `--remote`
gate) launched wrote nothing to its log for the stall timeout (default 300 s,
`stall_timeout_s` in the base branch's `.gstack/pregate.json`, or
`stall_timeout_s` in the base branch's `.gstack/pregate.json` or the user-owned file, or
`--stall-timeout`). pregate killed its process group and names the last test
file started. The check is incomplete, never pass.
@@ -3045,7 +3063,10 @@ file with `--receipt`, or let `gstack-pregate --stage tests --remote --spend-cap
### `PREGATE_STALE`
**Meaning.** `pregate.json` was recorded against a different working-tree
fingerprint than the one being published (the final stamp, a regen, any edit).
fingerprint than the one being published (the final stamp, a regen, any edit),
or the user-owned policy it ran under was edited or deleted (or one appeared,
or a symlink on its path started pointing nowhere, after a run under the
defaults).
**Fix.** Rerun `gstack-pregate` after the final generation and stamp; Step 18
refuses to write the ship receipt otherwise.
@@ -3274,8 +3295,9 @@ next `./setup` prints no CRLF warning.
<a id="pregate-guard-failed"></a>
### `PREGATE_GUARD_FAILED`
**Meaning.** A guard from the base branch's `.gstack/pregate.json` (a repo
command such as a serial-test manifest check or a CLI golden test) matched a
**Meaning.** A guard (a repo command such as a serial-test manifest check or a
CLI golden test) from the base branch's `.gstack/pregate.json`, or from the
user-owned file when the base carries none, matched a
file the branch touched and exited non-zero. Guards cover what the import graph
cannot see: manifests, subprocess goldens, registries. They cannot be
downgraded to warn.
+32 -2
View File
@@ -57,18 +57,46 @@ An unknown `GSTACK_SESSION_KIND` value coerces to `interactive` and says so:
## The terminal line
Every workflow skill ends an unattended run with one line the parent greps:
A run that writes a record ends with one line the parent greps:
```
GSTACK_RESULT: skill=<name> status=<status> run=<dir>
```
`status` is one of `complete`, `gate_pending`, `incomplete`, `interrupted` or
`refused`; `run` is the directory that holds the artifacts below. `/autoplan`
`refused`; `run` is where the run's record is. `/autoplan`
always ends `gate_pending` when it reaches the final gate, because the gate is
written and never approved. Pure queries (`gstack-artifact schema`,
`gstack-gate render`) print no result line.
Not every workflow writes this line or a run directory today:
- **`/autoplan`** writes the full run directory below through `gstack-autoplan`,
and `gstack-artifact validate` checks it. It is the only skill with steps
and helpers that write `run.json`, `decisions.jsonl` and `gate.json`.
- **Workflow commands** `gstack-pregate`, `gstack-regen`, `gstack-restamp` and
`gstack-ship-queue` print their own line, with `run=` naming their own record
(the pregate receipt, the regen worktree, the restamp journal, the queue
record), not a run directory.
- **A refused launch.** Any skill whose install check refuses it at startup
prints `GSTACK_RESULT: skill=<name> status=refused run=-` and stops.
- **Every other workflow skill** (`/ship`, `/review`, `/qa`, `/design-review`,
`/spec`, `/land-and-deploy` and the rest) gets the session rules above (no
prompts, approvals stay pending, nothing published), but has no step that
writes a run directory or prints the line, so a parent should not wait for
either. `/eval-plan` asks for a `gate_pending` line at its spend gate, but no
helper writes its `gate.json` yet (`gstack-gate` renders and parses a gate
list; it does not create one). `/cso` skips the shared startup, so these
session rules do not reach it at all. Read each skill's own record instead:
the review-log rows (`gstack-review-read`) for `/review`, the
`qa-report-*.md` file for `/qa`, and the ship receipt for `/ship`. When
the run did not push (an unattended run must not), read the receipt file in
the child's checkout with `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`;
use `gstack-ship-receipt read --pr <n> --json` only once a PR exists. In both
cases compare the receipt's `head` to the branch SHA (`git rev-parse HEAD` in
the child's checkout): `read` does not check that the receipt is current, so
an older PR's receipt reads as valid.
Every workflow command shares one exit table: `0` ok, `1` fail, `2` usage,
`3` refused or needs a flag. Error lines end with a result code in parentheses,
for example `findings.jsonl:12: ... (ARTIFACT_SCHEMA)`; each code has an anchor
@@ -77,6 +105,8 @@ a `fix:` clause. Grep the code, not the prose.
## The run directory
`/autoplan` writes these files; the next section shows how a parent reads them.
| File | Schema | Contents |
|------|--------|----------|
| `run.json` | `run` v1 | The manifest: `run` id, `status`, `required_phases`, each phase's `native` and `outside` outcome (`status`, `model`, `provider`), input `snapshot` hashes, `counts`, every artifact's `path` (relative to the run directory) and `sha256`, `session_kind`, `gate_rev`, `deviations`, `consumed_by`. |
+25 -13
View File
@@ -500,7 +500,7 @@ Score each category on a 0-10 scale using this rubric:
| Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass |
| Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused |
| Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) |
| GBrain | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) |
| GBrain | 10% | Computed in code: see **GBrain sub-score computation** below | — | — | N/A (gbrain not installed) |
**Parsing tool output for counts:**
Use the complete captured output, not the displayed tail. A zero match count cannot
@@ -534,17 +534,28 @@ a numeric composite, and skip numeric history persistence and trend calculation.
**GBrain sub-score computation:**
```
doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok";
7 if "warnings"; 0 otherwise (or command times out after 5s).
queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines;
7 if 10-100; 0 if >=100 (suggests secret-scan rejections
piling up). N/A if artifacts_sync_mode == off.
push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h;
7 if <72h; 0 if >=72h. N/A if artifacts_sync_mode == off.
gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component
(redistribute 0.3 + 0.2 into doctor when sync_mode is off:
gbrain_score = doctor_component in that case)
Compute the GBrain score in code, not by hand, and pass the printed
`GBRAIN_SCORE` as `gbrain` to the composite above. Gather three readings:
- **doctor:** 10 when `gbrain doctor --json` reports `status` `ok`, 7 for
`warnings`, 0 for anything else or when the call times out after 5s.
- **queue:** the line count of `~/.gstack/.brain-queue.jsonl` (0 when absent).
A long queue suggests secret-scan rejections piling up.
- **pushHours:** hours since the mtime of `~/.gstack/.brain-last-push`.
Pass `null` for both queue and pushHours when `artifacts_sync_mode` is `off`;
the doctor component then carries the whole score:
```bash
bun --no-env-file --config=/dev/null -e '
const doctor = <N|null>;
const queue = <N|null>;
const pushHours = <N|null>;
const d = doctor ?? 0;
const band = (v, ten, seven) => (v < ten ? 10 : v < seven ? 7 : 0);
const score = queue === null || pushHours === null ? d : 0.5 * d + 0.3 * band(queue, 10, 100) + 0.2 * band(pushHours, 24, 72);
console.log("GBRAIN_SCORE: " + score.toFixed(1));
'
```
The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung
@@ -625,7 +636,8 @@ Fields:
- `ts` -- ISO 8601 timestamp
- `branch` -- current git branch
- `score` -- composite score (one decimal)
- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10)
- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10)
- `gbrain` -- the printed `GBRAIN_SCORE` (0-10, one decimal)
- `duration_s` -- total time for all tools in seconds
If a category was skipped, set its value to `null`. Treat a history entry
+25 -13
View File
@@ -177,7 +177,7 @@ Score each category on a 0-10 scale using this rubric:
| Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass |
| Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused |
| Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) |
| GBrain | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) |
| GBrain | 10% | Computed in code: see **GBrain sub-score computation** below | — | — | N/A (gbrain not installed) |
**Parsing tool output for counts:**
Use the complete captured output, not the displayed tail. A zero match count cannot
@@ -211,17 +211,28 @@ a numeric composite, and skip numeric history persistence and trend calculation.
**GBrain sub-score computation:**
```
doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok";
7 if "warnings"; 0 otherwise (or command times out after 5s).
queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines;
7 if 10-100; 0 if >=100 (suggests secret-scan rejections
piling up). N/A if artifacts_sync_mode == off.
push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h;
7 if <72h; 0 if >=72h. N/A if artifacts_sync_mode == off.
gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component
(redistribute 0.3 + 0.2 into doctor when sync_mode is off:
gbrain_score = doctor_component in that case)
Compute the GBrain score in code, not by hand, and pass the printed
`GBRAIN_SCORE` as `gbrain` to the composite above. Gather three readings:
- **doctor:** 10 when `gbrain doctor --json` reports `status` `ok`, 7 for
`warnings`, 0 for anything else or when the call times out after 5s.
- **queue:** the line count of `~/.gstack/.brain-queue.jsonl` (0 when absent).
A long queue suggests secret-scan rejections piling up.
- **pushHours:** hours since the mtime of `~/.gstack/.brain-last-push`.
Pass `null` for both queue and pushHours when `artifacts_sync_mode` is `off`;
the doctor component then carries the whole score:
```bash
bun --no-env-file --config=/dev/null -e '
const doctor = <N|null>;
const queue = <N|null>;
const pushHours = <N|null>;
const d = doctor ?? 0;
const band = (v, ten, seven) => (v < ten ? 10 : v < seven ? 7 : 0);
const score = queue === null || pushHours === null ? d : 0.5 * d + 0.3 * band(queue, 10, 100) + 0.2 * band(pushHours, 24, 72);
console.log("GBRAIN_SCORE: " + score.toFixed(1));
'
```
The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung
@@ -301,7 +312,8 @@ Fields:
- `ts` -- ISO 8601 timestamp
- `branch` -- current git branch
- `score` -- composite score (one decimal)
- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10)
- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10)
- `gbrain` -- the printed `GBRAIN_SCORE` (0-10, one decimal)
- `duration_s` -- total time for all tools in seconds
If a category was skipped, set its value to `null`. Treat a history entry
@@ -10,7 +10,8 @@
* and clean removal. This file is that mediation.
*
* stdin JSON -> cap 1 MiB -> parse -> MEMORABLE=0? -> gate memorable_recall == on?
* -> win32? -> trust policy (deny / read-only veto, by session cwd; fail-closed)
* -> win32? -> trust policy (deny / read-only veto, by session cwd; fail-closed;
* a read-only remote's explicit per-remote opt-in lifts it, deny never)
* -> HIGH-tier secret scan (raw bytes AND decoded string leaves, each admitted by the clock)
* -> resolve vendor -> budget >= 500 ms? -> gate re-check
* -> receipt (fail-closed: no receipt, no send)
@@ -57,7 +58,7 @@ import {
} from '../../../lib/egress-receipt';
import { wrapUntrustedTrackerContent } from '../../../lib/tracker-guard';
import { scan } from '../../../lib/redact-engine';
import { hasRepoPolicyStore, repoPolicyTier } from '../../../lib/gbrain-repo-policy-client';
import { hasRepoPolicyStore, memorableRecallPolicy } from '../../../lib/gbrain-repo-policy-client';
import { LOG_RATE_LIMIT_MS, logHookError as sharedLogHookError } from './hook-log';
export { LOG_RATE_LIMIT_MS };
@@ -393,13 +394,30 @@ async function policyVeto(cwd: string, timeoutMs: number, remaining: () => numbe
if (git.status === GIT_NO_REMOTE) return 'ok';
if (git.status === GIT_FATAL && NOT_A_REPO_RE.test(git.stderrTail.trim())) return 'ok';
if (git.status !== 0) return 'error';
const url = git.stdout.toString('utf8').trim();
if (!url) return 'ok';
const res = repoPolicyTier(url, process.env, Math.max(1, Math.min(STAGE_CAP_MS, remaining())));
if (res.error) return 'error';
// `deny` and `read-only` are the tiers a user picks so a repo's content
// never lands in a shared store; a third-party memory service is one.
return res.tier === 'deny' || res.tier === 'read-only' ? 'skip' : 'ok';
if (!git.stdout.toString('utf8').trim()) return 'ok';
// The store is keyed by the configured URLs (what `gstack-gbrain-repo-policy
// get` and /sync-gbrain read), not the url.*.insteadOf rewrite that
// `remote get-url` applies, and every configured origin URL is checked:
// neither a rewrite nor an added URL may dodge a deny.
const raw = await runExternal('git', ['config', '--null', '--get-all', 'remote.origin.url'], {
cwd, timeoutMs: Math.max(1, Math.min(timeoutMs, remaining())), maxBuffer: GIT_MAX_BUFFER, env: gitEnv(process.env),
onSpawn: (kill) => { killInflight = kill; },
});
killInflight = null;
if (raw.timedOut || raw.error || raw.status !== 0) return 'error';
const urls = raw.stdout.toString('utf8').split('\0').filter((u) => u !== '');
if (urls.length === 0) return 'error';
for (const url of urls) {
const res = memorableRecallPolicy(url, process.env, Math.max(1, Math.min(STAGE_CAP_MS, remaining())));
if (res.error) return 'error';
// `deny` and `read-only` are the tiers a user picks so a repo's content
// never lands in a shared store; a third-party memory service is one.
// Only an explicit per-remote opt-in (`gstack-gbrain-repo-policy memorable
// set <url> allow`) lifts the read-only veto; nothing lifts deny.
if (res.tier === 'deny') return 'skip';
if (res.tier === 'read-only' && !res.memorableOptIn) return 'skip';
}
return 'ok';
}
function writeStdout(text: string): Promise<void> {
@@ -446,7 +464,7 @@ export async function main(): Promise<void> {
const payloadCwd = (payload as { cwd?: unknown }).cwd;
const cwd = typeof payloadCwd === 'string' && isDirectory(payloadCwd) ? payloadCwd : process.cwd();
const veto = await policyVeto(cwd, Math.min(STAGE_CAP_MS, remaining()), remaining);
if (veto === 'skip') { logHookError(`trust policy for ${cwd} is deny or read-only, recall skipped`, Date.now(), 'trust policy skip'); return; }
if (veto === 'skip') { logHookError(`trust policy for ${cwd} is deny or read-only, recall skipped (a read-only remote can opt in: gstack-gbrain-repo-policy memorable set <origin-url> allow)`, Date.now(), 'trust policy skip'); return; }
if (veto === 'error') { logHookError('trust policy lookup failed (store or repository unreadable), recall skipped (fail-closed)'); return; }
// HIGH-tier pre-scan over the raw text and the decoded string leaves (a JSON
+4 -4
View File
@@ -593,10 +593,10 @@ If the user says "demo", "demo mode", "show me", or "I want to see it
working", run in **DEMO MODE**. This changes how the agent interacts with
the app:
**DEMO MODE OVERRIDES ALL OTHER RULES.** When demo mode is active, the
agent MUST drive every action through visible UI (`/tap`, `/swipe`, `/type`)
and NEVER use `POST /state/*` writes to skip steps. Viewers see the agent
type every key, tap every button. The on-device DebugOverlay attribution
In demo mode, drive every action through visible UI (`/tap`, `/swipe`,
`/type`) rather than `POST /state/*` writes, because viewers watch each
step: they see the agent type every key and tap every button. All other
rules in this skill still apply. The on-device DebugOverlay attribution
chip shows "Driven by Claude Code (demo)" or the remote agent identity.
In demo mode, the screencap rate is bumped to 4fps so the recording feels
+4 -4
View File
@@ -249,10 +249,10 @@ If the user says "demo", "demo mode", "show me", or "I want to see it
working", run in **DEMO MODE**. This changes how the agent interacts with
the app:
**DEMO MODE OVERRIDES ALL OTHER RULES.** When demo mode is active, the
agent MUST drive every action through visible UI (`/tap`, `/swipe`, `/type`)
and NEVER use `POST /state/*` writes to skip steps. Viewers see the agent
type every key, tap every button. The on-device DebugOverlay attribution
In demo mode, drive every action through visible UI (`/tap`, `/swipe`,
`/type`) rather than `POST /state/*` writes, because viewers watch each
step: they see the agent type every key and tap every button. All other
rules in this skill still apply. The on-device DebugOverlay attribution
chip shows "Driven by Claude Code (demo)" or the remote agent identity.
In demo mode, the screencap rate is bumped to 4fps so the recording feels
+34 -24
View File
@@ -708,35 +708,45 @@ approves each), or C) stop.
## Step 3.4: VERSION drift detection (workspace-aware ship)
Check that another workspace has not claimed this PR's VERSION since `/ship`.
```bash
BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \
--base "$BASE_BRANCH" \
--exclude-pr "$PR_NUMBER" \
--bump patch \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false')
git fetch -q origin "$BASE_BRANCH"
read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json |
jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"')
Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \
--exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version
then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not)
then "unavailable: claims unread" else "ok" end')
cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; }
H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl)
V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$"
if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit }
for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit }
s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }')
if [ "$Q" = NO_VERSION ]; then R=$Q
elif [ "$V" = bad ]; then R="unavailable: bad version"
elif [ "$MODE" = stamp-at-merge ]; then
[ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale"
elif [ "$V" = low ]; then R="STOP base is at or above it"
elif [ -z "$MODE" ]; then R="unavailable: no policy"
else R=$Q; fi
echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)"
```
Use the existing conservative patch-level allocation; compare numeric version
components, not lexical strings. A missing/unparseable version on only one side is
unavailable, not green.
0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads):
print `VERSION check: not applicable (<version_source.reason>)` and continue to
Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag.
1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable
with the reason; continue to Step 3.5. CI's version gate is the backstop.
2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue.
3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions
and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path
reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT
auto-bump or merge here: duplicate versions can overwrite another branch's release notes.
- `NO_VERSION`: print `VERSION check: not applicable (<version_source.reason>)`; never
expect a version-prefixed title, CHANGELOG version header or tag.
- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop).
- `ok`: continue; a higher sibling claim or a MICRO bump is not drift.
- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings
are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base
<base> --pr <n> --expect-base <BASE_SHA> --allow-repo-commands`, push the stamp diff,
wait for CI, rerun.
- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship`
from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json,
CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can
overwrite another branch's release notes.
---
+34 -24
View File
@@ -191,35 +191,45 @@ approves each), or C) stop.
## Step 3.4: VERSION drift detection (workspace-aware ship)
Check that another workspace has not claimed this PR's VERSION since `/ship`.
```bash
BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \
--base "$BASE_BRANCH" \
--exclude-pr "$PR_NUMBER" \
--bump patch \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false')
git fetch -q origin "$BASE_BRANCH"
read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json |
jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"')
Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \
--exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version
then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not)
then "unavailable: claims unread" else "ok" end')
cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; }
H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl)
V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$"
if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit }
for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit }
s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }')
if [ "$Q" = NO_VERSION ]; then R=$Q
elif [ "$V" = bad ]; then R="unavailable: bad version"
elif [ "$MODE" = stamp-at-merge ]; then
[ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale"
elif [ "$V" = low ]; then R="STOP base is at or above it"
elif [ -z "$MODE" ]; then R="unavailable: no policy"
else R=$Q; fi
echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)"
```
Use the existing conservative patch-level allocation; compare numeric version
components, not lexical strings. A missing/unparseable version on only one side is
unavailable, not green.
0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads):
print `VERSION check: not applicable (<version_source.reason>)` and continue to
Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag.
1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable
with the reason; continue to Step 3.5. CI's version gate is the backstop.
2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue.
3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions
and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path
reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT
auto-bump or merge here: duplicate versions can overwrite another branch's release notes.
- `NO_VERSION`: print `VERSION check: not applicable (<version_source.reason>)`; never
expect a version-prefixed title, CHANGELOG version header or tag.
- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop).
- `ok`: continue; a higher sibling claim or a MICRO bump is not drift.
- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings
are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base
<base> --pr <n> --expect-base <BASE_SHA> --allow-repo-commands`, push the stamp diff,
wait for CI, rerun.
- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship`
from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json,
CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can
overwrite another branch's release notes.
---
+75 -2
View File
@@ -115,6 +115,76 @@ export function normalizeChangelog(text: string): string {
return [parsed.header.join('\n').replace(/\n+$/, ''), ...sections].join('\n\u0000');
}
/**
* A merged-in head's CHANGELOG, however its conflict was resolved: why it is
* not main's CHANGELOG with the PR's own entry above it, or null. Main's
* header and every main section must follow unchanged and in order, and the
* sections above them may hold only lines the PR itself added (`authored`,
* the + lines of its pin's CHANGELOG diff), so a hand resolve can drop
* damage but never add text the train gate did not carry.
*/
export function ownEntryAboveMain(head: string, main: string, authored: ReadonlySet<string>): string | null {
const h = parseChangelog(head);
const m = parseChangelog(main);
const trim = (lines: string[]) => { const out = [...lines]; while (out.length && !out[out.length - 1]!.trim()) out.pop(); return out.join('\n'); };
if (trim(h.header) !== trim(m.header)) return 'the header differs from main';
const own = h.sections.length - m.sections.length;
if (own < 0) return `it has ${h.sections.length} sections, main has ${m.sections.length}`;
const moved = m.sections.findIndex((s, i) => trim(s.lines) !== trim(h.sections[own + i]!.lines));
if (moved >= 0) return `main's ${m.sections[moved]!.heading.trim()} is not carried unchanged below the PR's entry`;
const mainKeys = new Set(m.sections.map(s => s.version ?? 'Unreleased'));
const clash = h.sections.slice(0, own).find(s => mainKeys.has(s.version ?? 'Unreleased'));
if (clash) return `${clash.heading.trim()} is headed twice`;
const added = h.sections.slice(0, own).flatMap(s => s.lines.slice(1)).find(l => l.trim() && !authored.has(l));
return added === undefined ? null : `the PR's entry holds a line its pin never added: ${JSON.stringify(added.slice(0, 80))}`;
}
const MIN_BLOCK_CHARS = 12;
/** Paragraph blocks of a section's entry text (heading lines dropped), long enough to identify one entry. */
function entryBlocks(s: ChangelogSection): string[] {
const blocks: string[] = [];
let run: string[] = [];
const flush = () => { const text = run.join('\n').trim(); if (text.length >= MIN_BLOCK_CHARS) blocks.push(text); run = []; };
for (const line of s.lines.slice(1)) {
if (!line.trim() || line.startsWith('#')) flush();
else run.push(line);
}
flush();
return blocks;
}
/**
* Why one side of a CHANGELOG merge damaged released history, or null. A
* side damages it when it lacks a released heading the merge base carries
* (dropped, or renamed), or when a section only that side carries holds a
* paragraph of a released entry the other side carries (a released heading
* replaced, so its body reads as part of the section above it; #3345).
*/
export function changelogDamage(ours: string, theirs: string, base?: string): string | null {
const sides = [{ name: 'ours', parsed: parseChangelog(ours) }, { name: 'theirs', parsed: parseChangelog(theirs) }];
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
if (base !== undefined) {
const released = parseChangelog(base).sections.filter(s => !s.unreleased).map(key);
for (const side of sides) {
const has = new Set(side.parsed.sections.map(key));
const missing = released.find(v => !has.has(v));
if (missing) return `${side.name} drops released heading ${missing} that the merge base carries`;
}
}
for (const [i, side] of sides.entries()) {
const other = sides[1 - i]!.parsed.sections;
const otherKeys = new Set(other.map(key));
const releasedText = new Map<string, string>();
for (const s of other.filter(s => !s.unreleased)) for (const b of entryBlocks(s)) releasedText.set(b, s.version!);
for (const s of side.parsed.sections.filter(s => !otherKeys.has(key(s)))) {
const hit = entryBlocks(s).find(b => releasedText.has(b));
if (hit) return `${side.name}'s ${key(s)} section holds released ${releasedText.get(hit)} text (${JSON.stringify(hit.slice(0, 60))}); a released heading was dropped or renamed`;
}
}
return null;
}
/**
* Gate-ahead merge of two CHANGELOGs that conflict textually but not
* semantically: the predecessor's header and sections, with the branch's own
@@ -122,9 +192,12 @@ export function normalizeChangelog(text: string): string {
* branch's top section carries the same version heading as a predecessor
* section with a different body (both took the same tentative stamp), the
* branch's entry is kept under `[Unreleased]` so the restamp re-heads it.
* Both sides carrying `[Unreleased]` is a real conflict and returns null.
* Both sides carrying `[Unreleased]` is a real conflict and returns null, and
* so is damage to released history (`changelogDamage`): null means a hand
* resolve, never a merge that carries the damage through.
*/
export function mergeChangelogs(ours: string, theirs: string): string | null {
export function mergeChangelogs(ours: string, theirs: string, base?: string): string | null {
if (changelogDamage(ours, theirs, base)) return null;
const a = parseChangelog(ours);
const b = parseChangelog(theirs);
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
+14 -5
View File
@@ -5,8 +5,8 @@ import { createHash } from 'node:crypto';
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import { isAbsolute, relative, resolve, sep } from 'node:path';
/** 'java' (Maven or Gradle) is detected so it is reported as itself; no runtime prepares it. */
export type CsoStack = 'node' | 'bun' | 'python' | 'rails' | 'java';
/** 'java' (Maven or Gradle) and 'go' (go.mod) are detected so they are reported as themselves; no runtime prepares them. */
export type CsoStack = 'node' | 'bun' | 'python' | 'rails' | 'java' | 'go';
export interface PreparationPrerequisite {
code: string;
message: string;
@@ -1205,12 +1205,15 @@ export function inspectPreparation(snapshotPath: string, stack?: CsoStack): Prep
detectedStacks.push('node');
if (read(root, 'Gemfile.lock', true) !== undefined || read(root, 'Gemfile', true) !== undefined)
detectedStacks.push('rails');
// A root Maven or Gradle build names the repository's language; Python
// metadata beside it is helper tooling, not a second application stack.
// A root Maven or Gradle build, or a root go.mod with no other supported
// stack, names the repository's language; Python metadata beside it is
// helper tooling, not a second application stack.
const jvmBuild = ['pom.xml', 'build.gradle', 'build.gradle.kts'].find(
(file) => read(root, file, true) !== undefined,
);
if (jvmBuild) detectedStacks.push('java');
else if (detectedStacks.length === 0 && read(root, 'go.mod', true) !== undefined)
detectedStacks.push('go');
else if (
read(root, 'uv.lock', true) !== undefined ||
read(root, 'requirements.txt', true) !== undefined ||
@@ -1231,10 +1234,16 @@ export function inspectPreparation(snapshotPath: string, stack?: CsoStack): Prep
`Java (${jvmBuild ?? 'Maven or Gradle'}) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails.`,
jvmBuild,
);
if (detected === 'go')
fail(
'UNSUPPORTED_STACK',
'Go (go.mod) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails.',
'go.mod',
);
if (!['node', 'bun', 'python', 'rails'].includes(detected))
fail('UNSUPPORTED_STACK', 'Supported runtime stacks are Node, Bun, Python, and Rails.');
({ node: inspectNode, bun: inspectBun, python: inspectPython, rails: inspectRails })[
detected as Exclude<CsoStack, 'java'>
detected as Exclude<CsoStack, 'java' | 'go'>
](root, plan);
} catch (error) {
plan.status = 'prerequisites';
+40 -13
View File
@@ -17,9 +17,12 @@
* subcommand `--help` is generic — so capability detection is best-effort and
* defaults to "unsupported". When we can't protect a user-managed source's
* files, we FAIL CLOSED (refuse the remove) rather than delete unprotected.
* - The autopilot lock filename isn't documented and (gbrain #1226) ignores
* GBRAIN_HOME, so the live `gbrain autopilot` process is the PRIMARY signal;
* known lock paths under both the configured home and ~/.gbrain are secondary.
* - The live `gbrain autopilot` process is the PRIMARY signal and it is
* deliberately machine-wide (#3360, Garry 2026-10-24): any autopilot on the
* machine refuses, whichever brain or repo it serves, because two syncs that
* share one brain or working tree can race. The refusal names the matched
* pid and command so the user can tell whose it is. Known lock paths under
* both the configured home and ~/.gbrain are secondary.
* - We refuse only on an AFFIRMATIVE autopilot signal — inability to introspect
* never blocks a normal sync (that would brick the tool).
* - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't
@@ -69,11 +72,17 @@ export interface AutopilotStatus {
signal: string | null;
}
/** A running process whose command line matches `gbrain autopilot`. */
export interface AutopilotProcess {
pid: number;
command: string;
}
export interface AutopilotProbe {
/** Override the lock-path list (tests). */
lockPaths?: string[];
/** Override the live-process check (tests). */
processRunning?: () => boolean;
/** Override the live-process check (tests): a bare boolean, or the matched processes. */
processRunning?: () => boolean | AutopilotProcess[];
}
/**
@@ -109,10 +118,17 @@ export function detectAutopilot(
// Stale lock (holder pid is dead): ignore this signal, keep checking. Pure
// decision function — we do NOT delete the file here; the caller may clean it.
}
// Primary signal: a live `gbrain autopilot` process.
const running = (probe.processRunning ?? defaultProcessRunning)();
if (running) return { active: true, signal: "process:gbrain autopilot" };
return { active: false, signal: null };
// Primary signal: a live `gbrain autopilot` process anywhere on the machine.
const running = (probe.processRunning ?? autopilotProcesses)();
if (running === true) return { active: true, signal: "process:gbrain autopilot" };
if (!Array.isArray(running) || running.length === 0) return { active: false, signal: null };
const listed = running.map(p => `pid ${p.pid} \`${p.command}\``).join(", ");
return {
active: true,
signal: `process:gbrain autopilot ${listed}; this check counts any process on this machine whose command line contains ` +
`"gbrain autopilot", even an autopilot serving another brain or repo. Stop that autopilot (or its service, if ` +
`\`gbrain autopilot --install\` set one up), or end the process that only mentions it`,
};
}
/** Read the holder pid from a lock/pid file. Returns null if no integer pid is present. */
@@ -143,11 +159,22 @@ function isPidAlive(pid: number): boolean {
}
}
function defaultProcessRunning(): boolean {
function autopilotProcesses(): AutopilotProcess[] {
// No reliable pgrep on Windows; rely on the lock-file signal there.
if (process.platform === "win32") return false;
const r = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 });
return r.status === 0 && (r.stdout || "").trim().length > 0;
if (process.platform === "win32") return [];
const found = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 });
const pids = found.status === 0 ? (found.stdout || "").split(/\s+/).filter(p => /^\d+$/.test(p)) : [];
if (pids.length === 0) return [];
const ps = spawnSync("ps", ["-o", "pid=,args=", "-p", pids.join(",")], { encoding: "utf-8", timeout: 3_000 });
const commands = new Map<string, string>();
for (const line of (ps.stdout || "").split("\n")) {
const m = line.match(/^\s*(\d+)\s+(.*\S)/);
if (m) commands.set(m[1], m[2]);
}
return pids.map(pid => ({
pid: Number(pid),
command: (commands.get(pid) ?? "command unavailable").replace(/\/\/[^\s/@]+@/g, "//***@").slice(0, 200),
}));
}
// ── Capability detection (E4 + Codex: per-process memo, no persistent cache) ─
+74 -3
View File
@@ -17,8 +17,8 @@
*/
import { spawnSync } from "child_process";
import { existsSync } from "fs";
import { join } from "path";
import { lstatSync, statSync } from "fs";
import { dirname, join } from "path";
import { mergedStateRoots, resolveStateRoot } from "./state-root";
export type RepoPolicyTierValue = "deny" | "read-only" | "read-write" | "none";
@@ -46,9 +46,36 @@ export function repoPolicyStorePath(env: NodeJS.ProcessEnv = process.env): strin
/**
* No store on disk = no policy was ever set (the fast path — no subprocess).
* The ~/.gstack store counts too: its deny tiers merge into every state root.
* Only confirmed absence is "no store": ENOENT whose nearest existing
* ancestor resolves (so no dangling symlink cut the walk short). A dangling
* symlink at the store or above it, a non-directory component, or a path that
* cannot be checked (an unsearchable directory) counts as a store, so the
* caller spawns the script and its own fail-closed rules apply.
*/
export function hasRepoPolicyStore(env: NodeJS.ProcessEnv = process.env): boolean {
return mergedStateRoots(env).some((root) => existsSync(join(root, "gbrain-repo-policy.json")));
return mergedStateRoots(env).some((root) => {
let path = join(root, "gbrain-repo-policy.json");
try {
lstatSync(path);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") return true;
}
for (let up = dirname(path); up !== path; path = up, up = dirname(up)) {
try {
lstatSync(up);
} catch {
continue;
}
try {
statSync(up);
return false;
} catch {
return true;
}
}
return false;
});
}
/** The bash script that owns the store — resolved relative to this file (lib/ → bin/), never cwd. */
@@ -94,6 +121,50 @@ export function repoPolicyTier(
return { tier: "none", error: "unreadable" }; // unexpected output — a read failure, not a tier
}
export interface MemorableRecallPolicy extends RepoPolicyResult {
/**
* True only when the tier is `read-only` and the store opts this remote in
* to Memorable recall. Never true alongside `error` or any other tier.
*/
memorableOptIn: boolean;
}
/**
* Tier plus the per-remote Memorable recall opt-in, via
* `gstack-gbrain-repo-policy memorable get <url>` (one spawn). Unlike
* repoPolicyTier, the script never quarantines a corrupt store on this path,
* so a corrupt store reads as `unreadable` instead of as "no policy".
* Fast paths and failure classification match repoPolicyTier; anything but
* exactly "<tier>\t<allow|off>" is `unreadable`. POLARITY IS THE CALLER'S.
*/
export function memorableRecallPolicy(
url: string | null,
env: NodeJS.ProcessEnv = process.env,
timeoutMs: number = 10_000,
): MemorableRecallPolicy {
if (!hasRepoPolicyStore(env)) return { tier: "none", memorableOptIn: false };
if (!url) return { tier: "none", memorableOptIn: false };
const [cmd, args]: [string, string[]] =
process.platform === "win32"
? ["bash", [POLICY_SCRIPT, "memorable", "get", url]]
: [POLICY_SCRIPT, ["memorable", "get", url]];
const res = spawnSync(cmd, args, {
encoding: "utf-8",
timeout: Math.max(1, timeoutMs),
env: { ...env } as NodeJS.ProcessEnv,
});
if (res.error) {
const code = (res.error as NodeJS.ErrnoException).code;
return { tier: "none", memorableOptIn: false, error: code === "ENOENT" ? "spawn-failed" : "unreadable" };
}
const unreadable: MemorableRecallPolicy = { tier: "none", memorableOptIn: false, error: "unreadable" };
if (res.status !== 0) return unreadable;
const match = /^(deny|read-only|read-write|unset)\t(allow|off)\n?$/.exec(res.stdout || "");
if (!match) return unreadable;
const tier = match[1] === "unset" ? "none" : (match[1] as RepoPolicyTierValue);
return { tier, memorableOptIn: tier === "read-only" && match[2] === "allow" };
}
/**
* Bulk trust-tier lookup via `gstack-gbrain-repo-policy get --batch` — ONE
* spawn total for the whole url list (memory-ingest checks every distinct
+5 -1
View File
@@ -73,6 +73,10 @@ export interface PregateFile {
schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number;
// C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict.
head?: string; base?: string; generated_at?: string; policy?: string; registry?: string; requires_remote?: PregateRemote[]; verdict?: string;
/** sha256 of the user-owned policy when `policy` is `user <path>`; verify re-checks it. */
policy_digest?: string;
/** Under the defaults, the user-owned policy path that was looked for and absent; verify checks it is still absent. */
policy_user_path?: string;
}
export interface ShipReceipt {
schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string;
@@ -189,7 +193,7 @@ export const ARTIFACT_SCHEMAS = {
properties: {
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList } } },
head: str, base: str, generated_at: str, policy: str, registry: str, verdict: str,
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str,
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str } } },
},
},
-1
View File
@@ -178,7 +178,6 @@ function openaiLifecycle(text: string): ParsedSource {
if (!upcoming || !modelTables.length) throw new Error('missing-deprecation-tables');
const lifecycle = modelTables.flatMap(table => table.slice(1).flatMap(row => {
if (/^(?:Videos API|Assistants API|OpenAI-Beta: |New fine-tuning training on )/.test(row[1]) || row[1].startsWith('`/')) return [];
if (!row[1].startsWith('`')) throw new Error('unrecognized-deprecated-model-cell');
const floor = row[0].startsWith('at earliest ');
const date = lifecycleDate(row[0].replace(/^at earliest /, ''));
if (!date) throw new Error('missing-shutdown-date');
+2 -1
View File
@@ -263,7 +263,8 @@ export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewC
}
if (!reviewed) return result(blocking ? 'findings' : 'clean');
const unparsed = firstUnparsed(code);
const notReady = !findings.highest && verdictNotReady(plainCode);
// A last explicit not-ready blocks even when the only parsed severities are advisory.
const notReady = verdictNotReady(plainCode);
if (blocking || notReady) return { ...result('findings'), ...(unparsed ? { unparsed } : {}) };
if (unparsed) return { ...result('unverified', 'unparsed_severity', unparsed), unparsed };
if ((input.gate === 'structured' || input.gate === 'review') && !findings.highest && !concludesNoFindings(code)) {
+1 -1
View File
@@ -90,7 +90,7 @@ export function renderBrief(inputs: BriefInputs, o: { page?: number; summary?: s
'',
renderDecisions(inputs, page).trimEnd(),
'',
`Full review: ${record}${recordNote}. Answer with \`gstack-autoplan answer --out <dir> --gate-rev ${inputs.gate.gate_rev} --reply "<all | d3b uc1a | all except d3b>" --by human\`.`,
`Full review: ${record}${recordNote}. Answer with \`gstack-autoplan answer --out <dir> --gate-rev ${inputs.gate.gate_rev} --reply "<rec | rec except d3b | d3b uc1a | as-is>" --by human\`.`,
'',
].join('\n');
}
+121 -24
View File
@@ -16,9 +16,10 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { baseInputs, sha256 } from '../context';
import { PREGATE_CONFIG_PATH } from '../config';
import { isDeviceDart, readRepoFile } from '../import-graph';
import { isDeviceDart, listRepoFiles, readRepoFile } from '../import-graph';
import { selectLanes, type Selection } from '../lane-selection';
import { launchRuntime } from '../runtime';
import { cargoModel, type CargoModel } from '../rust';
import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
import { matchesAny } from '../../ship-policy';
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
@@ -42,21 +43,24 @@ export function detectRunner(ctx: Pick<PregateContext, 'config' | 'repoRoot'>, f
/** One runner invocation: `files` relative to `cwd` (repo-relative, `''` for the root). */
export interface RunGroup { runner: string; cwd: string; files: string[] }
/** The nearest directory at or above `file` holding a pubspec.yaml, repo-relative; null outside any Dart package. `seen` memoises per directory. */
function dartRoot(repoRoot: string, file: string, seen: Map<string, string | null>): string | null {
/** The nearest directory at or above `file` holding `marker`, repo-relative; null when none does. `seen` memoises per directory (one map per marker). */
function nearestRoot(repoRoot: string, file: string, marker: string, seen: Map<string, string | null>): string | null {
const walked: string[] = [];
let found: string | null = null;
for (let d = path.posix.dirname(file); ; d = path.posix.dirname(d)) {
const rel = d === '.' ? '' : d;
if (seen.has(rel)) { found = seen.get(rel)!; break; }
walked.push(rel);
if (fs.existsSync(path.join(repoRoot, rel, 'pubspec.yaml'))) { found = rel; break; }
if (fs.existsSync(path.join(repoRoot, rel, marker))) { found = rel; break; }
if (d === '.' || d === '/' || d === '') break;
}
for (const w of walked) seen.set(w, found);
return found;
}
/** The nearest directory at or above `file` holding a pubspec.yaml, repo-relative; null outside any Dart package. */
function dartRoot(repoRoot: string, file: string, seen: Map<string, string | null>): string | null { return nearestRoot(repoRoot, file, 'pubspec.yaml', seen); }
/** An fvm pin (`.fvmrc` or `.fvm/fvm_config.json`) at the package root `root` or any ancestor up to the repo root. */
function fvmPinned(repoRoot: string, root: string): boolean {
for (let d = root; ; d = path.posix.dirname(d)) {
@@ -72,6 +76,9 @@ function fvmPinnedFor(repoRoot: string, files: readonly string[]): boolean {
return files.some(f => { const root = f.endsWith('.dart') ? dartRoot(repoRoot, f, seen) : null; return root !== null && fvmPinned(repoRoot, root); });
}
/** Whether `bin` is on the PATH of `env` (the PATH the runner will get). */
const onPath = (bin: string, env: NodeJS.ProcessEnv) => !!Bun.which(bin, { PATH: env.PATH ?? '' });
/**
* `flutter test` for a package on the Flutter SDK, else `dart test`; through fvm
* when the package or an ancestor up to the repo root pins one and fvm is on PATH. `missing` names the tool when
@@ -82,48 +89,129 @@ export function dartRunner(repoRoot: string, root: string, env: NodeJS.ProcessEn
const pubspec = readRepoFile(repoRoot, path.posix.join(root, 'pubspec.yaml')) ?? '';
// `sdk: flutter` (the key optionally quoted) under any dependency name (flutter, flutter_test, ...), block style or inside a flow mapping a key opens, comments stripped.
const tool = /^[ \t]*(['"]?)sdk\1:[ \t]*['"]?flutter\b|^[ \t]*[\w-]+:[ \t]*\{(?:[^\n]*[{,])?[ \t]*(['"]?)sdk\2:[ \t]*['"]?flutter\b/m.test(pubspec.replace(/(^|\s)#.*$/gm, '$1')) ? 'flutter' : 'dart';
const onPath = (bin: string) => !!Bun.which(bin, { PATH: env.PATH ?? '' });
const fvm = fvmPinned(repoRoot, root) && onPath('fvm');
if (!fvm && !onPath(tool)) return { missing: tool };
const fvm = fvmPinned(repoRoot, root) && onPath('fvm', env);
if (!fvm && !onPath(tool, env)) return { missing: tool };
return { runner: `${fvm ? 'fvm ' : ''}${tool} test {files}` };
}
/** The run plan: the groups that can run, the files no runner claims, Dart SDKs missing from PATH, and `.dart` files with no pubspec.yaml above. */
/**
* `cargo test -p <name>` for a crate (cargo runs a crate's tests, never a file list), or the missing tool when cargo is
* not on PATH. The name comes from the repo's Cargo.toml and reaches a shell and the `{files}` expansion: a name outside
* Cargo's grammar (letters, digits, `_`, `-`) gets no runner (null), and a valid one is still quoted like every file and cwd.
*/
function cargoRunner(name: string, env: NodeJS.ProcessEnv): { runner: string } | { missing: string } | null {
if (!/^[\p{ID_Continue}-]+$/u.test(name)) return null;
return onPath('cargo', env) ? { runner: `cargo test -p ${quote(name)}` } : { missing: 'cargo' };
}
type PackageJson = { packageManager?: string; devDependencies?: Record<string, string>; dependencies?: Record<string, string>; scripts?: Record<string, string> };
function readPackageJson(repoRoot: string, dir: string): PackageJson | null {
try { return JSON.parse(readRepoFile(repoRoot, path.posix.join(dir, 'package.json')) ?? 'null'); } catch { return null; }
}
const LOCKFILES: Array<[string, string]> = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm']];
const PM_EXEC: Record<string, string> = { pnpm: 'pnpm exec', yarn: 'yarn', bun: 'bun x', npm: 'npx' };
/** The package manager for a package: the first `packageManager` field or lockfile from its directory up to the repo root; npm when none. */
function packageManager(repoRoot: string, dir: string): string {
for (let d = dir; ; d = path.posix.dirname(d)) {
const rel = d === '.' ? '' : d;
const declared = /^(pnpm|yarn|bun|npm)@/.exec(readPackageJson(repoRoot, rel)?.packageManager ?? '')?.[1];
if (declared) return declared;
const lock = LOCKFILES.find(([f]) => fs.existsSync(path.join(repoRoot, rel, f)));
if (lock) return lock[1];
if (rel === '') return 'npm';
}
}
/**
* The runner a nested package (not the repo root) declares for itself: vitest
* or jest in its dependencies or its `test` script, through the package manager
* its `packageManager` field or nearest lockfile names; null when it declares
* neither, so its files stay with the root runner. `missing` names a package
* manager that is not on PATH.
*/
function packageRunner(repoRoot: string, dir: string, env: NodeJS.ProcessEnv): { runner: string } | { missing: string } | null {
const pkg = readPackageJson(repoRoot, dir);
if (!pkg) return null;
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
const script = pkg.scripts?.test ?? '';
const tool = deps.vitest || /\bvitest\b/.test(script) ? 'vitest run' : deps.jest || /\bjest\b/.test(script) ? 'jest' : null;
if (!tool) return null;
const pm = packageManager(repoRoot, dir);
const bin = pm === 'npm' ? 'npx' : pm;
if (!onPath(bin, env)) return { missing: bin };
return { runner: `${PM_EXEC[pm]} ${tool} {files}` };
}
/** The run plan: the groups that can run, the files no runner claims, tools missing from PATH, and `.dart` files with no pubspec.yaml above. */
export interface RunPlan { groups: RunGroup[]; unclaimed: string[]; missing: string[]; noPubspec: string[] }
/**
* The invocations that run `files`. A declared runner takes every file. Otherwise
* Dart tests run from their own package root (a sub-package's imports resolve
* against its own pubspec), and the rest go to the detected runner. Files no
* runner claims (a Dart SDK missing from PATH, a `.dart` file outside any
* package, no detected runner) are `unclaimed`: the lane is `incomplete`, never a pass.
* against its own pubspec); Rust tests run per crate as `cargo test -p <name>`
* from the crate's workspace root; JS/TS tests owned by a nested package that
* declares vitest or jest run from that package; the rest go to the detected
* runner from the repo root. Files no runner claims (a tool missing from PATH,
* a `.dart` file outside any package, a `.rs` file outside any crate or in a
* crate whose name Cargo would reject, no detected runner) are `unclaimed`: the lane is `incomplete`, never a pass.
*/
export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan {
if (ctx.config.runner) return { groups: [{ runner: ctx.config.runner, cwd: '', files: [...files] }], unclaimed: [], missing: [], noPubspec: [] };
const groups: RunGroup[] = [];
const dart = new Map<string, string[]>();
const cargo = new Map<string, { name: string; cwd: string; files: string[] }>();
const packages = new Map<string, string[]>();
const seen = new Map<string, string | null>();
const seenPackage = new Map<string, string | null>();
let model: CargoModel | null = null;
const pkgRunners = new Map<string, ReturnType<typeof packageRunner>>();
const rest: string[] = [];
const noPubspec: string[] = [];
const unclaimed: string[] = [];
const missing = new Set<string>();
const add = <T>(map: Map<string, T[]>, key: string, value: T) => { let list = map.get(key); if (!list) map.set(key, (list = [])); list.push(value); };
const under = (root: string, f: string) => (root ? f.slice(root.length + 1) : f);
for (const f of files) {
if (f.endsWith('.rs')) {
model ??= cargoModel(listRepoFiles(ctx.repoRoot), rel => readRepoFile(ctx.repoRoot, rel));
const crate = model.crateOf(f);
const cwd = crate?.workspace ?? null;
if (!crate || cwd === null) { unclaimed.push(f); continue; }
const key = `${cwd}\0${crate.name}`;
if (!cargo.has(key)) cargo.set(key, { name: crate.name, cwd, files: [] });
cargo.get(key)!.files.push(under(cwd, f));
continue;
}
const pkg = /\.[cm]?[jt]sx?$/.test(f) ? nearestRoot(ctx.repoRoot, f, 'package.json', seenPackage) : null;
if (pkg) {
if (!pkgRunners.has(pkg)) pkgRunners.set(pkg, packageRunner(ctx.repoRoot, pkg, ctx.env));
const r = pkgRunners.get(pkg);
if (r && 'missing' in r) { missing.add(r.missing); unclaimed.push(f); continue; }
if (r) { add(packages, pkg, under(pkg, f)); continue; }
}
if (!f.endsWith('.dart')) { rest.push(f); continue; }
const root = dartRoot(ctx.repoRoot, f, seen);
if (root === null) { noPubspec.push(f); continue; }
let list = dart.get(root);
if (!list) dart.set(root, (list = []));
list.push(root ? f.slice(root.length + 1) : f);
add(dart, root, under(root, f));
}
const unclaimed: string[] = [];
const missing = new Set<string>();
for (const [root, list] of [...dart].sort(([a], [b]) => a.localeCompare(b))) {
const r = dartRunner(ctx.repoRoot, root, ctx.env);
if ('missing' in r) { missing.add(r.missing); unclaimed.push(...list.map(f => (root ? `${root}/${f}` : f))); } else groups.push({ runner: r.runner, cwd: root, files: list });
}
for (const [, c] of [...cargo].sort(([a], [b]) => a.localeCompare(b))) {
const r = cargoRunner(c.name, ctx.env);
if (r && 'runner' in r) { groups.push({ runner: r.runner, cwd: c.cwd, files: c.files }); continue; }
if (r) missing.add(r.missing);
unclaimed.push(...c.files.map(f => (c.cwd ? `${c.cwd}/${f}` : f)));
}
if (rest.length) {
const runner = detectRunner(ctx, rest);
if (runner) groups.push({ runner, cwd: '', files: rest });
else unclaimed.push(...rest);
}
for (const [root, list] of [...packages].sort(([a], [b]) => a.localeCompare(b))) groups.push({ runner: (pkgRunners.get(root) as { runner: string }).runner, cwd: root, files: list });
return { groups, unclaimed: [...unclaimed, ...noPubspec], missing: [...missing].sort(), noPubspec };
}
@@ -132,8 +220,8 @@ function planGap(ctx: PregateContext, plan: RunPlan, total: number): Pick<CheckR
const { unclaimed, missing, noPubspec } = plan;
const detail = `no test runner detected for ${unclaimed.length} of ${total} selected file(s): ${unclaimed.slice(0, 8).join(' ')}${unclaimed.length > 8 ? ' …' : ''}${missing.length ? `; not on PATH: ${missing.join(', ')}` : ''}${noPubspec.length ? `; no pubspec.yaml above ${noPubspec.length} .dart file(s)` : ''}`;
const fix = missing.length ? `install ${missing.join(' and ')}${fvmPinnedFor(ctx.repoRoot, unclaimed) ? ' (or fvm, which this repo pins)' : ''} and rerun`
: noPubspec.length ? 'commit the Dart package\'s pubspec.yaml, or declare `runner` (with {files}) in .gstack/pregate.json'
: 'declare `runner` (with {files}) in .gstack/pregate.json';
: noPubspec.length ? `commit the Dart package's pubspec.yaml, or declare \`runner\` (with {files}) in ${ctx.configFile}`
: `declare \`runner\` (with {files}) in ${ctx.configFile}`;
return { code: 'PREGATE_INCOMPLETE', detail, fix };
}
@@ -153,9 +241,18 @@ function parseDart(output: string): RunnerSummary | null {
return d ? { ran: Number(d[1]) + Number(d[2] ?? 0), failed: Number(d[2] ?? 0) } : null;
}
/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test. A bats or dart/flutter runner's summary is tried first; for any other runner those formats are a last resort. `ran: null` when not recognised. Colour codes are dropped and a carriage return ends a line, so a coloured or redrawn verdict still reads. */
/** cargo test: one `test result: ok|FAILED. N passed; M failed; …` line per test binary (unit tests, each integration test, doc-tests), summed; ignored and filtered-out tests did not run. */
function parseCargo(output: string): RunnerSummary | null {
const lines = [...output.matchAll(/^test result: \w+\. (\d+) passed; (\d+) failed;/gm)];
if (!lines.length) return null;
const failed = lines.reduce((n, m) => n + Number(m[2]), 0);
return { ran: lines.reduce((n, m) => n + Number(m[1]), 0) + failed, failed };
}
/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test, cargo test. A bats, dart/flutter or cargo runner's summary is tried first (cargo's only there: its `N passed` lines would misread as another runner's); for any other runner the bats and dart formats are a last resort. `ran: null` when not recognised. Colour codes are dropped and a carriage return ends a line, so a coloured or redrawn verdict still reads. */
export function parseSummary(raw: string, runner = ''): RunnerSummary {
const output = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '\n');
if (/(?<![\w-])cargo\s+test(?![\w-])/.test(runner)) return parseCargo(output) ?? { ran: null, failed: null };
if (/(?<![\w-])bats(?![\w-])/.test(runner)) { const b = parseBats(output); if (b) return b; }
const dartRun = /(?<![\w-])(?:dart|flutter)(?![\w-])/.test(runner);
if (dartRun) { const d = parseDart(output); if (d) return d; }
@@ -221,7 +318,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
const timedOut = r.runs.filter(x => x.outcome === 'timeout');
const rest = r.failed ? `; ${r.failed} failed` : '';
if (stalled.length) {
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in .gstack/pregate.json; 0 disables) for a test that is quiet for longer` };
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that is quiet for longer` };
}
if (!timedOut.length) return { code: 'PREGATE_INCOMPLETE', detail: `the runner could not be launched (${total} files${rest})`, fix: 'read the lane log, fix the runner command, then rerun gstack-pregate --stage tests' };
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
@@ -282,7 +379,7 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string
*/
async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult): Promise<CheckResult> {
const lines = base.lines ?? [];
if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: 'declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in .gstack/pregate.json on the base branch, or run the tests locally without --remote' };
if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: `declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in ${ctx.configFile === PREGATE_CONFIG_PATH ? `${PREGATE_CONFIG_PATH} on the base branch` : ctx.configFile}, or run the tests locally without --remote` };
const obligation = remoteObligation(ctx, sel);
const cap = ctx.remote!.spendCapUsd;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-remote-'));
@@ -314,7 +411,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
const lines = describe(sel);
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines, remote: sel.remote };
if (sel.noLane.length) {
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(sel.noLane) };
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) };
}
if (sel.tests.length === 0) {
const inert = sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : '';
@@ -352,9 +449,9 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
return { ...withRun, status: sel.remote.length ? 'requires-remote' : 'pass', detail };
}
function noLaneFix(noLane: readonly string[]): string {
function noLaneFix(ctx: PregateContext, noLane: readonly string[]): string {
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) and let CI run them` : '';
return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${PREGATE_CONFIG_PATH}${device}`;
return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}${device}`;
}
export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run };
+2 -2
View File
@@ -31,8 +31,8 @@ function run(ctx: PregateContext): CheckResult {
if (!baseDirs.has(top)) warnings.push(`${file}: new top-level directory ${top}/ (not on ${ctx.baseRef})`);
}
ctx.explain.push(`strays: base directories ${[...baseDirs].join(',')}; allow ${allow.join(',') || '(none)'}`);
if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: 'delete or move the file; an intentional root file goes in .gstack/pregate.json strays.allow' };
if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: 'confirm the directory is intended, or allow it in .gstack/pregate.json strays.allow' };
if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: `delete or move the file; an intentional root file goes in ${ctx.configFile} strays.allow` };
if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: `confirm the directory is intended, or allow it in ${ctx.configFile} strays.allow` };
return { id: 'strays', stage: 'preflight', status: 'pass', inputs, detail: `${ctx.touched.filter(t => t.status === 'A').length} added file(s), none stray` };
}
+61 -7
View File
@@ -16,6 +16,9 @@
* warn check ids downgraded to warn (never `secrets`; `lanes` keeps
* its required-lane and requires-remote verdicts)
* strays.allow root files or new top-level directories that are intentional
* local `<workflow.yml>/<job>` lanes a local run satisfies (a
* platform-agnostic toolchain, or a repo whose CI is off):
* their tests run here instead of becoming requires-remote
* guards `{ id, when: [globs], command, fix?, timeout_s? }`: a repo
* command that must pass when a touched file matches `when`
* (manifest checks, CLI goldens); never downgradable
@@ -30,10 +33,16 @@
* remote_gate the repo's remote test gate command: under 8 cores the test
* tier is a requires-remote obligation naming it, and
* `--remote` runs it (it prints `PREGATE_REMOTE: tree=… tests=… failed=…`)
*
* A repo that keeps `.gstack/` out of git can hold the same file at
* `<state root>/projects/<slug>/pregate.json` (lib/state-root.ts), honoured
* only when the base carries none. It must sit outside the repo, so a PR
* cannot reach it; a path inside the checkout is ignored (pregate/context).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import type { ResultCodeName } from '../result-codes';
import { sealedErrors } from '../sealed-guard';
import { containedRelative, globToRegExp } from '../ship-policy';
@@ -52,6 +61,7 @@ export interface PregateConfig {
selection: Record<string, string>;
warn: string[];
strays: { allow: string[] };
local: string[];
guards: Guard[];
serial_patterns: string[];
price_file: string | null;
@@ -59,12 +69,13 @@ export interface PregateConfig {
remote_gate: string | null;
}
export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number }
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
export interface ConfigError { code: ResultCodeName; path: string; message: string }
export const MAX_LIST = 256;
const MAX_COMMAND = 512;
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
const LANE_ID = /^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/;
const MAX_GUARDS = 32;
const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s'];
@@ -153,7 +164,7 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
const sel = obj.selection;
if (!sel || typeof sel !== 'object' || Array.isArray(sel)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'selection', message: 'must be an object of <workflow.yml>/<job> → command' });
else for (const [lane, command] of Object.entries(sel as Record<string, unknown>)) {
if (!/^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be <workflow file>/<job id>' });
if (!LANE_ID.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be <workflow file>/<job id>' });
const why = commandError(command);
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: why });
}
@@ -176,6 +187,10 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
}
}
}
if ('local' in obj) {
if (!Array.isArray(obj.local) || obj.local.some(l => typeof l !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'local', message: 'must be an array of <workflow file>/<job id>' });
else (obj.local as string[]).forEach((l, i) => { if (!LANE_ID.test(l)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `local[${i}]`, message: `${JSON.stringify(l)} must be <workflow file>/<job id>` }); });
}
return errors;
}
@@ -186,14 +201,15 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []):
const errors = validateConfig(raw, knownChecks);
if (errors.length) return { config: DEFAULT_CONFIG, errors };
const o = raw as Partial<PregateConfig>;
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
}
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string };
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string };
function git(cwd: string, args: string[]): { ok: boolean; out: string } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
// r.error covers ENOBUFS, where stdout is cut at the buffer limit even with status 0.
return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim() };
}
export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: readonly string[] = []): ConfigLoad {
@@ -201,7 +217,12 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks:
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` };
const show = git(repoRoot, ['show', `${sha.out}:${PREGATE_CONFIG_PATH}`]);
if (!show.ok) return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' };
if (!show.ok) {
// Absent only when the tree says so: a timeout, an oversized blob or a missing/corrupt object must refuse, not hand the run to the user file.
const entry = git(repoRoot, ['ls-tree', '--name-only', sha.out, '--', PREGATE_CONFIG_PATH]);
if (!entry.ok || entry.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} could not be read and git cannot confirm it is absent` };
return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' };
}
return { ...parseConfig(show.out, knownChecks), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' };
}
@@ -211,6 +232,37 @@ export function loadConfigFromHead(repoRoot: string, knownChecks: readonly strin
return { ...parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' };
}
/** A symlink to nothing on the way to `file` (a dotfiles link not yet cloned): a policy the user meant to have, not an absent one. */
function danglingLink(file: string): boolean {
for (let p = file; path.dirname(p) !== p; p = path.dirname(p)) {
try { if (fs.lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() && !fs.existsSync(p)) return true; } catch { return true; }
}
return false;
}
/** Nothing at `file` and no dangling link on the way to it: the only state that means "no user policy", for the loader and verify alike. */
export function userPolicyAbsent(file: string): boolean {
try { fs.statSync(file); return false; } catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT' && !danglingLink(file); }
}
/** The user-owned copy for a repo that keeps `.gstack/` out of git; the caller consults it only when the base has none. */
export function loadConfigFromUser(file: string, knownChecks: readonly string[] = []): ConfigLoad | null {
let text: string;
try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT' && userPolicyAbsent(file)) return null;
// An unreadable policy must refuse, never fall back to defaults (its guards would silently vanish).
return { ...parseConfig('{}', knownChecks), errors: [{ code: 'PREGATE_CONFIG_INVALID', path: file, message: `cannot read the user-owned policy: ${(e as Error).message}` }], present: true, label: `user ${file}`, kind: 'user' };
}
const load = parseConfig(text, knownChecks);
// `sealed` (the pre-push guard) and `price_file` (gstack-eval-plan) read only the git copies, so a non-null value in the user file would look active and do nothing.
let raw: unknown = null;
try { raw = JSON.parse(text); } catch { /* parseConfig reported it */ }
const ignored = raw && typeof raw === 'object' && !Array.isArray(raw) ? ['sealed', 'price_file'].filter(k => (raw as Record<string, unknown>)[k] != null) : [];
// parseConfig names the repo copy for a JSON error; this file is not it.
const errors = [...load.errors.map(e => e.path === PREGATE_CONFIG_PATH ? { ...e, path: file } : e), ...ignored.map(k => ({ code: 'PREGATE_CONFIG_INVALID' as const, path: k, message: `the user-owned policy covers gstack-pregate run only; ${k} is read from ${PREGATE_CONFIG_PATH} on the base branch` }))];
return { ...load, errors, present: true, label: `user ${file}`, kind: 'user', digest: createHash('sha256').update(text).digest('hex') };
}
export function isConfigUnavailable(load: ConfigLoad): load is { unavailable: true; base: string; detail: string } {
return (load as any).unavailable === true;
}
@@ -223,11 +275,13 @@ export function explainConfig(): string {
'selection: <workflow.yml>/<job> → the runner-owned command that lists that lane’s test files when its matrix is planner-computed',
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`,
'strays.allow: root files or new top-level directories that are intentional',
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)',
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
'no .gstack/pregate.json on the base: <state root>/projects/<slug>/pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)',
].join('\n') + '\n';
}
+79 -7
View File
@@ -9,13 +9,16 @@
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { slugFromEnvironment } from '../bin-context';
import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type Registry } from '../regen';
import { resolveStateRoot } from '../state-root';
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, type PregateConfig } from './config';
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
import type { CheckResult, PregateContext, TouchedFile } from './types';
import { discoverLanes } from './workflows';
import type { RegistryError } from '../regen';
@@ -27,6 +30,8 @@ export interface BuildContextOptions {
stallTimeoutMs?: number; progress?: (line: string) => void; cores?: number; testBackend?: string; remote?: { spendCapUsd: number } | null;
/** How gstack's own bash bins launch (gstack-wtree, gstack-config); tests inject a Windows probe. */
invocation?: InvocationOptions;
/** `<state root>/projects/<slug>/pregate.json`: read only when the base carries no pregate.json, and never from a relative path or one inside the repository. */
userConfigPath?: string;
}
export const DEFAULT_STALL_TIMEOUT_S = 300;
@@ -123,6 +128,61 @@ export function touchedFiles(repoRoot: string, mergeBase: string): TouchedFile[]
return [...out.values()].filter(t => t.path !== 'node_modules' && !t.path.startsWith('node_modules/')).sort((a, b) => a.path.localeCompare(b.path));
}
/** The deepest existing ancestor realpathed (on-disk casing), the rest re-joined: a symlinked state root inside the checkout still counts as inside. */
function realish(p: string): string {
let head = path.resolve(p); const tail: string[] = [];
while (!fs.existsSync(head) && path.dirname(head) !== head) { tail.unshift(path.basename(head)); head = path.dirname(head); }
try { return path.join(fs.realpathSync.native(head), ...tail); } catch { return path.resolve(p); }
}
/**
* Every location the path to `p` passes through, each symlink followed one hop at a time: a link inside the checkout picks
* the file even when the last hop points back outside it. A missing component ends the walk with the rest joined lexically.
*/
function resolutionChain(p: string): string[] {
const chain: string[] = [];
let cur = path.parse(path.resolve(p)).root;
let rest = path.relative(cur, path.resolve(p)).split(path.sep).filter(Boolean);
for (let hops = 0; rest.length && hops <= 40;) {
const next = path.join(cur, rest.shift()!);
let st: fs.Stats | undefined;
try { st = fs.lstatSync(next, { throwIfNoEntry: false }); } catch { st = undefined; }
chain.push(st ? next : path.join(next, ...rest));
if (!st) break;
if (!st.isSymbolicLink()) { cur = next; continue; }
hops++;
const target = path.resolve(cur, fs.readlinkSync(next));
cur = path.parse(target).root;
rest = [...path.relative(cur, target).split(path.sep).filter(Boolean), ...rest];
}
return chain;
}
const within = (root: string, p: string) => { const rel = path.relative(root, p); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); };
/** Dotenv files Bun loads from the launch directory unless started with --no-env-file. */
const DOTENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local', '.env.production', '.env.production.local', '.env.test', '.env.test.local'];
/**
* The dotenv files in `cwd` that Bun loaded (no --no-env-file in `execArgv`). Any of them could set the slug or state
* root that picks the user policy, and Bun's dotenv syntax is looser than a key scan can follow, so any one refuses.
*/
export function loadedDotenvFiles(cwd: string, execArgv: readonly string[]): string[] {
if (execArgv.includes('--no-env-file')) return [];
return DOTENV_FILES.filter(f => fs.existsSync(path.join(cwd, f)));
}
/** `<state root>/projects/<slug>/pregate.json` for the repository at `root`, from the caller's environment. */
export function defaultUserConfigPath(root: string): string {
return path.join(resolveStateRoot(), 'projects', slugFromEnvironment(undefined, root), 'pregate.json');
}
/** Why a user policy path must not be read, or null: a relative path (no HOME) or one a PR could commit is never trusted. */
export function userConfigRejection(repoRoot: string, file: string): string | null {
if (!path.isAbsolute(file)) return 'not an absolute path';
const repo = path.resolve(repoRoot), real = realish(repoRoot);
if (within(repo, path.resolve(file)) || [file, ...resolutionChain(file)].some(p => within(real, realish(p)))) return 'inside the repository; a PR could supply it';
return null;
}
export function buildContext(o: BuildContextOptions): ContextLoad {
const base = o.base.replace(/^origin\//, '');
const baseRef = `origin/${base}`;
@@ -130,9 +190,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
const configLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
let configLoad: ConfigLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
const explain: string[] = [];
let absentUserPolicy: string | undefined;
if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') {
const rejected = userConfigRejection(o.repoRoot, o.userConfigPath);
const user = rejected ? null : loadConfigFromUser(o.userConfigPath, o.knownChecks);
if (user) configLoad = user;
else explain.push(`user policy: ${o.userConfigPath} ${rejected ? `ignored (${rejected})` : '(absent)'}`);
if (!user && !rejected) absentUserPolicy = o.userConfigPath;
}
if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } };
if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors } };
if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors, ...(configLoad.kind === 'user' ? { fix: `edit ${o.userConfigPath} directly at each path the message names, then rerun gstack-pregate; it lives outside the repository, so a base-branch change does not reach it` } : {}) } };
const registryLoad = o.policyFrom === 'head' ? loadRegistryFromHead(o.repoRoot) : loadRegistryFromBase(o.repoRoot, base);
if (isRegistryUnavailable(registryLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: registryLoad.detail } };
if (registryLoad.errors.length) return { refused: { code: 'REGEN_REGISTRY_INVALID', message: `${registryLoad.source.label}: ${registryLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: registryLoad.errors } };
@@ -142,15 +211,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
const wtree = fp.tree;
const platform = o.platform ?? process.platform;
const config = configLoad.config as PregateConfig;
const lanes = discoverLanes(o.repoRoot, platform);
// A lane that exists but is not platform-tier on this host already runs here, so only an unknown id is worth a line.
for (const id of config.local) if (!lanes.some(l => l.id === id)) explain.push(`local: ${id} matches no workflow lane; ignored`);
return {
ctx: {
repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase),
config, configLabel: configLoad.label, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
lanes: discoverLanes(o.repoRoot, platform), platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
config, configLabel: configLoad.label, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.kind === 'user' && configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000,
progress: o.progress ?? (line => process.stderr.write(`${line}\n`)),
cores: o.cores ?? os.availableParallelism(), testBackend: o.testBackend ?? readTestBackend(env, o.invocation), policyFrom: o.policyFrom, remote: o.remote ?? null,
env, explain: [],
env, explain,
},
};
}
@@ -159,7 +231,7 @@ export function sha256(text: string): string { return createHash('sha256').updat
/** The common input identity every check records; checks add their own keys. */
export function baseInputs(ctx: PregateContext, extra: Record<string, string> = {}): Record<string, string> {
return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...extra };
return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...(ctx.configDigest ? { policy: ctx.configDigest } : {}), ...extra };
}
/**
+9 -2
View File
@@ -12,7 +12,10 @@
* 3. the `bin/<name>` → `test/<name>*.test.*` convention (with and without
* the `gstack-` prefix);
* 4. explicit `.gstack/pregate.json` `dependencies` declarations;
* 5. in a Django project, the files Django loads by name (lib/pregate/django.ts).
* 5. in a Django project, the files Django loads by name (lib/pregate/django.ts);
* 6. in a Cargo crate, every `.rs` file and the Cargo.toml select the crate's
* tests and its dependents' (lib/pregate/rust.ts); in-source
* `#[cfg(test)]` files and `<crate>/tests/` files are the tests.
* Dart directives (`import`/`export`/`part`, conditional URIs included) resolve
* `package:<name>/x` through every tracked pubspec.yaml to `<root>/lib/x`.
* Uncertainty selects more tests, never fewer: a dependency on a directory
@@ -25,6 +28,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { matchesAny } from '../ship-policy';
import { djangoEdges } from './django';
import { rustEdges } from './rust';
export const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|sh|bash|go|rs|dart)$/;
export const TEST_FILE_RE = /(?:^|\/)(?:test|tests|spec|__tests__)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]*\.py$|_test\.(?:py|go|rb)$|_spec\.rb$|(?:^|\/)[^/]*\.bats$|(?:^|\/)test\/(?:.*\/)?[^/]*_test\.dart$/;
@@ -47,7 +51,7 @@ const scanner = new Bun.Transpiler({ loader: 'tsx' });
// that matches more text only gains an edge when it resolves to a repo file.
const QUOTED = /['"`]([A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)['"`]/g;
const JOIN = /\bjoin\(([^()]*)\)/g;
const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.mjs', '.cjs', '/index.ts', '/index.js'];
const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', '/index.mjs'];
// A statement starts a line or follows `;` or a compound statement's `:`. The module spec is
// non-empty and starts with a dot or a word character, so no two repetitions can share a dot or a space.
const PY_IMPORT = /(?:^|[;:])[ \t\f]*(?:from[ \t]+(\.+(?:\w[\w.]*)?|\w[\w.]*)[ \t]+import[ \t]+(\([^)]*\)|[^\n;]+)|import[ \t]+([^\n;]+))/gm;
@@ -465,6 +469,9 @@ export function buildGraph(repoRoot: string, opts: BuildOptions = {}): Graph {
for (const { target, via } of referencesIn(files, dirs, rel, source, config, graph.tests.has(rel) ? unresolved : undefined, packages)) addEdge(graph, target, rel, via);
if (unresolved.length) graph.unresolved!.set(rel, unresolved);
}
const rust = rustEdges(all, rel => readRepoFile(repoRoot, rel, maxBytes));
for (const t of rust.tests) graph.tests.add(t);
for (const e of rust.edges) addEdge(graph, e.dependency, e.dependent, e.via);
const django = djangoEdges({ files: [...files], tests: graph.tests, read: rel => pySources.get(rel) ?? readRepoFile(repoRoot, rel, maxBytes), code: pythonCode, resolveDotted: dotted => pythonPatchTarget(files, dotted) });
for (const e of django) addEdge(graph, e.dependency, e.dependent, 'framework');
for (const test of graph.tests) {
+32 -6
View File
@@ -22,8 +22,8 @@ import { regenCheck } from './checks/regen';
import { secretsCheck } from './checks/secrets';
import { straysCheck } from './checks/strays';
import { timingCheck } from './checks/timing';
import { NON_DOWNGRADABLE } from './config';
import { withFingerprint } from './context';
import { NON_DOWNGRADABLE, userPolicyAbsent } from './config';
import { sha256, withFingerprint } from './context';
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
@@ -57,7 +57,7 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<Pr
const remote: PregateRemote[] = checks.flatMap(c => c.remote ?? []).map(r => ({ ...r }));
const file: PregateFile = {
schema_version: 1, tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, generated_at: new Date().toISOString(),
policy: ctx.configLabel, registry: ctx.registryLabel, run: o.run,
policy: ctx.configLabel, ...(ctx.configDigest ? { policy_digest: ctx.configDigest } : {}), ...(ctx.absentUserPolicy ? { policy_user_path: ctx.absentUserPolicy } : {}), registry: ctx.registryLabel, run: o.run,
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' ? undefined : clocks.tests,
checks: checks.map(c => { const { remote: _r, ...rest } = c; return rest; }),
requires_remote: remote,
@@ -122,12 +122,17 @@ export interface Verification { ok: boolean; code?: 'PREGATE_STALE' | 'PREGATE_R
/**
* Publication check: the recorded tree must equal the tree now (results
* current after final generation and stamping), no check may be fail or
* incomplete, and every requires-remote obligation needs its receipt.
* incomplete, and every requires-remote obligation needs its receipt. The
* user-owned policy behind the run (or its absence, under the defaults) must
* still hold, since it lives outside the tree.
*/
export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: WtreeResult = wtreeFingerprint({ cwd: repoRoot })): Verification {
const uncleared = (file.requires_remote ?? []).filter(r => !r.receipt);
const current = 'tree' in treeNow && treeNow.tree === file.tree;
if (!current) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current, uncleared };
const treeCurrent = 'tree' in treeNow && treeNow.tree === file.tree;
if (!treeCurrent) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current: false, uncleared };
const policyChange = userPolicyChange(file);
if (policyChange) return { ok: false, code: 'PREGATE_STALE', message: policyChange, current: false, uncleared };
const current = true;
const failed = file.checks.filter(c => c.status === 'fail');
if (failed.length) return { ok: false, code: 'PREGATE_LANE_FAILED', message: `${failed.length} check(s) failed: ${failed.map(c => c.id).join(', ')}`, current, uncleared };
const incomplete = file.checks.filter(c => c.status === 'incomplete' || c.status === 'not_installed');
@@ -136,6 +141,27 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}`, current, uncleared };
}
/**
* Why the user-owned policy a run recorded no longer holds, or null. Both paths come from the record, never the verifier's
* environment: the policy file and its digest, or under the defaults the path that was looked for and absent.
*/
function userPolicyChange(file: PregateFile): string | null {
const recorded = file.policy?.startsWith('user ') ? file.policy.slice('user '.length) : null;
if (recorded) {
if (!file.policy_digest) return `pregate.json names the user-owned policy ${recorded} without its digest; rerun gstack-pregate`;
let text: string;
try { text = fs.readFileSync(recorded, 'utf8'); } catch { return `the user-owned policy ${recorded} that pregate.json was recorded under is gone or unreadable; rerun gstack-pregate`; }
return sha256(text) === file.policy_digest ? null : `the user-owned policy ${recorded} changed after pregate.json was recorded; rerun gstack-pregate`;
}
const absent = file.policy_user_path;
if (!absent) return null;
if (userPolicyAbsent(absent)) return null;
let leaf: fs.Stats | undefined;
try { leaf = fs.lstatSync(absent, { throwIfNoEntry: false }); } catch { leaf = undefined; }
if (!leaf) return `cannot confirm ${absent} is still absent (a link on the way to it is dangling or unreadable); rerun gstack-pregate`;
return `${absent} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`;
}
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string }
export type ClearDecision = { receipt: string; attested: boolean } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string };
+3 -2
View File
@@ -72,13 +72,14 @@ export function draftConfig(repoRoot: string): InitDraft {
}
const groups = new Map<string, string[]>();
for (const t of tests) { const lang = t.endsWith('.dart') ? 'dart' : t.endsWith('.py') ? 'python' : /\.[cm]?[jt]sx?$/.test(t) ? 'javascript' : 'other'; groups.set(lang, [...(groups.get(lang) ?? []), t]); }
for (const t of tests) { const lang = t.endsWith('.dart') ? 'dart' : t.endsWith('.rs') ? 'rust' : t.endsWith('.py') ? 'python' : /\.[cm]?[jt]sx?$/.test(t) ? 'javascript' : 'other'; groups.set(lang, [...(groups.get(lang) ?? []), t]); }
const notes = [...groups].map(([lang, list]) => {
if (lang === 'dart') return `${list.length} Dart test file(s) run per package with flutter test or dart test`;
if (lang === 'rust') return `${list.length} Rust test file(s) run per crate with cargo test -p`;
const runner = detectRunner({ repoRoot, config: DEFAULT_CONFIG }, list);
return runner ? `${lang} tests detected at run time: ${runner}` : `no runner detected for ${list.length} ${lang} test file(s); set runner (with {files})`;
});
const mixed = [...groups.keys()].filter(l => l !== 'dart').length > 1 ? '; a selection mixing these languages needs runner set' : '';
const mixed = [...groups.keys()].filter(l => l !== 'dart' && l !== 'rust').length > 1 ? '; a selection mixing these languages needs runner set' : '';
const runnerNote = notes.length ? notes.join('; ') + mixed : 'no test files found';
const selectionTodo = discoverLanes(repoRoot).filter(l => l.runsTests && l.matrixExpression && l.testFiles.length === 0).map(l => l.id);
+3 -2
View File
@@ -130,12 +130,13 @@ export function selectLanes(ctx: PregateContext): Selection {
const set = laneFileSet(ctx, lane);
if (set !== 'all') laneSets[lane.id] = set;
const inLane = set === 'all' ? selected : set === '?' ? selected : selected.filter(t => set.includes(t));
if (lane.tier === 'platform') {
if (lane.tier === 'platform' && ctx.config.local.includes(lane.id)) ctx.explain.push(`lane ${lane.id}: ${lane.platform} lane satisfied locally (pregate.json local)`);
else if (lane.tier === 'platform') {
if (inLane.length) remote.push({ lane: lane.id, platform: lane.platform, workflow: lane.workflow, job: lane.job, tests: inLane, receipt: null });
continue;
}
if (set === 'all') anyLocalAll = true;
if (inLane.length) { tiers.add(lane.tier); for (const t of inLane) local.add(t); }
if (inLane.length) { tiers.add(lane.tier === 'platform' ? 'unit' : lane.tier); for (const t of inLane) local.add(t); }
}
if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t);
for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t);
+258
View File
@@ -0,0 +1,258 @@
/**
* pregate/rust — Cargo crates for the import graph and the `lanes` runner.
* A crate is the nearest directory holding a Cargo.toml with a `[package]`
* name. Its `.rs` files are tests when they sit under `<crate>/tests/` or hold
* a `#[cfg(test)]` (in-source unit tests). Cargo runs tests per crate, never
* per file, so the graph maps at crate level: every `.rs` file of a crate,
* and its Cargo.toml, select that crate's tests and the tests of every crate
* that depends on it, transitively.
* Manifests are parsed as TOML (Bun.TOML, as lib/model-policy.ts does), so
* dotted keys, `[dependencies.<name>]` tables, target-specific tables and
* `{ workspace = true }` inheritance read as Cargo reads them. A dependency
* resolves by `path` (relative to the manifest declaring it) to a tracked
* crate, else by package name (`package = ` or the key); a name match only
* widens. The workspace a crate runs in follows Cargo's membership rules:
* `package.workspace`, else the nearest `[workspace]` above it, when that
* workspace is the crate's own manifest, lists it in `members` or reaches it
* by a path dependency, and does not `exclude` it; otherwise the crate
* directory. Nothing here runs cargo: pregate reads files only.
*/
import * as path from 'node:path';
/** Reads a repo-relative file's text, or null when it is absent or unreadable. */
export type ReadFile = (rel: string) => string | null;
type Table = Record<string, unknown>;
const isTable = (v: unknown): v is Table => typeof v === 'object' && v !== null && !Array.isArray(v);
const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
const DEP_TABLES = ['dependencies', 'dev-dependencies', 'build-dependencies'] as const;
/** One dependency: the crate directory its `path` names (repo-relative, normalized) and the package name it asks for. */
interface DepSpec { path: string | null; name: string }
export interface Crate {
root: string;
name: string;
/** Where `cargo test -p <name>` runs: the root of the workspace it is a member of, else the crate directory; null when membership is undecided. */
workspace: string | null;
/** Roots of the tracked crates it depends on. */
deps: string[];
}
export interface CargoModel { crates: Map<string, Crate>; crateOf(file: string): Crate | null }
interface Manifest { dir: string; toml: Table }
const dirOf = (manifest: string) => (path.posix.dirname(manifest) === '.' ? '' : path.posix.dirname(manifest));
/** `dir` joined with a manifest-relative path, normalized; null when it leaves the repository. */
function joinRel(dir: string, rel: string): string | null {
const joined = path.posix.normalize(path.posix.join(dir || '.', rel.replace(/\\/g, '/'))).replace(/\/+$/, '');
if (joined === '..' || joined.startsWith('../') || path.posix.isAbsolute(joined)) return null;
return joined === '.' ? '' : joined;
}
/** One glob component (`*`, `?`, `[...]` with `!`/`^` negation and ranges, else literal) against one path component, by Unicode code point and dynamic programming: O(pattern × name), no regex backtracking. */
function componentMatch(glob: string, name: string): boolean {
const g = Array.from(glob);
const s = Array.from(name);
const nextClose = new Array<number>(g.length + 1).fill(-1);
for (let i = g.length - 1; i >= 0; i--) nextClose[i] = g[i] === ']' ? i : nextClose[i + 1]!;
const tokens: Array<'*' | ((c: string) => boolean)> = [];
for (let i = 0; i < g.length; i++) {
const ch = g[i]!;
const negate = ch === '[' && /[!^]/.test(g[i + 1] ?? '');
const close = ch === '[' ? nextClose[i + (negate ? 3 : 2)] ?? -1 : -1;
if (ch === '*') tokens.push('*');
else if (ch === '?') tokens.push(() => true);
else if (close > 0) {
const body = g.slice(i + (negate ? 2 : 1), close).map(c => c.codePointAt(0)!);
tokens.push(c => {
const p = c.codePointAt(0)!;
let hit = false;
for (let j = 0; j < body.length; j++) {
if (body[j + 1] === 0x2d && j + 2 < body.length) { hit ||= p >= body[j]! && p <= body[j + 2]!; j += 2; } else hit ||= p === body[j];
}
return hit !== negate;
});
i = close;
} else tokens.push(c => c === ch);
}
let row = s.map(() => false).concat(false);
row[0] = true;
for (const t of tokens) {
const next = row.map(() => false);
next[0] = t === '*' && row[0]!;
for (let j = 1; j <= s.length; j++) next[j] = t === '*' ? row[j]! || next[j - 1]! : row[j - 1]! && t(s[j - 1]!);
row = next;
}
return row[s.length]!;
}
/** A Cargo `members` glob against a workspace-relative crate directory, component by component; `**` spans any number of components. */
function memberMatch(glob: string, rel: string): boolean {
const p = rel.split('/').filter(Boolean);
let row = p.map(() => false).concat(false);
row[0] = true;
for (const part of glob.split('/').filter(Boolean)) {
const next = row.map(() => false);
next[0] = part === '**' && row[0]!;
for (let j = 1; j <= p.length; j++) next[j] = part === '**' ? row[j]! || next[j - 1]! : row[j - 1]! && componentMatch(part, p[j - 1]!);
row = next;
}
return row[p.length]!;
}
/**
* Past this many characters of `members` globs a workspace's membership is undecided: every crate under it counts as a
* member for dependency edges (more tests, never fewer), but where its tests run is unknown, so they are unclaimed and the
* lane is incomplete (running from the wrong root can test a same-named member instead).
*/
const MAX_MEMBER_GLOB_CHARS = 4096;
const inside = (dir: string, root: string) => root === '' || dir === root || dir.startsWith(`${root}/`);
const relTo = (dir: string, root: string) => (root === '' ? dir : dir === root ? '' : dir.slice(root.length + 1));
/** Every dependency of a manifest: `[*dependencies]` and `[target.<cfg>.*dependencies]`, `{ workspace = true }` read from `ws`'s `[workspace.dependencies]` (its paths relative to the workspace root). */
function depSpecs(m: Manifest, ws: Manifest | null): DepSpec[] {
const tables: Table[] = [];
for (const key of DEP_TABLES) if (isTable(m.toml[key])) tables.push(m.toml[key] as Table);
if (isTable(m.toml.target)) for (const cfg of Object.values(m.toml.target)) if (isTable(cfg)) for (const key of DEP_TABLES) if (isTable(cfg[key])) tables.push(cfg[key] as Table);
const inherited = ws && isTable(ws.toml.workspace) && isTable(ws.toml.workspace.dependencies) ? ws.toml.workspace.dependencies : {};
const out: DepSpec[] = [];
for (const table of tables) {
for (const [key, raw] of Object.entries(table)) {
const fromWorkspace = isTable(raw) && raw.workspace === true;
const spec = fromWorkspace ? inherited[key] : raw;
const renamed = [spec, raw].find(s => isTable(s) && typeof s.package === 'string') as Table | undefined;
const p = isTable(spec) && typeof spec.path === 'string' ? joinRel(fromWorkspace ? ws!.dir : m.dir, spec.path) : null;
out.push({ path: p, name: (renamed?.package as string | undefined) ?? key });
}
}
return out;
}
/** The tracked crates among `files` (the paths `git ls-files` reports), what each depends on, and the workspace each runs in. */
export function cargoModel(files: readonly string[], read: ReadFile): CargoModel {
const manifests = new Map<string, Manifest>();
for (const f of files) {
if (path.posix.basename(f) !== 'Cargo.toml') continue;
const text = read(f);
if (text === null) continue;
try { const toml = Bun.TOML.parse(text); if (isTable(toml)) manifests.set(dirOf(f), { dir: dirOf(f), toml }); } catch { /* an unparsable manifest names no crate */ }
}
const nameOf = (dir: string) => { const pkg = manifests.get(dir)?.toml.package; return isTable(pkg) && typeof pkg.name === 'string' ? pkg.name : null; };
const roots = [...manifests.keys()].filter(d => nameOf(d) !== null);
const workspaces = [...manifests.values()].filter(m => isTable(m.toml.workspace));
const byName = new Map<string, string[]>();
for (const r of roots) byName.set(nameOf(r)!, [...(byName.get(nameOf(r)!) ?? []), r]);
// `package.workspace` names the workspace; otherwise Cargo consults only the nearest [workspace] at or above the crate.
const candidate = (root: string): Manifest | null => {
const pkg = manifests.get(root)!.toml.package as Table;
if (typeof pkg.workspace === 'string') { const w = manifests.get(joinRel(root, pkg.workspace) ?? '\0'); return w && isTable(w.toml.workspace) ? w : null; }
return workspaces.filter(w => inside(root, w.dir)).sort((a, b) => b.dir.length - a.dir.length)[0] ?? null;
};
const memberCache = new Map<string, Set<string>>();
const undecided = new Set<string>();
const membersOf = (ws: Manifest): Set<string> => {
const cached = memberCache.get(ws.dir);
if (cached) return cached;
const conf = ws.toml.workspace as Table;
const norm = (g: string) => joinRel('', g) ?? '\0';
const globs = strings(conf.members).map(norm);
const exclude = strings(conf.exclude).map(norm);
const own = new Set(roots.filter(r => candidate(r)?.dir === ws.dir));
const excluded = (r: string) => exclude.some(e => { const rel = relTo(r, ws.dir); return rel === e || rel.startsWith(`${e}/`); });
// Cargo's rules: the root package and a literally listed member always belong; a glob match or a path dependency of a
// member inside the workspace belongs unless `exclude` names it or a directory above it.
const literal = (r: string) => globs.some(g => !/[*?[]/.test(g) && relTo(r, ws.dir) === g);
const oversized = globs.join('').length > MAX_MEMBER_GLOB_CHARS;
const members = new Set([...own].filter(r => r === ws.dir || literal(r) || ((oversized || globs.some(g => memberMatch(g, relTo(r, ws.dir)))) && !excluded(r))));
if (oversized) for (const r of members) if (r !== ws.dir && !literal(r)) undecided.add(r);
memberCache.set(ws.dir, members);
const queue = [...members];
while (queue.length) {
for (const dep of depSpecs(manifests.get(queue.shift()!)!, ws)) {
if (dep.path === null || members.has(dep.path) || !own.has(dep.path) || !inside(dep.path, ws.dir) || excluded(dep.path)) continue;
members.add(dep.path);
queue.push(dep.path);
}
}
return members;
};
// A path names one crate; without a path (or when it names no tracked crate) every crate of that package name is a candidate.
const resolve = (specs: DepSpec[]) => [...new Set(specs.flatMap(s => (s.path !== null && nameOf(s.path) !== null ? [s.path] : byName.get(s.name) ?? [])))];
const crates = new Map<string, Crate>();
for (const root of roots) {
const ws = candidate(root);
const member = ws !== null && membersOf(ws).has(root) ? ws : null;
crates.set(root, { root, name: nameOf(root)!, workspace: undecided.has(root) ? null : member?.dir ?? root, deps: resolve(depSpecs(manifests.get(root)!, member)).filter(d => d !== root) });
}
const crateOf = (file: string): Crate | null => {
for (let d = path.posix.dirname(file); ; d = path.posix.dirname(d)) {
const c = crates.get(d === '.' ? '' : d);
if (c) return c;
if (d === '.' || d === '/' || d === '') return null;
}
};
return { crates, crateOf };
}
/**
* Whether Rust source declares tests: a `#[test]` or `#[<path>::test]` attribute (a plain test function, `#[tokio::test]`),
* or a `#[cfg(...)]` whose predicate names `test` outside a string (`cfg(test)`, `cfg(all(test, unix))`; never
* `cfg(feature = "test")`). One forward scan: each attribute is read once to its `]`, and one this scan cannot close
* (an unterminated string, no `]` before the end) counts as a test, which selects more, never fewer.
*/
export function hasCfgTest(source: string): boolean {
if (/#\s*\[\s*(?:[A-Za-z_]\w*\s*::\s*)*test\s*[\]()]/.test(source)) return true;
const open = /#\s*\[\s*cfg\s*\(/g;
for (let m = open.exec(source); m; m = open.exec(source)) {
let predicate = '';
let i = open.lastIndex;
for (; i < source.length && source[i] !== ']'; i++) {
if (source[i] !== '"') { predicate += source[i]; continue; }
const close = source.indexOf('"', i + 1);
if (close < 0) return true;
predicate += '""';
i = close;
}
if (i >= source.length) return true;
const body = predicate.trimEnd();
if (body.endsWith(')') && /\btest\b/.test(body.slice(0, -1))) return true;
open.lastIndex = i + 1;
}
return false;
}
export interface RustGraph {
tests: string[];
edges: Array<{ dependency: string; dependent: string; via: 'import' | 'convention' }>;
}
/**
* The Rust part of the reverse graph over the tracked `files`: each crate's
* test files, an edge from every `.rs` file of a crate to its Cargo.toml (the
* crate's node), and an edge from each Cargo.toml to the tests of its crate
* (`convention`) and of every crate depending on it, transitively (`import`).
*/
export function rustEdges(files: readonly string[], read: ReadFile): RustGraph {
const { crates, crateOf } = cargoModel(files, read);
const out: RustGraph = { tests: [], edges: [] };
if (!crates.size) return out;
const testsOf = new Map<string, string[]>([...crates.keys()].map(k => [k, []]));
const manifestOf = (root: string) => (root ? `${root}/Cargo.toml` : 'Cargo.toml');
for (const f of files) {
if (!f.endsWith('.rs')) continue;
const crate = crateOf(f);
if (!crate) continue;
out.edges.push({ dependency: f, dependent: manifestOf(crate.root), via: 'convention' });
if (f.startsWith(crate.root ? `${crate.root}/tests/` : 'tests/') || hasCfgTest(read(f) ?? '')) { out.tests.push(f); testsOf.get(crate.root)!.push(f); }
}
const dependents = new Map<string, Set<string>>();
for (const c of crates.values()) for (const d of c.deps) dependents.set(d, (dependents.get(d) ?? new Set()).add(c.root));
for (const root of crates.keys()) {
const reach = new Set<string>([root]);
const queue = [root];
while (queue.length) for (const next of dependents.get(queue.shift()!) ?? []) if (!reach.has(next)) { reach.add(next); queue.push(next); }
for (const r of reach) for (const t of testsOf.get(r)!) out.edges.push({ dependency: manifestOf(root), dependent: t, via: r === root ? 'convention' : 'import' });
}
return out;
}
+6
View File
@@ -33,6 +33,12 @@ export interface PregateContext {
touched: TouchedFile[];
config: PregateConfig;
configLabel: string;
/** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */
configFile: string;
/** sha256 of the user-owned policy's bytes when it is active; recorded as `inputs.policy` (a base copy is pinned by `inputs.base`). */
configDigest?: string;
/** The user-owned policy path looked for and absent under the defaults; recorded so verify can tell when one appears. */
absentUserPolicy?: string;
registry: Registry;
registryLabel: string;
lanes: Lane[];
+4 -2
View File
@@ -68,7 +68,7 @@ export interface RestampResult {
function sha256(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex'); }
function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): { ok: boolean; out: string; err: string; status: number | null } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env });
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
return { ok: r.status === 0, out: (r.stdout ?? '').replace(/\n$/, ''), err: (r.stderr ?? '').trim(), status: r.status };
}
class Refusal extends Error { constructor(public code: ResultCodeName, message: string, public extra: Partial<RestampResult> = {}) { super(message); } }
@@ -578,7 +578,9 @@ function gateAhead(o: RestampOptions, plan: Plan, stages: StageRecord[], common:
if (resolvable) {
for (const c of conflicts) {
if (c === plan.policy.changelog) {
const merged = mergeChangelogs(git(dir, ['show', `:2:${c}`]).out + '\n', git(dir, ['show', `:3:${c}`]).out + '\n');
const stage = (n: number) => git(dir, ['show', `:${n}:${c}`]);
const [base, ours, theirs] = [stage(1), stage(2), stage(3)];
const merged = ours.ok && theirs.ok ? mergeChangelogs(ours.out + '\n', theirs.out + '\n', base.ok ? base.out + '\n' : undefined) : null;
if (merged === null) { unresolved = [c]; break; }
fs.writeFileSync(path.join(dir, c), merged);
} else git(dir, ['checkout', '--theirs', '--', c]);
+2 -2
View File
@@ -166,7 +166,7 @@ export const RESULT_CODES = {
// C-stamp (plan C1, C2, C3, C5, C7): ship policy, restamp, tree receipt, ship receipt.
POLICY_SOURCE_UNAVAILABLE: {
anchor: 'policy-source-unavailable',
summary: 'policy source unavailable: origin/<base> is not a resolvable commit, so the reviewed .gstack/ship-policy.json cannot be read',
summary: 'policy source unavailable: origin/<base> is not a resolvable commit, or a pin file committed on it cannot be read, so the reviewed .gstack/ship-policy.json cannot be read',
fix: 'git fetch origin <base> --depth=1 (the working-tree copy is never used silently)',
},
POLICY_INVALID: {
@@ -364,7 +364,7 @@ export const RESULT_CODES = {
},
PREGATE_CONFIG_INVALID: {
anchor: 'pregate-config-invalid',
summary: '.gstack/pregate.json has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob',
summary: 'the pregate policy (.gstack/pregate.json, or the user-owned copy) has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob',
fix: 'run `gstack-pregate validate` and fix the file on the base branch (secrets and required lanes cannot be downgraded)',
},
PREGATE_SECRETS: {
+55 -9
View File
@@ -7,6 +7,7 @@ import { resultLine, type ResultCodeName } from './result-codes';
const DIFF_REVIEWS = new Set(['review', 'adversarial-review', 'codex-review', 'design-review-lite', 'ship']);
const SHARED_LIBS_COVERAGE_VERSION = 1;
const HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
function record(value: unknown): value is Record<string, any> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -163,18 +164,54 @@ export function checkSharedLibsReuse(finding: unknown, token: string, env = proc
return result;
}
export function captureReviewStart(skill: string, env = process.env): string {
/**
* #3339: a fix-range repeat reviews only what changed since the latest pass of
* the same skill, and only when that pass completed and made fixes on this
* branch, no Step 11 review followed it, and the content it started from is
* exactly its commit's tree. That commit, held in the review log rather than
* the working tree, is the base; it must still be an ancestor of HEAD, so
* `git diff <base>` holds every fix. Anything else needs a full pass.
*/
function fixRangeBase(skill: string, env: NodeJS.ProcessEnv): string {
const refuse = (reason: string): never => {
throw new Error(`gstack-review-log: fix-range refused: ${reason}; run a full pass (gstack-review-log --start ${skill})`);
};
let rows: Record<string, any>[] = [];
try {
rows = readFileSync(env.GSTACK_REVIEW_LOG!, 'utf8').split('\n').filter(Boolean).flatMap(line => {
try { const row = JSON.parse(line); return record(row) ? [row] : []; } catch { return []; }
});
} catch {}
const at = rows.findLastIndex(row => row.skill === skill);
if (at < 0) refuse('no earlier pass on this branch');
if (rows.slice(at + 1).some(row => row.skill === 'adversarial-review' || row.skill === 'codex-review')) refuse('a Step 11 review followed the latest pass');
const prior = rows[at]!, binding = prior.review_binding;
if (prior.completed !== true) refuse('the latest pass did not complete');
if (prior.converged !== false) refuse('the latest pass made no fixes');
if (!record(binding) || (binding.state !== 'changed' && binding.state !== 'incomplete') || typeof binding.start_wtree !== 'string') refuse('the latest pass has no captured start');
if (binding.branch_id !== sha256(env.GSTACK_REVIEW_BRANCH ?? '')) refuse('the latest pass is from another branch');
const base = prior.commit_full;
const git = (...args: string[]) => spawnSync('git', args, { cwd: env.GSTACK_REVIEW_REPO, encoding: 'utf8', timeout: 10_000 });
if (typeof base !== 'string' || !HASH.test(base) || git('cat-file', '-e', `${base}^{commit}`).status !== 0) refuse('the latest pass has no resolvable commit');
if (git('rev-parse', `${base}^{tree}`).stdout.trim() !== binding.start_wtree) refuse('the latest pass started from uncommitted content, so no commit marks what it read');
if (git('merge-base', '--is-ancestor', base, 'HEAD').status !== 0) refuse(`${base} is not an ancestor of HEAD`);
return base;
}
export function captureReviewStart(skill: string, env = process.env, fixRange = false): string {
if (!DIFF_REVIEWS.has(skill) || !env.GSTACK_STAMP_WTREE || !env.GSTACK_REVIEW_REPO) {
throw new Error('cannot capture a diff review without a working-tree fingerprint');
}
const fixBase = fixRange ? fixRangeBase(skill, env) : undefined;
const dir = join(env.GSTACK_REVIEW_DIR!, '.review-starts');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const token = crypto.randomUUID();
writeFileSync(join(dir, `${token}.json`), JSON.stringify({
skill, repo: env.GSTACK_REVIEW_REPO, branch: env.GSTACK_REVIEW_BRANCH,
wtree: env.GSTACK_STAMP_WTREE, started_at: new Date().toISOString(),
...(fixBase ? { scope: 'fix-range', fix_base: fixBase } : {}),
}), { mode: 0o600, flag: 'wx' });
return token;
return fixBase ? `${token}\nFIX_BASE=${fixBase}` : token;
}
const GATE_ACTIONS = new Set(['kept', 'restored', 'open']);
@@ -226,7 +263,7 @@ export function deriveFromFindings(rec: Record<string, any>, findingsPath: strin
}
export function bindReview(rec: Record<string, any>, token: string, env = process.env, findingsPath?: string): Record<string, any> {
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file']) delete rec[key];
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key];
// The findings file is validated BEFORE the start token is consumed below:
// a rejected row must leave the token for the retry.
rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' };
@@ -256,11 +293,19 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
const state = !start || !end ? 'uncaptured'
: start.wtree !== end ? 'changed'
: rec.completed !== true || rec.converged !== true ? 'incomplete' : 'verified';
rec.review_binding = {
state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at,
...(typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {}),
};
if (state === 'verified') rec.wtree = end;
const branchId = typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {};
const narrowed = start?.scope === 'fix-range' || start?.fix_base !== undefined;
if (narrowed) {
rec.scope = 'fix-range';
if (typeof start.fix_base === 'string' && HASH.test(start.fix_base)) rec.fix_base = start.fix_base;
}
// A narrowed pass on unchanged content never carries start_wtree/end_wtree,
// so Step 11.5 cannot bind it and it never becomes CURRENT; only a later full pass can.
rec.review_binding = narrowed && (state === 'incomplete' || state === 'verified')
? { state: 'fix-range', fix_base: rec.fix_base, range_start_wtree: start.wtree, range_end_wtree: end, started_at: start.started_at, ...branchId }
: { state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at, ...branchId };
const verified = rec.review_binding.state === 'verified';
if (verified) rec.wtree = end;
if (rec.skill === 'review' && Array.isArray(rec.findings)) {
rec.shared_libs_coverage_version = SHARED_LIBS_COVERAGE_VERSION;
for (const finding of rec.findings) {
@@ -270,7 +315,7 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
const fingerprint = sharedLibsFingerprint(finding);
if (!fingerprint) continue;
finding.fingerprint = fingerprint;
finding.snapshot_covered_paths = state === 'verified' && finding.action === 'skipped'
finding.snapshot_covered_paths = verified && finding.action === 'skipped'
? sharedLibsSnapshotCoverage(env.GSTACK_REVIEW_REPO!, end!, finding.evidence_paths, env) : [];
}
}
@@ -282,6 +327,7 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
const binding = rec.review_binding;
if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' };
if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' };
if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true ||
!rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) {
return { status: 'UNVERIFIED', reason: 'missing start capture or incomplete/nonconverged pass' };
+2 -1
View File
@@ -99,7 +99,8 @@ export function recomputeMergeTree(repoRoot: string, branchHead: string, predece
if (!ours || !theirs) return { error: `${file} conflicts beyond a content merge (added or deleted on one side)` };
if (file === policy.changelog) {
const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n';
const merged = mergeChangelogs(text(ours), text(theirs));
const base = entry.get(1);
const merged = mergeChangelogs(text(ours), text(theirs), base ? text(base) : undefined);
if (merged === null) return { error: `${file} conflicts beyond a heading move` };
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged);
if (!blob.ok) return { error: `cannot write the merged ${file}: ${blob.err}` };
+5 -2
View File
@@ -15,7 +15,9 @@ import { trainBisect, trainRebuild, trainVerdict } from './ship-queue-train-verd
import { verifyTrain } from './ship-queue-train-verify';
import { redactCause } from './ship-queue-train-record';
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--urgent | --repair] [--dry-run] [--free-timeout <s>]
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...] [--urgent | --repair] [--dry-run] [--free-timeout <s>]
--order: explicit slot order, every PR in --prs once (default: screened
first, then READY order)
train build --train <nonce> resume a train (push, free tests, dispatch) from its record
merge pinned unstamped heads in slot order onto main, free tests on
train/<nonce>, then one full gate through eval-bg's nonce
@@ -77,8 +79,9 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise<numb
const freeTimeoutSec = positive(flag(rest, '--free-timeout'), 2700);
if (Number.isNaN(freeTimeoutSec)) return usage('--free-timeout needs seconds');
if (rest.includes('--urgent') && rest.includes('--repair')) return usage('--urgent and --repair are separate one-PR trains; pass one');
if (rest.includes('--order') && (nonce || !flag(rest, '--order'))) return usage('--order <n>,... orders a new train from --prs; a resumed train keeps its recorded order');
return guarded('train build', () => buildTrain(ctx, {
pins: flag(rest, '--prs'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
pins: flag(rest, '--prs'), order: flag(rest, '--order'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000,
}));
}
+4 -1
View File
@@ -14,6 +14,7 @@ import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, awaitNonceRun, dedupeKey, dispatchWorkflow, findDuplicateRun, listDispatchRuns, makeNonce, type WorkflowDispatch } from '../scripts/eval-bg';
import { EXIT } from './headless-artifacts';
import { ready, type QueueContext } from './ship-queue';
import { classifyTrainHead } from './version-gate-train';
import { git } from './ship-queue-gate';
import { execFor, repoSlug } from './ship-queue-train-github';
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
@@ -100,6 +101,8 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
}
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
const baseSha = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out;
const kind = classifyTrainHead({ repoRoot: ctx.repoRoot, baseRef: ctx.base, head });
if (kind.kind === 'invalid') return outRefused({ code: 'QUEUE_NOT_READY', message: `the Version Gate refuses ${head.slice(0, 12)}: ${kind.reason}` }, base.lines, `fix the head, push, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
const sel = await screenSelection(env, head, baseSha);
const lines = [...base.lines, ...sel.lines];
const rec = sel.recommend ?? 'no-screen';
@@ -119,7 +122,7 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
}
lines.splice(1, 0, `screen: ${choice.choice}${choice.policy ? ` policy=${choice.policy} nonce=${choice.nonce} run=${choice.run ?? '-'}` : ''}`);
const now = nowIso();
const readyAt = loadReady(env.repo.key, o.pr, head)?.ready_at ?? now;
const readyAt = loadReady(env.repo.keys, o.pr, head)?.ready_at ?? now;
const receipt: ReadyReceipt = { schema: 1, kind: 'ready', repo_key: env.repo.key, pr: o.pr, head, base_sha: baseSha, ready_at: readyAt, screen: choice, updated_at: now };
if (o.dryRun) return outOk([...lines, 'DRY-RUN: no READY receipt written, nothing dispatched'], `${Q} ready --pr ${o.pr} --${o.screen}${o.broad ? ' --broad' : ''}`, 'none');
const p = persist(ctx.repoRoot, [receipt], { remote: env.remote });
+40 -11
View File
@@ -31,7 +31,7 @@ export interface FreeRun { run: number | null; url: string | null; head_sha: str
export interface TrainRecord {
schema: 1; kind: 'train'; nonce: string; key: string; repo_key: string; repo: string; base: { ref: string; sha: string }; policy: string;
slots: TrainSlot[]; held: string[]; prefixes: TrainPrefix[]; head: string; tree: string; branch: string;
urgent: boolean; repair: boolean; state: TrainState; free: FreeRun | null; attempts: string[];
urgent: boolean; repair: boolean; explicit_order?: boolean; state: TrainState; free: FreeRun | null; attempts: string[];
merged: Array<{ pr: string; sha: string; tree: string; at: string }>; superseded_by: string | null;
effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string;
}
@@ -87,11 +87,34 @@ const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json');
const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`);
export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`);
/** The repository's identity: origin's URL, keyed so records of different repositories never mix. */
export function repoIdentity(repoRoot: string): { key: string; url: string } {
const url = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot;
const norm = url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, '');
return { key: sha256(norm).slice(0, 16), url: norm };
/** Hosts that serve GitHub repositories at /<owner>/<repo> (git.capy.ai is Capy's GitHub proxy, set through url.insteadOf). */
const GITHUB_HOSTS = ['github.com', 'www.github.com', 'ssh.github.com', 'git.capy.ai'];
/** Before 1.91.187.0 the key hashed origin's URL with only .git, git@github.com: and trailing slashes normalized. */
const legacyNorm = (url: string) => url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, '');
/** A remote URL as https://github.com/<owner>/<repo> when it names a GitHub repository in any form (scp, ssh, http(s), credentials, proxy host); otherwise the legacy form. */
export function normalizeRemoteUrl(url: string): string {
const m = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/*$/i.exec(url.trim());
if (m && GITHUB_HOSTS.includes(m[1]!.toLowerCase())) return `https://github.com/${m[2]!.toLowerCase()}/${m[3]!.toLowerCase()}`;
return legacyNorm(url.trim().replace(/^([a-z+]+:\/\/)[^@/]+@/i, '$1'));
}
/**
* The repository's identity, keyed so records of different repositories
* never mix: a hash of origin's URL normalized to owner/repo, so lanes whose
* origin names the same GitHub repository in another form share one key.
* `keys` adds the keys earlier versions wrote for this URL and for the
* repository's common URL forms; readers accept all of them, writers use `key`.
*/
export function repoIdentity(repoRoot: string): { key: string; url: string; keys: string[] } {
const raw = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot;
const url = normalizeRemoteUrl(raw);
const key = sha256(url).slice(0, 16);
const gh = /^https:\/\/github\.com\/(.+)$/.exec(url)?.[1];
const forms = gh ? GITHUB_HOSTS.flatMap(h => ['https', 'http', 'ssh://git@'].map(s => `${s.includes('://') ? s : `${s}://`}${h}/${gh}`)) : [];
const keys = [...new Set([key, ...[raw, ...forms].map(u => sha256(legacyNorm(u)).slice(0, 16))])];
return { key, url, keys };
}
function readJson<T>(file: string, kind: string): T | null {
@@ -101,19 +124,25 @@ function readJson<T>(file: string, kind: string): T | null {
export const loadTrain = (nonce: string) => (/^[A-Za-z0-9._-]{1,128}$/.test(nonce) ? readJson<TrainRecord>(trainFile(nonce), 'train') : null);
export const loadAttempt = (nonce: string, id: string) => (/^[A-Za-z0-9._-]{1,64}$/.test(id) ? readJson<AttemptRecord>(attemptPath(nonce, id), 'train-attempt') : null);
export const loadBisect = (nonce: string) => readJson<BisectRecord>(bisectPath(nonce), 'train-bisect');
export const loadFreeze = (repoKey: string) => readJson<FreezeRecord>(freezePath(repoKey), 'freeze');
export const loadReady = (repoKey: string, pr: string, head: string) => readJson<ReadyReceipt>(readyPath(repoKey, pr, head), 'ready');
export const activeFreeze = (repoKey: string): FreezeRecord | null => { const f = loadFreeze(repoKey); return f && !f.cleared_at ? f : null; };
/** A reader's repo keys: the current key first, then the legacy keys (repoIdentity().keys). */
type RepoKeys = string | readonly string[];
const keyList = (k: RepoKeys): readonly string[] => (typeof k === 'string' ? [k] : k);
const firstOf = <T>(k: RepoKeys, read: (key: string) => T | null): T | null => { for (const key of keyList(k)) { const r = read(key); if (r) return r; } return null; };
export const loadFreeze = (repoKeys: RepoKeys) => firstOf(repoKeys, key => readJson<FreezeRecord>(freezePath(key), 'freeze'));
export const loadReady = (repoKeys: RepoKeys, pr: string, head: string) => firstOf(repoKeys, key => readJson<ReadyReceipt>(readyPath(key, pr, head), 'ready'));
export const activeFreeze = (repoKeys: RepoKeys): FreezeRecord | null => firstOf(repoKeys, key => { const f = readJson<FreezeRecord>(freezePath(key), 'freeze'); return f && !f.cleared_at ? f : null; });
export function attemptsOf(train: TrainRecord): AttemptRecord[] {
return train.attempts.map(id => loadAttempt(train.nonce, id)).filter((a): a is AttemptRecord => !!a);
}
/** Every train of this repository, newest first. */
export function listTrains(repoKey: string): TrainRecord[] {
export function listTrains(repoKeys: RepoKeys): TrainRecord[] {
const dir = path.join(queueRoot(), 'trains');
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && t.repo_key === repoKey)
const keys = keyList(repoKeys);
return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && keys.includes(t.repo_key))
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.nonce.localeCompare(a.nonce));
}
+3 -3
View File
@@ -23,7 +23,7 @@ import {
type AttemptRecord, type BisectRecord, type CasePanel, type GateOutcome, type PrefixResult, type TrainRecord,
} from './ship-queue-train-record';
import {
dispatchAttempt, effect, findTrain, gateDispatch, nextFor, outFailed, outOk, outPending, outRefused, pinList, save, trainEnv,
dispatchAttempt, effect, findTrain, gateDispatch, nextFor, outFailed, outOk, outPending, outRefused, prsArgs, save, trainEnv,
type TrainEnv, type TrainOut,
} from './ship-queue-train';
@@ -309,7 +309,7 @@ export async function trainRebuild(ctx: QueueContext, o: { train: string; infra:
const run = trainFile(t.nonce);
const gates = gateAttempts(t);
const last = gates.at(-1);
const repair = `route ${last?.red_cases.join(',') || 'the red cases'} to a repair lane (GStack Minder); the train is rebuilt only once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`;
const repair = `route ${last?.red_cases.join(',') || 'the red cases'} to a repair lane (GStack Minder); the train is rebuilt only once the repair merges: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
const refuse = (reason: string) => outRefused({ code: 'QUEUE_TRAIN_REBUILD_REFUSED', message: `rebuild refused: ${reason}` }, gates.map(attemptLine), repair, run);
if (o.infra) {
if (!last || last.outcome !== 'INCOMPLETE' || !last.infra_eligible) return refuse(`no INFRA/INCOMPLETE-only record (the last gate is ${last?.outcome ?? 'missing'})`);
@@ -321,7 +321,7 @@ export async function trainRebuild(ctx: QueueContext, o: { train: string; infra:
if (b.outcome.kind !== 'unattributed' || !b.outcome.all_green) return refuse(`the bisect was not all green (${Object.entries(b.outcome.patterns).map(([c, s]) => `${c}=${s}`).join(' ')})`);
}
const tip = remoteBranchSha(args => git(ctx.repoRoot, args), env.remote, t.branch);
if (tip !== t.head) return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `${t.branch} is at ${tip?.slice(0, 12) ?? 'nothing'}, not the recorded head ${t.head.slice(0, 12)}` }, [], `${Q} train build --prs ${pinList(t.slots)}`, run);
if (tip !== t.head) return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `${t.branch} is at ${tip?.slice(0, 12) ?? 'nothing'}, not the recorded head ${t.head.slice(0, 12)}` }, [], `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run);
const slug = repoSlug(ctx);
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} train rebuild --train ${t.nonce}${o.infra ? ' --infra' : ''}`, run);
const a = await dispatchAttempt(env, slug, t, o.infra ? 'infra-redispatch' : 'rebuild', gateDispatch(env, t, `${t.nonce}.${gates.length + 1}`));
+39 -8
View File
@@ -20,8 +20,9 @@ import { git } from './ship-queue-gate';
import { checkRun, execFor, repoSlug } from './ship-queue-train-github';
import { activeFreeze, attemptsOf, nowIso, persist, trainFile, type FreezeRecord, type TrainRecord } from './ship-queue-train-record';
import {
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, save, trainEnv, type TrainEnv, type TrainOut,
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, prsArgs, save, trainEnv, type TrainEnv, type TrainOut,
} from './ship-queue-train';
import { ownEntryAboveMain } from './changelog-check';
import { buildReceipt } from './tree-receipt';
const Q = 'gstack-ship-queue';
@@ -32,6 +33,37 @@ function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same
return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` };
}
/**
* A slot head with main merged in (from slot 2 on the lane merges main into
* its pin so GitHub can merge it): it must contain main, and its tree must be
* the slot's prefix T<i> modulo stamps. The CHANGELOG is judged against main
* instead of T<i>'s mechanical merge, so a hand resolve is accepted when it
* is main's CHANGELOG plus the PR's own entry (ownEntryAboveMain); any other
* difference is refused.
*/
function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main: string): { same: boolean; detail: string } {
const after = t.prefixes[i + 1]!;
const root = env.ctx.repoRoot;
if (!git(root, ['merge-base', '--is-ancestor', main, head]).ok) return { same: false, detail: `does not contain main ${main.slice(0, 12)}` };
const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label });
if ('error' in r) return { same: false, detail: r.error };
const stamps = `${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)`;
if (r.tree === 'same-modulo-stamps') return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps})` };
const cl = env.policy.policy.changelog;
const beyond = r.residual.filter(f => f !== cl);
if (beyond.length || !cl) return { same: false, detail: `differs from T${after.index}: ${r.residual.slice(0, 8).join(', ')}` };
const pin = t.slots[i]!.head;
const forkPoint = git(root, ['merge-base', pin, t.prefixes[0]!.commit]).out;
const diff = git(root, ['diff', '--unified=0', '--no-color', '--no-ext-diff', forkPoint, pin, '--', cl]);
const show = (rev: string) => git(root, ['show', `${rev}:${cl}`]);
const [headCl, mainCl] = [show(head), show(main)];
if (!forkPoint || !diff.ok || !headCl.ok || !mainCl.ok) return { same: false, detail: `cannot read ${cl} at the pin, the head or main` };
const authored = new Set(diff.out.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1)));
const why = ownEntryAboveMain(headCl.out, mainCl.out, authored);
if (why) return { same: false, detail: `differs from T${after.index} in ${cl}, which is not main's plus #${t.slots[i]!.pr}'s own entry: ${why}` };
return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps}) except ${cl}, resolved as main's ${cl} plus #${t.slots[i]!.pr}'s own entry` };
}
/**
* Freeze the queue: the record goes to local state and the notes ref before
* anything remote, then the backstop gate is dispatched on main's sha.
@@ -61,8 +93,8 @@ async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSh
/** Interrupted publication: freeze when earlier slots merged; otherwise a plain refusal (nothing was published). */
async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[]): Promise<TrainOut> {
const run = trainFile(t.nonce);
if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build --prs ${pinList(t.slots)} (a new train on current main)`, run);
const f = activeFreeze(env.repo.key) ?? await freezeQueue(env, t, message, mainSha);
if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run);
const f = activeFreeze(env.repo.keys) ?? await freezeQueue(env, t, message, mainSha);
return outFailed({ code, message: `${message}; the queue is frozen` }, [...lines, ...frozenLines(f)], `${Q} train build --repair --prs <repair-pr>@<sha>`, run);
}
@@ -84,7 +116,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
if (check?.conclusion !== 'success') return outRefused({ code: 'QUEUE_GATE_MISSING', message: `no successful ${env.train.full_gate_check} check run on the train head ${t.head.slice(0, 12)}` }, [], `${Q} train verdict --train ${t.nonce}`, run);
if (!fetchTrainBranch(env, t)) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the train's prefix commits are not here and ${t.branch} could not be fetched` }, [], `${Q} train status --train ${t.nonce}`, run);
const main = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out;
const freeze = activeFreeze(env.repo.key);
const freeze = activeFreeze(env.repo.keys);
if (freeze && freeze.repair?.train !== t.nonce) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen (${freeze.reason}); only the admitted repair train merges` }, frozenLines(freeze), `${Q} train build --repair --prs <repair-pr>@<sha>`, run);
const after = t.prefixes[i + 1]!;
const before = t.prefixes[i]!;
@@ -98,9 +130,8 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
const headRef = o.noFetch ? '' : (git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/pull/${o.pr}/head:refs/gstack/queue/pr-${o.pr}`]).ok ? `refs/gstack/queue/pr-${o.pr}` : '');
const head = headRef ? git(ctx.repoRoot, ['rev-parse', headRef]).out : t.slots[i]!.head;
const pinned = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head);
const asPrefix = pinned.same ? null : sameModuloStamps(env, after.commit, head);
const stampOnly = asPrefix?.same ? { same: true, detail: `T${after.index} with main merged in, ${asPrefix.detail}` } : pinned;
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${pinned.detail})`, main, lines);
const stampOnly = pinned.same ? pinned : mergedIn(env, t, i, head, main);
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${stampOnly.detail}; against the pin it ${pinned.detail})`, main, lines);
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`,
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
@@ -113,7 +144,7 @@ function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string,
effect(t, `verified #${pr} merged as T${index}`);
save(env, t);
lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${t.merged.length}/${t.slots.length} merged)`);
const freeze = activeFreeze(env.repo.key);
const freeze = activeFreeze(env.repo.keys);
if (freeze && freeze.repair?.train === t.nonce && t.state === 'merged') {
freeze.cleared_at = nowIso();
freeze.cleared_by = `repair #${pr} via train ${t.nonce}; main ${main.slice(0, 12)} carries a green record`;
+53 -25
View File
@@ -16,7 +16,7 @@
*/
import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg';
import { mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
import type { ResultStatus } from './headless-artifacts';
import { versionAtRef, versionFiles } from './restamp';
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
@@ -34,7 +34,7 @@ export const outRefused = (failure: QueueFailure, lines: string[], next: string,
export const outFailed = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 1, status: 'refused', lines, next, failure, run });
export const outPending = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 3, status: 'incomplete', lines, next, failure, run });
export interface TrainEnv { ctx: QueueContext; policy: LoadedPolicy; train: TrainPolicy; repo: { key: string; url: string }; remote: string }
export interface TrainEnv { ctx: QueueContext; policy: LoadedPolicy; train: TrainPolicy; repo: { key: string; url: string; keys: string[] }; remote: string }
const HEX = /^[0-9a-f]{40,64}$/;
const Q = 'gstack-ship-queue';
@@ -92,9 +92,20 @@ export function parsePins(spec: string | undefined): Array<{ pr: string; sha: st
}
export const pinList = (slots: Array<{ pr: string; head: string }>) => slots.map(s => `${s.pr}@${s.head}`).join(',');
/** `--prs <pins>` for a rebuild of these slots, plus `--order` when the train was built with an explicit order. */
export const prsArgs = (slots: Array<{ pr: string; head: string }>, explicit?: boolean) => `--prs ${pinList(slots)}${explicit ? ` --order ${slots.map(s => s.pr).join(',')}` : ''}`;
/** Slot order: screened PRs first, then unscreened, each in READY order; enough unscreened PRs form their own train. */
export function orderSlots(slots: TrainSlot[], max: number): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } {
/**
* Slot order: screened PRs first, then unscreened, each in READY order;
* enough unscreened PRs form their own train. An explicit order (`--order`,
* every admitted PR once) replaces that rule: slots follow it, the first
* `max` ride and the rest are held.
*/
export function orderSlots(slots: TrainSlot[], max: number, explicit?: readonly string[]): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } {
if (explicit) {
const all = explicit.map(pr => slots.find(s => s.pr === pr)!);
return { train: all.slice(0, max), held: all.slice(max), split: false };
}
const byReady = (a: TrainSlot, b: TrainSlot) => a.ready_at.localeCompare(b.ready_at) || Number(a.pr) - Number(b.pr);
const screened = slots.filter(s => s.screen.choice === 'screen').sort(byReady);
const unscreened = slots.filter(s => s.screen.choice !== 'screen').sort(byReady);
@@ -130,7 +141,9 @@ function unionChangelog(ours: string, theirs: string): string {
* One train merge, in object storage: the PR head onto the previous prefix.
* Version files take the prefix's side; the CHANGELOG merges by section (the
* gate-ahead rule, else a union of sections, since each heading is restamped
* at merge). Any other conflict is reported with every conflicting path.
* at merge), unless a side damaged released history (changelogDamage), which
* is a conflict the lane resolves. Any other conflict is reported with every
* conflicting path.
*/
export function mergeStep(repoRoot: string, pred: string, head: string, policy: ShipPolicy): { tree: string } | { conflicts: string[] } {
const r = git(repoRoot, ['merge-tree', '--write-tree', head, pred]);
@@ -157,6 +170,9 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy:
if (!ours || !theirs) conflicts.push(file);
else if (file === policy.changelog) {
const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n';
const base = entry.get(1);
const damage = changelogDamage(text(ours), text(theirs), base ? text(base) : undefined);
if (damage) { conflicts.push(`${file} (${damage})`); continue; }
const merged = mergeChangelogs(text(ours), text(theirs)) ?? unionChangelog(text(ours), text(theirs));
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged);
if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file);
@@ -207,12 +223,12 @@ export function nextFor(t: TrainRecord): string {
case 'attributed': {
const drop = loadBisect(n)?.outcome?.prs ?? [];
const keep = t.slots.filter(s => !drop.includes(s.pr));
return keep.length ? `${Q} train build --prs ${pinList(keep)}` : 'none (every PR was dropped)';
return keep.length ? `${Q} train build ${prsArgs(keep, t.explicit_order)}` : 'none (every PR was dropped)';
}
case 'red-on-main': return `fix forward on main first, then ${Q} train build --prs ${pinList(t.slots)}`;
case 'red-on-main': return `fix forward on main first, then ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
case 'unattributed': return loadBisect(n)?.outcome?.all_green && !attemptsOf(t).some(a => a.purpose === 'rebuild')
? `${Q} train rebuild --train ${n}`
: `route the red cases to a repair lane (GStack Minder); once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`;
: `route the red cases to a repair lane (GStack Minder); once the repair merges: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
default: return `${Q} train prune --train ${n}`;
}
}
@@ -249,7 +265,17 @@ export function gateDispatch(env: TrainEnv, train: TrainRecord, nonce: string):
return { workflow: env.train.gate_workflow, inputs: { [DISPATCH_INPUTS.evalsAll]: 'true', [DISPATCH_INPUTS.expectedSha]: train.head, [DISPATCH_INPUTS.nonce]: nonce } };
}
export interface BuildOptions { pins?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number }
export interface BuildOptions { pins?: string; order?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number }
/** `--order <pr>,<pr>,...`: every pinned PR exactly once (a leading # allowed). */
export function parseOrder(spec: string | undefined, pins: Array<{ pr: string }>): string[] | undefined | { error: string } {
if (spec === undefined) return undefined;
const order = spec.split(',').map(s => s.trim().replace(/^#/, '')).filter(Boolean);
const want = pins.map(p => p.pr).sort((a, b) => Number(a) - Number(b));
const got = [...order].sort((a, b) => Number(a) - Number(b));
if (order.some(pr => !/^\d+$/.test(pr)) || got.join(',') !== want.join(',')) return { error: `--order must list every PR in --prs exactly once (--prs has ${want.map(p => '#' + p).join(',')}; got ${spec})` };
return order;
}
/** The slots a new train would carry: trusted, unmoved, READY PRs, in slot order. */
function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): { slots: TrainSlot[]; lines: string[] } | TrainOut {
@@ -267,7 +293,7 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) {
return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs <pins>`, 'none');
}
const ready = loadReady(env.repo.key, p.pr, info.headRefOid);
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen: ready.screen });
}
@@ -280,28 +306,30 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
if (typeof pins === 'string') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: pins }, [], 'none', 'none');
if ((o.urgent || o.repair) && pins.length !== 1) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `${o.repair ? '--repair' : '--urgent'} admits exactly one PR` }, [], 'none', 'none');
rebuildFromNotes(env.ctx.repoRoot, env.remote);
const freeze = activeFreeze(env.repo.key);
const freeze = activeFreeze(env.repo.keys);
if (freeze && !o.repair) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen since ${freeze.created_at}: train ${freeze.train} stopped after merging ${freeze.merged.map(p => '#' + p).join(',')} (${freeze.reason})` },
frozenLines(freeze), `${Q} train build --repair --prs <repair-pr>@<sha>`, 'none');
const explicit = parseOrder(o.order, pins);
if (explicit && 'error' in explicit) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: explicit.error }, [], 'none', 'none');
const admitted = admitSlots(env, pins);
if ('exit' in admitted) return admitted;
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size);
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size, explicit);
const lines = [
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}`,
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`,
...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []),
];
const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair);
if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build --prs ${pinList(order.train)} (after ${due}, or with more PRs, or --urgent for one)`, 'none');
if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none');
const baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out;
const key = sha256(JSON.stringify({ base: baseSha, slots: order.train.map(s => `${s.pr}@${s.head}`) }));
const live = listTrains(env.repo.key).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed');
const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed');
if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines };
const chain = buildChain(env.ctx.repoRoot, baseSha, order.train, env.policy.policy);
if ('stoppedAt' in chain) {
const without = order.train.filter(s => s.pr !== chain.stoppedAt);
return outFailed({ code: 'QUEUE_TRAIN_CONFLICT', message: `#${chain.stoppedAt} conflicts with the prefix before it` }, [...lines,
`QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build --prs ${pinList(without)}`],
without.length ? `${Q} train build --prs ${pinList(without)}` : 'none', 'none');
`QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build ${prsArgs(without, !!explicit)}`],
without.length ? `${Q} train build ${prsArgs(without, !!explicit)}` : 'none', 'none');
}
const head = chain.prefixes.at(-1)!;
const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' });
@@ -309,7 +337,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
return { lines, t: {
schema: 1, kind: 'train', nonce, key, repo_key: env.repo.key, repo: env.repo.url, base: { ref: env.ctx.base, sha: baseSha }, policy: env.policy.source.label,
slots: order.train, held: order.held.map(s => s.pr), prefixes: chain.prefixes, head: head.commit, tree: head.tree, branch: `train/${nonce}`,
urgent: o.urgent, repair: o.repair, state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null,
urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null,
created_at: now, updated_at: now,
} };
}
@@ -342,7 +370,7 @@ async function freePhase(env: TrainEnv, slug: string, t: TrainRecord, o: BuildOp
t.state = 'free-red';
effect(t, `${env.train.free_workflow} dispatch refused by gh`);
save(env, t);
return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `gh could not dispatch ${env.train.free_workflow} on ${t.branch}` }, describe(t), `${Q} train build --prs ${pinList(t.slots)}`, run);
return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `gh could not dispatch ${env.train.free_workflow} on ${t.branch}` }, describe(t), `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run);
}
t.state = 'free-pending';
effect(t, `${env.train.free_workflow} dispatched on ${t.branch}`);
@@ -422,12 +450,12 @@ export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise<Tr
if (!('kind' in t)) return t;
if (o.dryRun) {
return outOk([...lines, ...describe(t), `ESTIMATE: one ${env.train.gate_workflow} full gate (evals_all) on T${t.prefixes.length - 1} plus ${env.train.free_workflow}; dollar figure pending the T0 cost replay (lib/gate-cost.ts)`,
'DRY-RUN: nothing written, pushed or dispatched'], `${Q} train build --prs ${pinList(t.slots)}`, 'none');
'DRY-RUN: nothing written, pushed or dispatched'], `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, 'none');
}
if (t.state === 'built' && !t.attempts.length && !loadTrain(t.nonce)) {
effect(t, 'train.json written');
save(env, t);
for (const old of listTrains(env.repo.key)) {
for (const old of listTrains(env.repo.keys)) {
if (old.nonce === t.nonce || TERMINAL_STATES.includes(old.state) || old.state === 'green' || old.state === 'merging') continue;
if (!old.slots.some(s => t.slots.some((n: TrainSlot) => n.pr === s.pr))) continue;
old.state = 'superseded';
@@ -435,7 +463,7 @@ export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise<Tr
save(env, old);
}
if (o.repair) {
const f = activeFreeze(env.repo.key)!;
const f = activeFreeze(env.repo.keys)!;
f.repair = { pr: t.slots[0]!.pr, head: t.slots[0]!.head, train: t.nonce };
f.updated_at = nowIso();
persist(env.ctx.repoRoot, [f], { remote: env.remote });
@@ -454,7 +482,7 @@ export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut
if ('code' in env) return outRefused(env, [], 'none', 'none');
const restored = rebuildFromNotes(ctx.repoRoot, env.remote);
const lines = [`NOTES: read ${restored.read} record(s), restored ${restored.restored} (${restored.detail})`];
const trains = o.train ? [loadTrain(o.train)].filter((t): t is TrainRecord => !!t) : listTrains(env.repo.key).filter(t => !TERMINAL_STATES.includes(t.state));
const trains = o.train ? [loadTrain(o.train)].filter((t): t is TrainRecord => !!t) : listTrains(env.repo.keys).filter(t => !TERMINAL_STATES.includes(t.state));
if (o.train && !trains.length) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, lines, `${Q} train status`, 'none');
const slug = trains.length ? repoSlug(ctx) : null;
const exec = execFor(ctx);
@@ -482,7 +510,7 @@ export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut
lines.push(`NEXT(${t.nonce}): ${nextFor(t)}`);
}
if (!trains.length) lines.push('TRAIN: none live');
const freeze = activeFreeze(env.repo.key);
const freeze = activeFreeze(env.repo.keys);
if (freeze) lines.push(...frozenLines(freeze));
const single = trains.length === 1 ? trains[0]! : null;
const next = freeze && !freeze.repair ? `${Q} train build --repair --prs <repair-pr>@<sha>` : single ? nextFor(single) : trains.length ? 'see each NEXT(<nonce>) line' : 'none';
@@ -496,7 +524,7 @@ export function pruneTrains(ctx: QueueContext, o: { dryRun: boolean; train?: str
rebuildFromNotes(ctx.repoRoot, env.remote);
const lines: string[] = [];
const rgit = (args: string[]) => git(ctx.repoRoot, args);
for (const t of listTrains(env.repo.key).filter(x => !o.train || x.nonce === o.train)) {
for (const t of listTrains(env.repo.keys).filter(x => !o.train || x.nonce === o.train)) {
if (!TERMINAL_STATES.includes(t.state)) { lines.push(`KEEP: ${t.branch} (${t.state})`); continue; }
if (!remoteBranchSha(rgit, env.remote, t.branch)) { lines.push(`GONE: ${t.branch} (${t.state})`); continue; }
if (o.dryRun) { lines.push(`WOULD-DELETE: ${t.branch} (${t.state})`); continue; }
+10 -3
View File
@@ -18,7 +18,7 @@ export type TrainHead =
| { kind: 'invalid'; reason: string };
function git(repoRoot: string, args: string[]): string | null {
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
return r.status === 0 ? r.stdout : null;
}
@@ -50,11 +50,18 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head:
}
const file = typeof policy.changelog === 'string' ? policy.changelog : 'CHANGELOG.md';
const before = parseChangelog(show(mergeBase, file) ?? '');
const after = parseChangelog(show(o.head, file) ?? '');
const exists = (rev: string) => git(o.repoRoot, ['cat-file', '-e', `${rev}:${file}`]) !== null;
const read = (rev: string) => (exists(rev) ? show(rev, file) : '');
const [beforeText, afterText] = [read(mergeBase), read(o.head)];
if (beforeText === null || afterText === null) return { kind: 'invalid', reason: `cannot read ${file} at ${beforeText === null ? `the merge base ${mergeBase.slice(0, 12)}` : o.head}` };
const before = parseChangelog(beforeText);
const after = parseChangelog(afterText);
const unreleased = after.sections.filter(s => s.unreleased);
if (unreleased.length > 1) return { kind: 'invalid', reason: `${file} has ${unreleased.length} [Unreleased] sections` };
if (unreleased.length === 1 && after.sections[0] !== unreleased[0]) return { kind: 'invalid', reason: `${file}: [Unreleased] must be the top section` };
const kept = new Set(after.sections.filter(s => !s.unreleased).map(s => s.version));
const dropped = before.sections.filter(s => !s.unreleased && !kept.has(s.version)).map(s => s.version);
if (dropped.length) return { kind: 'invalid', reason: `${file} drops released heading ${dropped.slice(0, 3).join(', ')}${dropped.length > 3 ? ` (+${dropped.length - 3})` : ''}; an unstamped head may only add its [Unreleased] section above the latest release` };
const released = (c: typeof after) => [c.header.join('\n'), ...c.sections.filter(s => !s.unreleased).map(s => s.lines.join('\n'))];
const was = released(before);
const now = released(after);
+1 -1
View File
@@ -509,7 +509,7 @@ SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null)
```
1. Read `CLAUDE.md`, `TODOS.md` (if they exist).
2. Run `git log --oneline -30` and `git diff origin/main --stat 2>/dev/null` to understand recent context.
2. Run `git log --oneline -30` and `git diff origin/HEAD --stat` (default branch) to grasp recent context.
3. Use Grep/Glob to map the codebase areas most relevant to the user's request.
4. **List existing design docs for this project:**
```bash
+1 -1
View File
@@ -81,7 +81,7 @@ Understand the project and the area the user wants to change.
```
1. Read `CLAUDE.md`, `TODOS.md` (if they exist).
2. Run `git log --oneline -30` and `git diff origin/main --stat 2>/dev/null` to understand recent context.
2. Run `git log --oneline -30` and `git diff origin/HEAD --stat` (default branch) to grasp recent context.
3. Use Grep/Glob to map the codebase areas most relevant to the user's request.
4. **List existing design docs for this project:**
```bash
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.186",
"version": "1.91.218",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -467,7 +467,7 @@ only under the Step 0.5 conditions (no UI scope, designer unavailable, or the us
Commands: `generate` (single mockup), `variants` (multiple directions), `compare`
(side-by-side review board), `iterate` (refine with feedback), `check` (cross-model
quality gate via GPT-4o vision), `evolve` (improve from screenshot).
quality gate via OpenAI vision), `evolve` (improve from screenshot).
Setup is handled by the DESIGN SETUP section below. If `DESIGN_READY` is printed,
the designer is available and you should use it.
+1 -1
View File
@@ -85,7 +85,7 @@ only under the Step 0.5 conditions (no UI scope, designer unavailable, or the us
Commands: `generate` (single mockup), `variants` (multiple directions), `compare`
(side-by-side review board), `iterate` (refine with feedback), `check` (cross-model
quality gate via GPT-4o vision), `evolve` (improve from screenshot).
quality gate via OpenAI vision), `evolve` (improve from screenshot).
Setup is handled by the DESIGN SETUP section below. If `DESIGN_READY` is printed,
the designer is available and you should use it.
+9 -14
View File
@@ -125,7 +125,8 @@ building proposed code. Keep suppressed findings for the output appendix.
## Confidence Calibration
Every finding MUST include a confidence score (1-10):
Every finding MUST include a severity, CRITICAL (blocks shipping) or INFORMATIONAL
(lower severity, still actioned), and a confidence score (1-10):
| Score | Meaning | Display rule |
|-------|---------|-------------|
@@ -133,20 +134,18 @@ Every finding MUST include a confidence score (1-10):
| 7-8 | High confidence pattern match. Very likely correct. | Show normally |
| 5-6 | Moderate. Could be a false positive. | Show with caveat: "Medium confidence, verify this is actually an issue" |
| 3-4 | Low confidence. Pattern is suspicious but may be fine. | Suppress from main report. Include in appendix only. |
| 1-2 | Speculation. | Only report if severity would be P0. |
| 1-2 | Speculation. | Only report a suspected release-blocking catastrophe (widespread data loss, total outage or system-wide compromise), at CRITICAL severity and marked explicitly speculative. |
**Finding format:**
`[SEVERITY] (confidence: N/10) file:line — description`
`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description`
Example:
`[P1] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause`
`[P2] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs`
`[CRITICAL] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause`
`[INFORMATIONAL] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs`
### Pre-emit verification gate
Before any finding is promoted to the report, the gate requires:
1. **Quote the specific code line that motivates the finding** — file:line plus
the verbatim text of the line(s) that triggered it. If the finding is "field
X doesn't exist on model Y", quote the lines of class Y where the field
@@ -156,9 +155,7 @@ Before any finding is promoted to the report, the gate requires:
2. **If you cannot quote the motivating line(s), the finding is unverified.**
Force its confidence to 4-5. Use 4 when it should be suppressed from the main
report; use 5 only when it belongs in the report with the medium-confidence
caveat. Keep suppressed items in the appendix so reviewers can audit
calibration. Do not work around this by inventing
speculative confidence 7+ — that defeats the gate.
caveat. Never invent speculative confidence 7+.
**Framework-meta nudge:** When the symbol is generated by a framework
metaclass, descriptor, ORM Meta inner-class, or migration history (Django
@@ -178,10 +175,8 @@ False-positive classes the gate catches:
| "save() might lose fields" | Requires quoting the ORM signature or model definition |
| "update_fields might miss X" | Requires quoting the field set; if X doesn't exist, the FP is self-evident |
**Calibration learning:** If you report a finding with confidence < 7 and the user
confirms it IS a real issue, that is a calibration event. Your initial confidence was
too low. Log the corrected pattern as a learning so future reviews catch it with
higher confidence.
**Calibration learning:** If the user confirms a reported finding scored < 7 is
real, log the corrected pattern as a learning.
## Decision procedure
+1 -1
View File
@@ -34,7 +34,7 @@
"test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":6},
"test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":6},
"test/helpers/pty/binary.ts": {"blob":"404d7515076d5c00804f89e23ea69296312c00e1","since":6},
"test/helpers/pty/boundaries.ts": {"blob":"8c87100c9dd0cfc28c02598f250726fd93bfbdea","since":6,"nonBehavioral":"Removes the plan-design 0D focus picker from the shared harness; floor.ts gains an optional pickSetupChoice hook that no other caller passes, so no other case's input or verdict can change. plan-design-finding-floor, the only case whose seed raises that menu, passes the picker from its owned fixture test/fixtures/design-floor-focus-picker.ts, so its owned bytes start its own new series."},
"test/helpers/pty/boundaries.ts": {"blob":"c5520118da36f96f60a3c9720751bb57a669865c","since":6,"nonBehavioral":"Widens the plan-eng-review first-review severity annotation from [P0-3] to [P0-3|CRITICAL|INFORMATIONAL] (#3349). Only a question carrying the new labels can change verdict, and only the changed plan-eng-review prompt asks for them; every case that runs it owns that generated SKILL.md, which this change also edits, so those cases already start their own series. No other case's input or verdict can change."},
"test/helpers/pty/classify.ts": {"blob":"3f2daa12ff6484f96b8d28d910a50231c4bb3ae8","since":6},
"test/helpers/pty/fake-session.ts": {"blob":"554e064ef259b8444280eddfe9b0db93c66a8202","since":6},
"test/helpers/pty/judge.ts": {"blob":"81c5bb4c48ab928aca2f186a1ade9df7169d7fbe","since":6},
+9 -14
View File
@@ -53,7 +53,8 @@ real, log the corrected pattern as a learning.`;
const result = `## Confidence Calibration
Every finding MUST include a confidence score (1-10):
Every finding MUST include a severity, CRITICAL (blocks shipping) or INFORMATIONAL
(lower severity, still actioned), and a confidence score (1-10):
| Score | Meaning | Display rule |
|-------|---------|-------------|
@@ -61,20 +62,18 @@ Every finding MUST include a confidence score (1-10):
| 7-8 | High confidence pattern match. Very likely correct. | Show normally |
| 5-6 | Moderate. Could be a false positive. | Show with caveat: "Medium confidence, verify this is actually an issue" |
| 3-4 | Low confidence. Pattern is suspicious but may be fine. | Suppress from main report. Include in appendix only. |
| 1-2 | Speculation. | Only report if severity would be P0. |
| 1-2 | Speculation. | Only report a suspected release-blocking catastrophe (widespread data loss, total outage or system-wide compromise), at CRITICAL severity and marked explicitly speculative. |
**Finding format:**
\\\`[SEVERITY] (confidence: N/10) file:line — description\\\`
\\\`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description\\\`
Example:
\\\`[P1] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\\\`
\\\`[P2] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\\\`
\\\`[CRITICAL] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\\\`
\\\`[INFORMATIONAL] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\\\`
### Pre-emit verification gate
Before any finding is promoted to the report, the gate requires:
1. **Quote the specific code line that motivates the finding** — file:line plus
the verbatim text of the line(s) that triggered it. If the finding is "field
X doesn't exist on model Y", quote the lines of class Y where the field
@@ -84,9 +83,7 @@ Before any finding is promoted to the report, the gate requires:
2. **If you cannot quote the motivating line(s), the finding is unverified.**
Force its confidence to 4-5. Use 4 when it should be suppressed from the main
report; use 5 only when it belongs in the report with the medium-confidence
caveat. Keep suppressed items in the appendix so reviewers can audit
calibration. Do not work around this by inventing
speculative confidence 7+ — that defeats the gate.
caveat. Never invent speculative confidence 7+.
**Framework-meta nudge:** When the symbol is generated by a framework
metaclass, descriptor, ORM Meta inner-class, or migration history (Django
@@ -106,9 +103,7 @@ False-positive classes the gate catches:
| "save() might lose fields" | Requires quoting the ORM signature or model definition |
| "update_fields might miss X" | Requires quoting the field set; if X doesn't exist, the FP is self-evident |
**Calibration learning:** If you report a finding with confidence < 7 and the user
confirms it IS a real issue, that is a calibration event. Your initial confidence was
too low. Log the corrected pattern as a learning so future reviews catch it with
higher confidence.`;
**Calibration learning:** If the user confirms a reported finding scored < 7 is
real, log the corrected pattern as a learning.`;
return _ctx.skillName === 'plan-eng-review' ? result.replaceAll('\\`', '`') : result;
}
@@ -1,6 +1,13 @@
import { FREE_TEXT_WRITE_RULE, freeTextFileBash, freeTextFileUse } from '../free-text-file';
export function generateFailureRouting(): string {
return `**Failures: check the cause, then route.** A failing test, error or stack trace shows where a failure surfaces, not its root cause.
- **Cause supplied** (the user names a specific cause and a narrow fix): make one read-only check of the evidence it rests on, such as the named file, test or log. If that confirms the cause, make the fix and run the focused test that covers it. If it does not, invoke \`/investigate\` before any further tracing or edit.
- **Cause unknown:** make one read-only triage pass over the supplied evidence and the directly implicated test and code. Do not reproduce, test hypotheses or search wider. If that pass establishes the root cause and a narrow fix, make the fix and run the focused test that covers it. If the cause, scope or intended behavior is still unclear, invoke \`/investigate\` before any broader diagnosis or edit.`;
}
export function generateTestFailureTriage(): string {
return `## Test Failure Ownership Triage
@@ -48,7 +55,7 @@ Ask with AskUserQuestion in the AskUserQuestion Format. List each failure the sa
### Step T4: Execute the chosen action
**If "Investigate and fix now":**
- Switch to /investigate mindset: root cause first, then minimal fix.
- One read-only root-cause pass, then \`/investigate\` if unclear.
- Fix the pre-existing failure.
- Commit the fix separately from the branch's changes: \`git commit -m "fix: pre-existing test failure in <test-file>"\`
- Continue with the workflow.
+8 -3
View File
@@ -5,7 +5,8 @@
set -euo pipefail
BUN_VERSION=1.4.2
NODE_VERSION=22.20.0
NODE_VERSION=22.23.4
NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce
export DEBIAN_FRONTEND=noninteractive
sudo -E apt-get update -qq
@@ -17,9 +18,13 @@ sudo -E apt-get install -y -qq --no-install-recommends \
# needs; GitHub-hosted runners ship with this relaxed.
sudo sysctl -qw kernel.apparmor_restrict_unprivileged_userns=0
# The same verified release archive as .github/docker/Dockerfile.ci.
if ! command -v node >/dev/null; then
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
| sudo tar -xJ -C /usr/local --strip-components=1
node_tmp=$(mktemp -d)
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" -o "$node_tmp/node.tar.xz"
echo "$NODE_SHA256 $node_tmp/node.tar.xz" | sha256sum -c - >/dev/null
sudo tar -xJ -C /usr/local --strip-components=1 -f "$node_tmp/node.tar.xz"
rm -rf "$node_tmp"
fi
# The exact release archive, verified by SHA-256 before it is unpacked, as in
+10 -9
View File
@@ -793,7 +793,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
3. **Queue-aware pick** (workspace-aware ship):
3. **Queue-aware pick:**
```bash
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
@@ -803,6 +803,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
Offline output without that fallback, failure, malformed output or an empty version
is unusable, even when it contains a version-looking string.
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
Version Gate CI rereads the queue) or stop.
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
@@ -814,16 +818,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
4. **Write the bump** (FRESH, or an approved rebump):
```bash
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
```
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
reclassify and `repair` DRIFT_STALE_PKG.
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
for Step 13. Exit 2 wrote nothing: show stderr, stop.
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
push, verify the committed digest matches generation for the selected VERSION.
+10 -9
View File
@@ -265,7 +265,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
3. **Queue-aware pick** (workspace-aware ship):
3. **Queue-aware pick:**
```bash
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
@@ -275,6 +275,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
Offline output without that fallback, failure, malformed output or an empty version
is unusable, even when it contains a version-looking string.
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
Version Gate CI rereads the queue) or stop.
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
@@ -286,16 +290,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
4. **Write the bump** (FRESH, or an approved rebump):
```bash
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
```
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
reclassify and `repair` DRIFT_STALE_PKG.
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
for Step 13. Exit 2 wrote nothing: show stderr, stop.
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
push, verify the committed digest matches generation for the selected VERSION.
+3 -3
View File
@@ -10,16 +10,16 @@ automation lane, an unattended parent said so). Nothing here publishes.
section does, then (with its Issue links flags; ask nothing):
```bash
~/.claude/skills/gstack/bin/gstack-issue-links --base <base> --out .gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md
```
2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:`
line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`),
line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`),
pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend
cap; `before/after:` one row per claimed metric, measured on base and on this
SHA with the command used; `escalation:` the one condition that stops the merge
(a `requires-remote` lane, an open gate finding, an unverified claim) or `none`.
`issues:` its lines, unpromoted candidates and lookup failures.
Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers.
Full thread ids (`jam_…`) cross-thread.
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied.
### Multi-lane runs
+3 -3
View File
@@ -8,16 +8,16 @@ automation lane, an unattended parent said so). Nothing here publishes.
section does, then (with its Issue links flags; ask nothing):
```bash
~/.claude/skills/gstack/bin/gstack-issue-links --base <base> --out .gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md
```
2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:`
line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`),
line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`),
pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend
cap; `before/after:` one row per claimed metric, measured on base and on this
SHA with the command used; `escalation:` the one condition that stops the merge
(a `requires-remote` lane, an open gate finding, an unverified claim) or `none`.
`issues:` its lines, unpromoted candidates and lookup failures.
Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers.
Full thread ids (`jam_…`) cross-thread.
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied.
### Multi-lane runs
+35 -39
View File
@@ -3,8 +3,8 @@
## Step 9: Pre-Landing Review
Set CYCLES to 0 on first entry only. Keep existing approvals; changed finding scope
needs a new decision. Run checklist/design, specialists (9.1), merge/Red Team (9.2),
exploratory QA (9.2.1), dedup (9.3), then fixes and logging (9.4).
needs a new decision. Order: checklist/design, specialists (9.1), Red Team (9.2),
QA (9.2.1), dedup (9.3), fixes and logging (9.4).
Gated/unsupported specialists skip only their dispatch, never QA or Step 11.
Steps 10–11 queue findings without editing; include those findings in this pass.
Every repeat starts before the checklist read and captures a fresh REVIEW_START.
@@ -12,7 +12,8 @@ Finish the complete review and QA before applying any fix in Step 9.4.
## Confidence Calibration
Every finding MUST include a confidence score (1-10):
Every finding MUST include a severity, CRITICAL (blocks shipping) or INFORMATIONAL
(lower severity, still actioned), and a confidence score (1-10):
| Score | Meaning | Display rule |
|-------|---------|-------------|
@@ -20,20 +21,18 @@ Every finding MUST include a confidence score (1-10):
| 7-8 | High confidence pattern match. Very likely correct. | Show normally |
| 5-6 | Moderate. Could be a false positive. | Show with caveat: "Medium confidence, verify this is actually an issue" |
| 3-4 | Low confidence. Pattern is suspicious but may be fine. | Suppress from main report. Include in appendix only. |
| 1-2 | Speculation. | Only report if severity would be P0. |
| 1-2 | Speculation. | Only report a suspected release-blocking catastrophe (widespread data loss, total outage or system-wide compromise), at CRITICAL severity and marked explicitly speculative. |
**Finding format:**
\`[SEVERITY] (confidence: N/10) file:line — description\`
\`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description\`
Example:
\`[P1] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\`
\`[P2] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\`
\`[CRITICAL] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\`
\`[INFORMATIONAL] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\`
### Pre-emit verification gate
Before any finding is promoted to the report, the gate requires:
1. **Quote the specific code line that motivates the finding** — file:line plus
the verbatim text of the line(s) that triggered it. If the finding is "field
X doesn't exist on model Y", quote the lines of class Y where the field
@@ -43,9 +42,7 @@ Before any finding is promoted to the report, the gate requires:
2. **If you cannot quote the motivating line(s), the finding is unverified.**
Force its confidence to 4-5. Use 4 when it should be suppressed from the main
report; use 5 only when it belongs in the report with the medium-confidence
caveat. Keep suppressed items in the appendix so reviewers can audit
calibration. Do not work around this by inventing
speculative confidence 7+ — that defeats the gate.
caveat. Never invent speculative confidence 7+.
**Framework-meta nudge:** When the symbol is generated by a framework
metaclass, descriptor, ORM Meta inner-class, or migration history (Django
@@ -65,10 +62,8 @@ False-positive classes the gate catches:
| "save() might lose fields" | Requires quoting the ORM signature or model definition |
| "update_fields might miss X" | Requires quoting the field set; if X doesn't exist, the FP is self-evident |
**Calibration learning:** If you report a finding with confidence < 7 and the user
confirms it IS a real issue, that is a calibration event. Your initial confidence was
too low. Log the corrected pattern as a learning so future reviews catch it with
higher confidence.
**Calibration learning:** If the user confirms a reported finding scored < 7 is
real, log the corrected pattern as a learning.
### Core checklist
@@ -78,6 +73,10 @@ This pass is static; defer product probes to Step 9.2.1.
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
**Fix-range repeat** (decision 3): start with `--start review --fix-range`. It prints
REVIEW_START, then `FIX_BASE=<sha>`, which stands in for `origin/<base>` and `<base>` in this pass.
If it refuses, start a full pass.
2.5. **Gate scan.** Before applying the checklist, run the gate scan on the same candidate:
List every gate edit in the candidate (test, CI, runner/lint-config, snapshot and
@@ -121,9 +120,9 @@ fi
### Design-lite checklist
Its numbering is local to this checklist. When frontend review applies, `/ship`
automatically attempts this optional design check; `enabled` expresses that choice,
not a new user question. Step 11 has its own outside-review switch and required native pass.
Its numbering is local. `/ship` attempts this design check automatically when frontend
review applies; `enabled` is that choice, not a user question. Step 11 has its own
outside-review switch and required native pass.
## Design Review (conditional, diff-scoped)
@@ -257,10 +256,9 @@ Use the original DESIGN_START token. COMPLETED is true only when the native chec
Substitute: TIMESTAMP = ISO 8601 datetime, STATUS = "clean" if 0 findings or "issues_found", N = total findings, M = auto-fixed count, D = counted detector findings from step 0 (0 when the detector did not run), COMMIT = output of `git rev-parse --short HEAD`.
The parent owns design-lite; the Design specialist is an independent read.
Before final counting/Fix-First, merge the same evidenced design defect at the same path/line
into one item with both sources and stricter ASK. Retain actual specialist stats;
distinct defects stay separate and neither pass substitutes for the other.
Design-lite (parent) and the Design specialist are independent reads. Before counting
and Fix-First, merge one evidenced design defect at the same path/line into one item
with both sources and the stricter ASK. Keep actual specialist stats and distinct defects.
## Step 9.1: Review Army — Specialist Dispatch
@@ -623,11 +621,9 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
2. **Auto-fix all AUTO-FIX items.** Apply each fix. Output one line per fix:
`[AUTO-FIXED] [file:line] Problem → what you did`
3. **If ASK items remain,** present them in ONE AskUserQuestion:
- List each with number, severity, problem, recommended fix
- Per-item options: A) Fix B) Skip
- Overall RECOMMENDATION
- If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead
3. **If ASK items remain,** present them in ONE AskUserQuestion (or one per item for 3 or
fewer): each item's number, severity, problem and recommended fix with A) Fix B) Skip,
plus an overall RECOMMENDATION.
Save each explicit Skip immediately in the invocation action list with its
identity, scope and supporting source evidence; keep it across repeats.
@@ -674,10 +670,10 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
```bash
~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"'"$(git rev-parse --short HEAD)"'","via":"ship","completed":COMPLETED,"converged":CONVERGED,"cycles":CYCLES}' --finish REVIEW_START
```
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output;
otherwise `clean` only for completed coverage with no
unresolved non-advisory defects; otherwise `issues_found`. N counts current
unresolved defects, not original totals. Missing coverage is not a defect.
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output,
else `clean` only for completed coverage with no unresolved non-advisory defects,
else `issues_found`. N counts unresolved defects now, not original totals; missing
coverage is not a defect.
- `REVIEW_START`: this pass's Step 9 token captured before reading the diff;
never recapture at persistence to certify unreviewed fixes.
- `COMPLETED`: checklist and dispatched specialists/Red Team finish, and all required probes pass.
@@ -699,13 +695,13 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
`open` counts in `issues_found` and by severity. The logger stamps `actor` from
`gstack-session-kind` (a supplied `actor` is discarded) and forces `"open"` on
gate findings in a non-interactive session.
Save the review output and the `Gate edits:` line — they go into the PR body in Step 19.
Any `open` gate finding also decides Step 19's draft state.
Save the review output and `Gate edits:` line for Step 19's PR body; an `open` gate
finding makes that PR a draft.
### Decide whether to repeat Step 9
After persistence, record missing dispatched output, CYCLES and applied fixes in
the invocation record. Apply these decisions in order:
After persistence, record missing output, CYCLES and fixes in the invocation record.
Apply these decisions in order:
1. **Dispatched reviewer output missing:** STOP and name each failed specialist or
Red Team. Retain queued fixes and restore coverage. If this pass made edits,
@@ -715,10 +711,10 @@ the invocation record. Apply these decisions in order:
`converged:false`; do not run a fourth fixing cycle.
3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of
Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing
failures and scope. Keep CYCLES and scoped approvals across this repeat.
failures and scope. Keep CYCLES and scoped approvals across this repeat; it may be a fix-range repeat.
4. **No edits in this pass:** Resolve the required-probe gate below. Only after it
clears may you continue to Step 10. Undispatched gated/unsupported specialists
do not block independently, but never replace QA or required native review.
clears may you continue to Step 10, and only from a full pass: after a fix-range
pass, run one full Step 9 first.
**Required-probe parent gate:** With completed checklist and dispatched reviewers,
failed/unavailable required probes block continuation.
+26 -25
View File
@@ -1,8 +1,8 @@
## Step 9: Pre-Landing Review
Set CYCLES to 0 on first entry only. Keep existing approvals; changed finding scope
needs a new decision. Run checklist/design, specialists (9.1), merge/Red Team (9.2),
exploratory QA (9.2.1), dedup (9.3), then fixes and logging (9.4).
needs a new decision. Order: checklist/design, specialists (9.1), Red Team (9.2),
QA (9.2.1), dedup (9.3), fixes and logging (9.4).
Gated/unsupported specialists skip only their dispatch, never QA or Step 11.
Steps 10–11 queue findings without editing; include those findings in this pass.
Every repeat starts before the checklist read and captures a fresh REVIEW_START.
@@ -18,6 +18,10 @@ This pass is static; defer product probes to Step 9.2.1.
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
**Fix-range repeat** (decision 3): start with `--start review --fix-range`. It prints
REVIEW_START, then `FIX_BASE=<sha>`, which stands in for `origin/<base>` and `<base>` in this pass.
If it refuses, start a full pass.
2.5. **Gate scan.** Before applying the checklist, run the gate scan on the same candidate:
{{GATE_SCAN_BLOCK}}
@@ -33,16 +37,15 @@ This pass is static; defer product probes to Step 9.2.1.
### Design-lite checklist
Its numbering is local to this checklist. When frontend review applies, `/ship`
automatically attempts this optional design check; `enabled` expresses that choice,
not a new user question. Step 11 has its own outside-review switch and required native pass.
Its numbering is local. `/ship` attempts this design check automatically when frontend
review applies; `enabled` is that choice, not a user question. Step 11 has its own
outside-review switch and required native pass.
{{DESIGN_REVIEW_LITE}}
The parent owns design-lite; the Design specialist is an independent read.
Before final counting/Fix-First, merge the same evidenced design defect at the same path/line
into one item with both sources and stricter ASK. Retain actual specialist stats;
distinct defects stay separate and neither pass substitutes for the other.
Design-lite (parent) and the Design specialist are independent reads. Before counting
and Fix-First, merge one evidenced design defect at the same path/line into one item
with both sources and the stricter ASK. Keep actual specialist stats and distinct defects.
{{REVIEW_ARMY}}
@@ -63,11 +66,9 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
2. **Auto-fix all AUTO-FIX items.** Apply each fix. Output one line per fix:
`[AUTO-FIXED] [file:line] Problem → what you did`
3. **If ASK items remain,** present them in ONE AskUserQuestion:
- List each with number, severity, problem, recommended fix
- Per-item options: A) Fix B) Skip
- Overall RECOMMENDATION
- If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead
3. **If ASK items remain,** present them in ONE AskUserQuestion (or one per item for 3 or
fewer): each item's number, severity, problem and recommended fix with A) Fix B) Skip,
plus an overall RECOMMENDATION.
Save each explicit Skip immediately in the invocation action list with its
identity, scope and supporting source evidence; keep it across repeats.
@@ -114,10 +115,10 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
```bash
~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"'"$(git rev-parse --short HEAD)"'","via":"ship","completed":COMPLETED,"converged":CONVERGED,"cycles":CYCLES}' --finish REVIEW_START
```
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output;
otherwise `clean` only for completed coverage with no
unresolved non-advisory defects; otherwise `issues_found`. N counts current
unresolved defects, not original totals. Missing coverage is not a defect.
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output,
else `clean` only for completed coverage with no unresolved non-advisory defects,
else `issues_found`. N counts unresolved defects now, not original totals; missing
coverage is not a defect.
- `REVIEW_START`: this pass's Step 9 token captured before reading the diff;
never recapture at persistence to certify unreviewed fixes.
- `COMPLETED`: checklist and dispatched specialists/Red Team finish, and all required probes pass.
@@ -139,13 +140,13 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
`open` counts in `issues_found` and by severity. The logger stamps `actor` from
`gstack-session-kind` (a supplied `actor` is discarded) and forces `"open"` on
gate findings in a non-interactive session.
Save the review output and the `Gate edits:` line — they go into the PR body in Step 19.
Any `open` gate finding also decides Step 19's draft state.
Save the review output and `Gate edits:` line for Step 19's PR body; an `open` gate
finding makes that PR a draft.
### Decide whether to repeat Step 9
After persistence, record missing dispatched output, CYCLES and applied fixes in
the invocation record. Apply these decisions in order:
After persistence, record missing output, CYCLES and fixes in the invocation record.
Apply these decisions in order:
1. **Dispatched reviewer output missing:** STOP and name each failed specialist or
Red Team. Retain queued fixes and restore coverage. If this pass made edits,
@@ -155,10 +156,10 @@ the invocation record. Apply these decisions in order:
`converged:false`; do not run a fourth fixing cycle.
3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of
Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing
failures and scope. Keep CYCLES and scoped approvals across this repeat.
failures and scope. Keep CYCLES and scoped approvals across this repeat; it may be a fix-range repeat.
4. **No edits in this pass:** Resolve the required-probe gate below. Only after it
clears may you continue to Step 10. Undispatched gated/unsupported specialists
do not block independently, but never replace QA or required native review.
clears may you continue to Step 10, and only from a full pass: after a fix-range
pass, run one full Step 9 first.
**Required-probe parent gate:** With completed checklist and dispatched reviewers,
failed/unavailable required probes block continuation.
+1 -1
View File
@@ -319,7 +319,7 @@ Ask with AskUserQuestion in the AskUserQuestion Format. List each failure the sa
### Step T4: Execute the chosen action
**If "Investigate and fix now":**
- Switch to /investigate mindset: root cause first, then minimal fix.
- One read-only root-cause pass, then `/investigate` if unclear.
- Fix the pre-existing failure.
- Commit the fix separately from the branch's changes: `git commit -m "fix: pre-existing test failure in <test-file>"`
- Continue with the workflow.
+61 -1
View File
@@ -4,7 +4,7 @@ import * as path from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts';
import { linkedWorktrees, runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts';
import { OWNED_READ_APPROVAL } from '../autoplan/bin/owned-read';
const ROOT = path.join(import.meta.dir, '..');
@@ -192,3 +192,63 @@ describe('Autoplan hook journal-root verdicts', () => {
for (const detail of ['unrecognized_shape:cwd_spelling', 'analytics/autoplan-guard.jsonl']) expect(guide).toContain(detail);
});
});
describe('linkedWorktrees from a project directory below the repository root (#3296)', () => {
const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0' };
const git = (cwd: string, ...args: string[]) => {
const child = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000, env: gitEnv });
expect(child.status).toBe(0);
};
function repo(): string {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(dir);
git(dir, 'init', '-q', '-b', 'main');
git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed');
return dir;
}
const real = (dir: string) => fs.realpathSync(dir);
test.skipIf(process.platform === 'win32')('the reporter layout: a subfolder project directory owns its worktree under that subfolder', () => {
const root = repo(), sub = path.join(root, 'sub'); fs.mkdirSync(path.join(sub, '.claude', 'worktrees'), { recursive: true });
const worktree = path.join(sub, '.claude', 'worktrees', 'w');
git(root, 'worktree', 'add', '-q', worktree);
expect(linkedWorktrees(sub)).toEqual([real(worktree)]);
expect(linkedWorktrees(path.join(sub, 'deeper', 'still'))).toEqual([real(worktree)]);
expect(linkedWorktrees(root)).toEqual([real(worktree)]);
});
test.skipIf(process.platform === 'win32')('a nested independent repository stops the walk at its own .git', () => {
const outer = repo(), inner = path.join(outer, 'vendor', 'lib');
fs.mkdirSync(inner, { recursive: true });
git(inner, 'init', '-q', '-b', 'main');
git(inner, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed');
git(outer, 'worktree', 'add', '-q', path.join(outer, 'outer-wt'));
expect(linkedWorktrees(inner)).toEqual([]);
expect(linkedWorktrees(path.join(inner, 'src'))).toEqual([]);
});
test.skipIf(process.platform === 'win32')('a project directory inside a linked worktree has no registry of its own', () => {
const root = repo(), worktree = path.join(root, 'wt');
git(root, 'worktree', 'add', '-q', worktree);
fs.mkdirSync(path.join(worktree, 'pkg'));
expect(linkedWorktrees(worktree)).toEqual([]);
expect(linkedWorktrees(path.join(worktree, 'pkg'))).toEqual([]);
});
test('a registry entry whose worktree names another repository is still refused from a subfolder', () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(root);
const other = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-other-'))); dirs.push(other);
const worktree = path.join(root, 'sub', 'wt'); fs.mkdirSync(worktree, { recursive: true });
const entry = path.join(root, '.git', 'worktrees', 'wt'); fs.mkdirSync(entry, { recursive: true });
const foreign = path.join(other, '.git', 'worktrees', 'wt'); fs.mkdirSync(foreign, { recursive: true });
fs.writeFileSync(path.join(entry, 'gitdir'), path.join(worktree, '.git') + '\n');
fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${foreign}\n`);
expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([]);
fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${entry}\n`);
expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([worktree]);
});
test('a directory with no .git anywhere above it has no worktrees', () => {
expect(linkedWorktrees(path.parse(tmpdir()).root)).toEqual([]);
});
});
+20 -2
View File
@@ -16,9 +16,9 @@ const ROOT = path.resolve(import.meta.dir, '..');
let home: string;
let remote: string;
function run(argv: string[]) {
function run(argv: string[], extraEnv: Record<string, string> = {}) {
const r = spawnSync(path.join(ROOT, 'bin', argv[0]), argv.slice(1), {
env: { ...process.env, HOME: home, GSTACK_HOME: home },
env: { ...process.env, HOME: home, GSTACK_HOME: home, ...extraEnv },
encoding: 'utf-8',
timeout: 60_000,
});
@@ -145,6 +145,24 @@ describe('artifacts_sync_removals', () => {
expect(run(['gstack-brain-sync', '--publish-removals', '--yes']).stdout).toContain('no removals to publish');
});
test('--publish-removals cleans up its temp files and lock on every exit path', () => {
syncAll(['projects/p/designs/a.md', 'projects/p/designs/b.md']);
fs.rmSync(path.join(home, 'projects/p/designs/a.md'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-removals-tmp-'));
try {
for (const argv of [['--publish-removals'], ['--publish-removals', '--yes'], ['--publish-removals', '--yes']]) {
const r = run(['gstack-brain-sync', ...argv], { TMPDIR: tmp });
expect(r.stderr).not.toContain('unbound variable');
expect(r.status).toBe(0);
expect(fs.readdirSync(tmp)).toEqual([]);
expect(fs.existsSync(path.join(home, '.brain-sync.lock.d'))).toBe(false);
}
expect(remoteFiles()).toEqual(['projects/p/designs/b.md']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('config rejects values other than on and off', () => {
const r = run(['gstack-config', 'set', 'artifacts_sync_removals', 'yes']);
expect(r.stderr).toContain("artifacts_sync_removals 'yes' not recognized");
+15 -15
View File
@@ -3,7 +3,7 @@
* bin/gstack-bun-version.sh and `resolveBun` / `launchRuntime` in
* lib/pregate/runtime.ts must pick the same binary (by realpath) for the
* GBRA-60 layout: the image's old Bun first on PATH, Bun 1.4.2 in
* $BUN_INSTALL/bin, Bun 1.4.3 elsewhere, a project pinning 1.4.3 only through
* $BUN_INSTALL/bin, Bun 1.4.4 elsewhere, a project pinning 1.4.4 only through
* a CI lane, an unpinned project and a directory outside any project. A file
* carrying the shim marker is never returned, and a candidate is chosen only
* when it satisfies every applicable pin.
@@ -41,7 +41,7 @@ function layout(opts: { storeVersions?: string[]; homeVersion?: string } = {}) {
const pinned = path.join(base, 'gbrain');
fs.mkdirSync(path.join(pinned, '.github/workflows'), { recursive: true });
fs.writeFileSync(path.join(pinned, 'package.json'), '{ "name": "gbrain", "engines": { "bun": ">=1.4.0" } }\n');
fs.writeFileSync(path.join(pinned, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.3\n');
fs.writeFileSync(path.join(pinned, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.4\n');
const unpinned = path.join(base, 'plain');
fs.mkdirSync(unpinned);
fs.writeFileSync(path.join(unpinned, 'package.json'), '{ "name": "plain" }\n');
@@ -74,24 +74,24 @@ function tsResolve(l: ReturnType<typeof layout>, project: string | null): string
}
describe('one Bun resolution contract, two twins', () => {
test('GBRA-60: 1.4.3 in the store, 1.4.2 in $BUN_INSTALL, a CI-lane pin of 1.4.3 — both twins pick the store 1.4.3 for the project and for an unpinned or outside directory', () => {
const l = layout({ storeVersions: ['1.4.3'] });
const store143 = path.join(bunStore(l.state), '1.4.3/bun');
for (const [project, want] of [[l.pinned, `${store143} 1.4.3 pin`], [l.unpinned, `${store143} 1.4.3 default`], [null, `${store143} 1.4.3 default`]] as const) {
test('GBRA-60: 1.4.4 in the store, 1.4.2 in $BUN_INSTALL, a CI-lane pin of 1.4.4 — both twins pick the store 1.4.4 for the project and for an unpinned or outside directory', () => {
const l = layout({ storeVersions: ['1.4.4'] });
const store144 = path.join(bunStore(l.state), '1.4.4/bun');
for (const [project, want] of [[l.pinned, `${store144} 1.4.4 pin`], [l.unpinned, `${store144} 1.4.4 default`], [null, `${store144} 1.4.4 default`]] as const) {
expect(bashResolve(l, project)).toBe(want);
expect(tsResolve(l, project)).toBe(want);
}
const lanes = launchRuntime(l.pinned, l.env);
expect(path.join(lanes.dir!, 'bun')).toBe(store143);
expect(path.join(lanes.dir!, 'bun')).toBe(store144);
expect(lanes.verdict).toBe('skipped');
expect(lanes.label).toContain('install verdict: not checked');
});
test('1.4.2 in $BUN_INSTALL and 1.4.3 in ~/.bun/bin, nothing in the store: the pinned project skips the ineligible 1.4.2; unpinned directories take $BUN_INSTALL first', () => {
const l = layout({ homeVersion: '1.4.3' });
const home143 = path.join(l.home, '.bun/bin/bun');
test('1.4.2 in $BUN_INSTALL and 1.4.4 in ~/.bun/bin, nothing in the store: the pinned project skips the ineligible 1.4.2; unpinned directories take $BUN_INSTALL first', () => {
const l = layout({ homeVersion: '1.4.4' });
const home144 = path.join(l.home, '.bun/bin/bun');
const bi142 = path.join(l.bunInstall, 'bin/bun');
for (const [project, want] of [[l.pinned, `${home143} 1.4.3 home`], [l.unpinned, `${bi142} 1.4.2 BUN_INSTALL`], [null, `${bi142} 1.4.2 BUN_INSTALL`]] as const) {
for (const [project, want] of [[l.pinned, `${home144} 1.4.4 home`], [l.unpinned, `${bi142} 1.4.2 BUN_INSTALL`], [null, `${bi142} 1.4.2 BUN_INSTALL`]] as const) {
expect(bashResolve(l, project)).toBe(want);
expect(tsResolve(l, project)).toBe(want);
}
@@ -106,20 +106,20 @@ describe('one Bun resolution contract, two twins', () => {
});
test('a symlinked candidate resolves to its target realpath in both twins (portable readlink loop, no realpath(1))', () => {
const l = layout({ storeVersions: ['1.4.3'] });
const l = layout({ storeVersions: ['1.4.4'] });
const linkDir = path.join(l.base, 'links');
fs.mkdirSync(linkDir);
fs.symlinkSync('../home/.gstack/runtimes/bun/1.4.3/bun', path.join(linkDir, 'bun'));
fs.symlinkSync('../home/.gstack/runtimes/bun/1.4.4/bun', path.join(linkDir, 'bun'));
fs.rmSync(path.join(l.bunInstall, 'bin/bun'));
const env = { ...l.env, GSTACK_STATE_ROOT: path.join(l.base, 'elsewhere'), PATH: `${linkDir}:${l.env.PATH}` };
const want = `${path.join(bunStore(l.state), '1.4.3/bun')} 1.4.3 PATH`;
const want = `${path.join(bunStore(l.state), '1.4.4/bun')} 1.4.4 PATH`;
expect(bashResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
expect(tsResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
});
// Integration item 8: the gstack checkout itself binds the 'none' verdict.
test('launchRuntime on the gstack checkout reads the none verdict and launches its recorded Bun', () => {
const l = layout({ storeVersions: ['1.4.3'] });
const l = layout({ storeVersions: ['1.4.4'] });
const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict');
const checkout = fs.realpathSync(ROOT);
const file = installVerdictPath(checkout, l.state, null);
+82
View File
@@ -0,0 +1,82 @@
/**
* CHANGELOG merges that would carry damage to released history (GSTA-67,
* from #3345 on 2026-10-22). #3345's pin 1eb7092 replaced main's
* `## [1.91.179.0]` heading with `## [Unreleased]` instead of inserting
* above it, and mergeChangelogs returned non-null output with 179's body
* duplicated into [Unreleased]; the train's T1 6755ab8 carried it. The
* fixtures under test/fixtures/merge-train-3345/ are the real CHANGELOGs at
* those commits, cut below 1.91.177.0. A null merge means a hand resolve.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { changelogDamage, mergeChangelogs, ownEntryAboveMain, parseChangelog } from '../lib/changelog-check';
const fixture = (name: string) => fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'merge-train-3345', name), 'utf8');
const base = fixture('base-a59b5e4.md');
const pin = fixture('pin-1eb7092.md');
const main = fixture('main-4a98e40.md');
const keys = (text: string) => parseChangelog(text).sections.map(s => s.version ?? 'Unreleased');
describe('mergeChangelogs refuses damage to released history', () => {
test('GSTA-64 repro: a released heading replaced by [Unreleased] is null, not a merge with the 1.0.1 body duplicated', () => {
const ours = '# Changelog\n\n## [Unreleased]\n\nNew entry.\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
const theirs = '# Changelog\n\n## [1.0.2] - d\n\nMain body.\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
expect(changelogDamage(ours, theirs)).toContain("ours's Unreleased section holds released 1.0.1 text");
expect(mergeChangelogs(ours, theirs)).toBeNull();
const baseText = '# Changelog\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
expect(changelogDamage(ours, theirs, baseText)).toBe('ours drops released heading 1.0.1 that the merge base carries');
});
test('#3345: pin 1eb7092 against main 4a98e40 is null with or without the merge base; the train T1 it produced duplicated 179', () => {
expect(changelogDamage(pin, main, base)).toBe('ours drops released heading 1.91.179.0 that the merge base carries');
expect(changelogDamage(pin, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/);
expect(mergeChangelogs(pin, main, base)).toBeNull();
expect(mergeChangelogs(pin, main)).toBeNull();
const t1 = fixture('train-T1-6755ab8.md');
expect(keys(t1)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
expect(changelogDamage(t1, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/);
});
test('either side, and a renamed heading, count as damage', () => {
expect(changelogDamage(main, pin, base)).toBe('theirs drops released heading 1.91.179.0 that the merge base carries');
const renamed = main.replace('## [1.91.178.0]', '## [1.91.178.9]');
expect(changelogDamage(renamed, main, base)).toBe('ours drops released heading 1.91.178.0 that the merge base carries');
expect(changelogDamage(renamed, main)).toMatch(/^ours's 1\.91\.178\.9 section holds released 1\.91\.178\.0 text/);
});
test('negative controls: the real hand resolve, an entry above a newer main, a shared tentative stamp and two [Unreleased] blocks are not damage', () => {
const fixed = pin.replace(/\n\n\*\*docsync-fault-interface/, '\n\n## [1.91.179.0] - 2026-10-22\n\n**docsync-fault-interface');
expect(keys(fixed)).toEqual(['Unreleased', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
expect(changelogDamage(fixed, main, base)).toBeNull();
const merged = mergeChangelogs(fixed, main, base)!;
expect(keys(merged)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
expect(merged.split('**docsync-fault-interface fits its budgets').length).toBe(2);
const b = '## [1.2.3.0] - d\n\n- base release entry\n';
expect(mergeChangelogs(`# C\n\n## [1.2.4.0] - d\n\n- mine, a new entry\n\n${b}`, `# C\n\n## [1.2.4.0] - e\n\n- theirs, a new entry\n\n${b}`, `# C\n\n${b}`)).not.toBeNull();
expect(changelogDamage(`# C\n\n## [Unreleased]\n\n- #2 change, its entry\n\n${b}`, `# C\n\n## [Unreleased]\n\n- #1 change, its entry\n\n${b}`, `# C\n\n${b}`)).toBeNull();
});
});
describe("ownEntryAboveMain: a merged-in head's CHANGELOG however it was resolved", () => {
const dir = path.join(import.meta.dir, 'fixtures', 'merge-train-3345');
const diff = spawnSync('git', ['diff', '--no-index', '--unified=0', '--no-color', path.join(dir, 'base-a59b5e4.md'), path.join(dir, 'pin-1eb7092.md')], { encoding: 'utf8', timeout: 30_000 }).stdout;
const authored = new Set(diff.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1)));
const stamped = fixture('stamped-de23f5d.md');
test("#3345's stamped head de23f5d is main 4a98e40's CHANGELOG plus its own entry; the damaged train T1 is not", () => {
expect(authored.has('### Fixed')).toBe(true);
expect(authored.has('## [1.91.179.0] - 2026-10-22')).toBe(false);
expect(ownEntryAboveMain(stamped, main, authored)).toBeNull();
expect(ownEntryAboveMain(fixture('train-T1-6755ab8.md'), main, authored)).toMatch(/^the PR's entry holds a line its pin never added: "\*\*docsync-fault-interface/);
});
test('negative controls: an invented line, a dropped or edited main section, a changed header and a duplicate heading are refused', () => {
expect(ownEntryAboveMain(stamped.replace('### Fixed\n', '### Fixed\n- added after the gate\n'), main, authored)).toBe('the PR\'s entry holds a line its pin never added: "- added after the gate"');
expect(ownEntryAboveMain(stamped.replace(/## \[1\.91\.180\.0\][^\n]*\n/, ''), main, authored)).toBe("main's ## [1.91.181.0] - 2026-10-23 is not carried unchanged below the PR's entry");
expect(ownEntryAboveMain(stamped.replace('## [1.91.178.0]', '## [1.91.178.0]\n\n- smuggled'), main, authored)).toBe("main's ## [1.91.178.0] - 2026-10-22 is not carried unchanged below the PR's entry");
expect(ownEntryAboveMain(stamped.replace('# Changelog', '# Change log'), main, authored)).toBe('the header differs from main');
expect(ownEntryAboveMain(stamped.replace('## [1.91.182.0]', '## [1.91.181.0]'), main, authored)).toMatch(/is headed twice$/);
});
});
+42 -2
View File
@@ -24,7 +24,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(__dirname, '..');
const DOCKERFILE = path.join(ROOT, '.github', 'docker', 'Dockerfile.ci');
const DOCKERFILE = path.join(__dirname, '..', '.github', 'docker', 'Dockerfile.ci');
/** Package specs from every `npm i -g` / `npm install -g` in the Dockerfile. */
export function globalNpmInstallSpecs(source: string): string[] {
@@ -193,7 +193,7 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => {
test(`${rel} downloads the verified linux-x64 archive instead of piping an installer into a shell`, () => {
expect(curlPipedToShell(source)).toEqual([]);
const download = source.indexOf('/releases/download/bun-v$BUN_VERSION/bun-linux-x64.zip');
const verify = source.indexOf('sha256sum -c');
const verify = source.indexOf('sha256sum -c', download);
const unpack = source.indexOf('unzip -q');
expect(download).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(download);
@@ -216,3 +216,43 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => {
});
}
});
describe('CI Node.js installs match the image and are verified (#3354)', () => {
const docker = fs.readFileSync(DOCKERFILE, 'utf-8');
const imageVersion = docker.match(/^ENV NODE_VERSION=(\S+)$/m)?.[1];
const imageSha = docker.match(/^ARG NODE_SHA256=(\S+)$/m)?.[1];
const nodeRun = logicalLines(docker).find((line) => line.includes('nodejs.org/dist/v${NODE_VERSION}/')) ?? '';
test('Dockerfile.ci pins an exact Node.js version and the SHA-256 of its linux-x64 archive', () => {
expect(imageVersion).toMatch(/^\d+\.\d+\.\d+$/);
expect(imageSha).toMatch(/^[0-9a-f]{64}$/);
});
test('Dockerfile.ci checks the Node.js archive before it is unpacked', () => {
expect(nodeRun, 'Node.js must come from the nodejs.org release archive').not.toBe('');
const download = nodeRun.indexOf('-o /tmp/node.tar.xz');
const verify = nodeRun.indexOf('echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c -');
const unpack = nodeRun.indexOf('tar -xJ');
expect(download).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(download);
expect(unpack).toBeGreaterThan(verify);
});
const rel = 'scripts/ubicloud/setup-free-suite.sh';
const source = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
test(`${rel} pins the same Node.js version and SHA-256 as Dockerfile.ci`, () => {
expect(imageSha).toMatch(/^[0-9a-f]{64}$/);
expect(source.match(/^NODE_VERSION=(\S+)$/m)?.[1]).toBe(imageVersion!);
expect(source.match(/^NODE_SHA256=(\S+)$/m)?.[1]).toBe(imageSha!);
});
test(`${rel} checks the Node.js archive before it is unpacked`, () => {
const download = source.indexOf('/node-v$NODE_VERSION-linux-x64.tar.xz" -o "$node_tmp/node.tar.xz"');
const verify = source.indexOf('echo "$NODE_SHA256 $node_tmp/node.tar.xz" | sha256sum -c -');
const unpack = source.indexOf('tar -xJ');
expect(download).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(download);
expect(unpack).toBeGreaterThan(verify);
});
});
+2 -2
View File
@@ -128,13 +128,13 @@ describe('per-case host metadata', () => {
for (const id of Object.keys(E2E_HOSTS)) expect(Object.hasOwn(E2E_TOUCHFILES, id), id).toBe(true);
});
test('classifies 52 adapter-only, 29 Claude-specific, 26 rework and 1 host-neutral case', () => {
test('classifies 52 adapter-only, 33 Claude-specific, 26 rework and 1 host-neutral case', () => {
const counts: Record<string, number> = {};
for (const support of Object.values(E2E_HOSTS)) counts[support] = (counts[support] ?? 0) + 1;
expect(counts.both).toBe(52);
expect(counts.rework).toBe(26);
expect(counts['host-neutral']).toBe(1);
expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(29);
expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(33);
});
});
+46
View File
@@ -185,6 +185,52 @@ describe('version gate: unstamped heads under a train policy', () => {
}
});
describe('a CHANGELOG over 1 MiB (GSTA-67: #3345 head 1eb7092 passed because the 1.67 MB CHANGELOG overflowed the read)', () => {
const excerpt = (name: string) => fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'merge-train-3345', name), 'utf8');
const baseTop = excerpt('base-a59b5e4.md');
const padding = Array.from({ length: 2400 }, (_, i) => `## [1.0.${2400 - i}.0] - 2026-01-01\n\nOlder release ${i}: ${'x'.repeat(440)}\n`).join('\n');
const big = `${baseTop}\n${padding}`;
const bigFixture = () => {
const r = fixture();
write(r, 'CHANGELOG.md', big);
commit(r, 'main with a full-size CHANGELOG');
r.git('push', '-q', 'origin', 'main');
r.git('fetch', '-q', 'origin');
branch(r);
expect(Buffer.byteLength(big)).toBeGreaterThan(1.05 * 1024 * 1024);
return r;
};
test("#3345's change (the 1.91.179.0 heading replaced by [Unreleased]) is refused, naming the dropped heading", () => {
const r = bigFixture();
write(r, 'CHANGELOG.md', big.replace(baseTop, excerpt('pin-1eb7092.md')));
const res = run(r, commit(r, 'the 1eb7092 CHANGELOG edit'));
expect(res.code).toBe(1);
expect(res.out).toContain('::error::PR #3320: CHANGELOG.md drops released heading 1.91.179.0; an unstamped head may only add its [Unreleased] section above the latest release.');
});
test('control: an additive [Unreleased] entry above the latest release still passes at full size', () => {
const r = bigFixture();
write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]'));
const res = run(r, commit(r, 'additive entry'));
expect(res.code).toBe(0);
expect(res.out).toContain('is unstamped under the base');
});
test('a CHANGELOG that cannot be read is refused, never passed', () => {
const r = bigFixture();
write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]'));
const head = commit(r, 'additive entry, then a damaged object');
const blob = r.git('rev-parse', `${head}:CHANGELOG.md`);
const loose = path.join(r.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2));
fs.chmodSync(loose, 0o644);
fs.writeFileSync(loose, fs.readFileSync(loose).subarray(0, 2000));
const res = run(r, head);
expect(res.code).toBe(1);
expect(res.out).toContain(`::error::PR #3320: cannot read CHANGELOG.md at ${head}.`);
});
});
test('the workflow runs the gate from the merge commit with the base ref and head sha', () => {
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any;
const steps = workflow.jobs.check.steps;
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more