Merge commit '9d9d4c31bb87b1215ca4ccba911318de8e04599b' into gsta251/investigate-gate

This commit is contained in:
garrytan committed 2026-11-02 14:50:45 +00:00
commit 81f71d0309
238 files changed
+11910 -902

No files matched your search

+15
View File
@@ -193,6 +193,21 @@ RUN for i in 1 2 3; do \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*
# ffmpeg (with ffprobe): /design-motion's checks and exports shell out to it
# (lib/motion-encode.ts), and the marathon design-motion-showcase case runs
# that flow end to end. The image had none, so every trial stopped at
# FFMPEG_MISSING (#3687). The build fails if the packaged ffmpeg ever lacks a
# filter or encoder the checks and exports use.
RUN for i in 1 2 3; do \
apt-get update && apt-get install -y --no-install-recommends ffmpeg && break || \
(echo "ffmpeg install retry $i/3"; sleep 10); \
done \
&& rm -rf /var/lib/apt/lists/* \
&& command -v ffprobe \
&& filters="$(ffmpeg -hide_banner -filters)" && encoders="$(ffmpeg -hide_banner -encoders)" \
&& for f in psnr gblur palettegen paletteuse signalstats tile; do echo "$filters" | grep -qw "$f" || { echo "ERROR: ffmpeg lacks the $f filter" >&2; exit 1; }; done \
&& for e in libx264 gif; do echo "$encoders" | grep -qw "$e" || { echo "ERROR: ffmpeg lacks the $e encoder" >&2; exit 1; }; done
# Pre-install dependencies (cached layer — only rebuilds when package.json or
# bun.lock changes). Copy BOTH so install is deterministic and matches local
# resolution. Without bun.lock here, bun install resolved transitive deps
+25 -10
View File
@@ -41,13 +41,21 @@ on:
description: 'Runner-sizing experiments only: shard count, 1-40 (empty keeps FREE_SUITE_SHARDS)'
type: string
default: ''
free_jobs:
description: 'Runner-sizing experiments only: sub-shards per CI shard (--shard-jobs), 1-8 (empty keeps FREE_SUITE_JOBS)'
type: string
default: ''
# The one source of the free-suite runner and shard count. pull_request and
# push runs never see workflow_dispatch inputs, so free-plan resolves these
# (or a dispatch override) into outputs that free-suite reads.
# The one source of the free-suite runner, shard count and sub-shards per
# shard. pull_request and push runs never see workflow_dispatch inputs, so
# free-plan resolves these (or a dispatch override) into outputs and the plan
# that free-suite reads. FREE_SUITE_JOBS is k in `--ci-plan --shard-jobs k`:
# each shard runs min(k, its parallel files) sandboxed sub-shards side by side
# (scripts/lib/shard-jobs.ts); the exclusive host-state shard stays one child.
env:
FREE_SUITE_RUNNER: ubicloud-standard-4
FREE_SUITE_SHARDS: '20'
FREE_SUITE_RUNNER: ubicloud-standard-8
FREE_SUITE_SHARDS: '10'
FREE_SUITE_JOBS: '4'
# Keyed on the PR number, not head_ref: a bare branch name carries no fork
# prefix, so same-name branches from two forks would share one group and a
@@ -120,10 +128,12 @@ jobs:
env:
RUNNER_OVERRIDE: ${{ inputs.free_runner }}
SHARDS_OVERRIDE: ${{ inputs.free_shards }}
JOBS_OVERRIDE: ${{ inputs.free_jobs }}
run: |
set -euo pipefail
runner="${RUNNER_OVERRIDE:-$FREE_SUITE_RUNNER}"
shards="${SHARDS_OVERRIDE:-$FREE_SUITE_SHARDS}"
jobs="${JOBS_OVERRIDE:-$FREE_SUITE_JOBS}"
case "$runner" in
ubicloud-standard-2|ubicloud-standard-4|ubicloud-standard-8) ;;
*) echo "::error::free_runner '$runner' is not ubicloud-standard-2, -4 or -8"; exit 1 ;;
@@ -132,10 +142,14 @@ jobs:
''|*[!0-9]*) echo "::error::free_shards '$shards' is not a whole number"; exit 1 ;;
esac
if [ "$shards" -lt 1 ] || [ "$shards" -gt 40 ]; then echo "::error::free_shards $shards is outside 1-40"; exit 1; fi
if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ]; then
echo "- Runner-sizing experiment: $shards shards on $runner (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY"
case "$jobs" in
''|*[!0-9]*) echo "::error::free_jobs '$jobs' is not a whole number"; exit 1 ;;
esac
if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 8 ]; then echo "::error::free_jobs $jobs is outside 1-8"; exit 1; fi
if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ] || [ "$jobs" != "$FREE_SUITE_JOBS" ]; then
echo "- Runner-sizing experiment: $shards shards on $runner at $jobs sub-shards each (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER at $FREE_SUITE_JOBS); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY"
fi
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards")
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards" --shard-jobs "$jobs")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
echo "runner=$runner" >> "$GITHUB_OUTPUT"
- name: Seed growth ratchet (60 s ceiling, shrink-only allowlist)
@@ -186,7 +200,8 @@ jobs:
# code that compares a realpath with an unresolved path passes on Linux's
# /tmp and fails here (#2982). TMPDIR is deliberately left at the runner's
# default, and the job fails if that layout is not symlinked. It also sources
# the Codex probe from zsh, macOS's default shell (#3024; no paid call).
# the Codex probe from zsh, macOS's default shell (#3024; no paid call), and
# runs ubi-runner under macOS's bsdtar and one-true-awk (#3680, #3679).
macos-named-regressions:
needs: reuse
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
@@ -229,7 +244,7 @@ jobs:
resolved="$(realpath "$tmp")"
echo "temp root: $tmp -> $resolved"
test "$resolved" != "${tmp%/}"
files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts)
files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts test/regression-issue3680-ubicloud-bsd-userland.test.ts)
for file in "${files[@]}"; do test -f "$file"; done
bun test "${files[@]}"
+20 -1
View File
@@ -42,6 +42,10 @@ on:
description: Record per-file Windows durations (artifact) instead of running the lane
type: boolean
default: false
windows_jobs:
description: 'Sub-shard experiments only: sub-shards per Windows shard (--shard-jobs), 1-4 (empty keeps WINDOWS_FREE_JOBS). Never cite such a run as a PR result.'
type: string
default: ''
# free-durations-record.yml calls the recording path weekly.
workflow_call:
inputs:
@@ -49,6 +53,11 @@ on:
type: boolean
default: false
# k in `--ci-plan --shard-jobs k`: each Windows shard runs min(k, its parallel
# files) sandboxed sub-shards side by side (scripts/lib/shard-jobs.ts).
env:
WINDOWS_FREE_JOBS: '1'
concurrency:
group: windows-free-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
@@ -105,8 +114,18 @@ jobs:
# 4-vCPU Windows runner, so the lane lands near 4 min with setup.
env:
GSTACK_FREE_TEST_DURATIONS: scripts/free-test-durations-windows.json
JOBS_OVERRIDE: ${{ inputs.windows_jobs }}
run: |
matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6)
set -euo pipefail
jobs="${JOBS_OVERRIDE:-$WINDOWS_FREE_JOBS}"
case "$jobs" in
''|*[!0-9]*) echo "::error::windows_jobs '$jobs' is not a whole number"; exit 1 ;;
esac
if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 4 ]; then echo "::error::windows_jobs $jobs is outside 1-4"; exit 1; fi
if [ "$jobs" != "$WINDOWS_FREE_JOBS" ]; then
echo "- Sub-shard experiment: $jobs sub-shards per Windows shard (default $WINDOWS_FREE_JOBS); not a PR Windows Free Tests result" >> "$GITHUB_STEP_SUMMARY"
fi
matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6 --shard-jobs "$jobs")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
+43 -2
View File
@@ -619,7 +619,7 @@ Storage stays intact by default. With explicit approval on a Chromium target, `-
| Command | Description |
|---------|-------------|
| `tabs` | List open tabs |
| `tabs` | List open tabs, with each owned tab's owner (a pair-agent client or `session:<name>`) |
| `tab <id>` | Switch to tab |
| `newtab [url] [--json]` | Open new tab; `--json` returns `{tabId, url}` for programmatic use |
| `closetab [id]` | Close tab |
@@ -634,6 +634,27 @@ in a long script, `status`, `tabs`, `tab <id>`, `newtab`, `closetab`,
not responding. A command that needs the stuck tab times out after 30s and
names it, so you can switch away or close it without `--force-restart`.
**Sessions keep parallel agents on their own tabs (#1961).** A call from a
known agent harness (Claude Code, Codex and others) gets a session named after
that agent process; its first command opens the session's own tab and later
commands stay there without moving the shared active tab. Name one with
`--session <name>` or `BROWSE_SESSION=<name>`; `BROWSE_SESSION=` turns it off.
`--tab-id` and `BROWSE_TAB` win over a session and no longer move the active
tab either. Sessions isolate tabs, not cookies or storage, and only the local
root token can name one.
### Phone emulation + touch
This is Chromium emulating a phone on one tab, not Safari; a real device still goes through `/ios-qa`.
| Command | Description |
|---------|-------------|
| `device [<name> [--landscape] \| off \| --list]` | Emulate a phone on the current tab only (size, pixel ratio, touch, `pointer: coarse`, user agent) from Playwright's devices table; other tabs stay desktop. `off` restores; `viewport` and `responsive` refuse while a device is active |
| `tap <@ref\|sel\|x y> [--hold <ms>]` | Touch and release at an element's center or at x y. Needs `device` first |
| `swipe <@ref\|sel\|x y> <up\|down\|left\|right> [--distance <px>] [--ms <n>]` | One-finger swipe (default 300px over 300ms). Needs `device` first |
| `pinch <@ref\|sel\|x y> <scale>` | Two-finger pinch; scale above 1 zooms in, below 1 zooms out. Needs `device` first |
| `touch [down\|move <finger 0-9> <x> <y> \| up <finger> \| cancel]` | Hold, move and lift numbered fingers for multi-finger flows; no action lists held fingers. Needs `device` first |
### Extraction
| Command | Description |
@@ -1515,6 +1536,19 @@ capacity each), flushed to disk asynchronously via `Bun.write()`:
- Network: `.gstack/browse-network.log`
- Dialog: `.gstack/browse-dialog.log`
The console, network, dialog and startup-error logs start empty at each daemon
start and can grow while one daemon runs; `browse-daemon.log` and the
`browse-audit.jsonl` command audit build up across restarts, and codex scratch
files collect in `.gstack/tmp/`.
Run `gstack-clean` in the repository to see what it would reclaim, then
`gstack-clean --apply` to do it: it cuts these logs to their last 256 KB
once they pass 1 MB, removes `tmp/codex-*` files older than 7 days, and
trims the audit log only when you pass `--audit-days N`. It never touches
`browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/`
or `browse-states/`, and it skips the browse files while the daemon runs.
Deleting the whole `.gstack/` folder instead also deletes your cookies and
browser skills.
The `console`, `network`, and `dialog` commands read from the in-memory
buffers (not disk) so capture is real-time even when disk is slow.
@@ -1732,6 +1766,9 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a.
| `BROWSE_HEADLESS_SKIP` | 0 | Skip Chromium launch entirely (test harness only) |
| `BROWSE_TUNNEL` | 0 | Activate the dual-listener tunnel architecture (requires `NGROK_AUTHTOKEN`) |
| `BROWSE_TUNNEL_LOCAL_ONLY` | 0 | Test-only — bind both listeners locally without ngrok |
| `BROWSE_SESSION` | unset (agent harness calls get `agent-<pid>`) | CLI side: this caller's session name; empty or `off` turns sessions off |
| `BROWSE_SESSION_IDLE_MINUTES` | 120 | Daemon side: close (headless) or release (headed) a session's tabs after this many idle minutes; 0 keeps them |
| `BROWSE_MAX_SESSIONS` | 16 | Daemon side: most sessions at once; the least recently used idle one is closed to make room |
| `CHROMIUM_PROFILE` | unset | Explicit headed Chromium profile directory (used by gbrowser's gbd per-workspace); honored by headed launch and profile-lock cleanup, not used by headless sessions |
| `GSTACK_DISABLE_GPU` | unset | On macOS, headless Chromium starts with a GPU-taming flag set (it stops runaway GPU-process spin) that also disables WebGL: `getContext('webgl')`/`'webgl2'` return `null`, so WebGL pages (three.js, Mapbox GL, sigma.js) show their fallback. Set `GSTACK_DISABLE_GPU=off` to enable WebGL (`off` is the only recognized value). |
| `GSTACK_BROWSE_MAX_HTML_BYTES` | 52428800 (50MB) | `load-html` size cap |
@@ -1776,7 +1813,11 @@ browse/
│ ├── cdp-inspector.ts # $B inspect — persistent CDP session per page
│ ├── activity.ts # ActivityEntry, CircularBuffer, SSE subscribers, privacy filtering
│ ├── buffers.ts # Console/network/dialog circular buffers (O(1) ring)
│ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope)
│ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope, device profile, held fingers)
│ ├── session-name.ts # CLI: which session a call belongs to (--session, BROWSE_SESSION, agent harness pid)
│ ├── session-tabs.ts # Daemon: per-session and per-tab-id request scopes, idle sweep, session cap
│ ├── device-emulation.ts # $B device: per-tab phone emulation over the tab's own CDP session
│ ├── touch-commands.ts # $B tap/swipe/pinch/touch via Input.dispatchTouchEvent
│ ├── token-registry.ts # Mint/validate/revoke for root + setup keys + scoped tokens
│ ├── sse-session-cookie.ts # 30-min HttpOnly cookie for /activity/stream + /inspector/events
│ ├── pty-session-cookie.ts # Separate scope: live Claude PTY auth
+309
View File
@@ -1,5 +1,314 @@
# Changelog
## [1.93.0.0] - 2026-11-02
**`gstack-clean` reclaims the throwaway files that pile up in a repository's `.gstack/` folder, and changes nothing unless you pass `--apply`** (#2627). Until now nothing removed old codex scratch files or trimmed the browse logs, so the folder only grew (the reporter measured 8.3 MB in six weeks), and the only way out was deleting the whole folder, which also deletes your cookies, Chromium profile and browser skills.
### Added
- **`bin/gstack-clean`** (rules in `lib/repo-state-clean.ts`): cleans `<git toplevel>/.gstack` only. It removes top-level `tmp/codex-*` files older than 7 days, cuts `browse-console.log`, `browse-network.log`, `browse-dialog.log`, `browse-daemon.log` and `browse-startup-error.log` to their last 256 KB once they pass 1 MB (in place, starting after a line break, so a running writer keeps appending), and with `--audit-days N` drops `browse-audit.jsonl` entries older than N days while keeping every line without a readable timestamp. Everything else, including `browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/`, `browse-states/` and the other `tmp/` entries, is never touched.
- **Safety:** it refuses (exit 3) outside git, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` names another folder, and when the folder is, holds or sits inside the gstack state root. It skips symlinks, logs with more than one hard link, files it cannot read and files changed in the last hour, skips browse files while the browse daemon runs and `tmp/` while codex runs, and skips a group when it cannot tell. Right before each change it checks that the folder is still the one it verified and that the file still has the size, time and inode it measured, and it stops that change if either moved. `--json` prints the same result as one versioned object.
- Result codes `GSTACK_CLEAN_USAGE`, `GSTACK_CLEAN_TARGET_REFUSED`, `GSTACK_CLEAN_PARTIAL` and `GSTACK_CLEAN_INTERNAL`, each with a docs/troubleshooting.md entry; BROWSER.md names the command where it lists the log files.
### Tests
- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder, hard-linked and unreadable logs and a symlinked `tmp/` are left alone, a log with no line break in its tail and a malformed audit file are left alone, an unlistable `tmp/` skips only its group, `--json` reaches a pipe whole with hundreds of actions and a reader that closes early is not an error, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape.
- `test/gstack-clean-producers.test.ts`: every tracked producer of a `.gstack/tmp/` entry is classified as scratch or kept, and any new `codex-*` name must be added to the scratch list on purpose.
**Capy sessions on Claude Opus 5.5 can read a smaller set of gstack skills (GSTA-161).** Before, every model read the full shared preamble on every skill. Now Opus 5.5 gets the `lite` loadout: the same skills without eleven coaching sections of the preamble, about 10.7 KB less on land-and-deploy. Every other model, and any model gstack is not told about, still gets the full skills, and the checkout's committed SKILL.md files stay full.
### Added
- **`lite` loadout and a model map** in `modules/loadouts.json`. `lite` drops the voice, writing-style, completeness, confusion, evidence, context-health, question-tuning, search-before-building, repo-mode, brain-health and context-recovery pieces and keeps the upgrade check and brain sync. `models` maps only `claude-opus-5-5` (and its dated snapshots) to it; a point release or any other suffix gets full.
- **Explicit model detection** (`lib/model-detect.ts`): an explicit loadout, then `--model`, then `GSTACK_MODEL`, else full. An unmapped, invalid or unreadable model id serves full and never errors (`LOADOUT_INVALID_MODEL` names an id that is not a plain model id).
- **`gstack-capy-install --model <id>`** (passed through `scripts/install.sh`), or `GSTACK_MODEL=<id>` in its environment, which the Capy skill uses because an older installer ignores it: prints `LOADOUT` and `SKILL_ROOT` lines and, for lite, writes a second launch file that adds `GSTACK_LOADOUT_SKILL_ROOT`. The install verdict and its key do not change.
- **`gstack-modules install` and `resolve`**: `install` publishes the lite tree under `<state root>/loadouts/<loadout>-<digest>/` as a complete skill tree (rendered files plus every tracked runtime asset), keyed by the selection, model, full root, host and source tree, reused only after its files re-hash against the lock, and never replaced in place. A failure serves full with `LOADOUT_FALLBACK`. `render --bind-model <id> --full-root <dir>` renders the same start lines for eval arms.
- **`./setup --loadout <name>`** for Claude Code: saved as `claude_loadout` and rendered through setup's own Claude render into the untracked render dir; `--loadout full` returns to the committed files.
- **The start line carries the model.** A lite skill's start command names its model and the full install root, so `gstack-skill-start` prints `LOADOUT` and `LOADOUT_RULE` lines, plus `LOADOUT_MISMATCH` when `GSTACK_MODEL` names another model, telling that session to read the full SKILL.md instead.
### Tests
- `test/model-detect.test.ts`, `test/module-loadouts-install.test.ts`, `test/loadout-setup.test.ts`, and new cases in `test/module-loadouts.test.ts` and `test/gstack-capy-install.test.ts` cover the mapping and its near misses, hostile model ids, the start-line rules, two models on one machine, runtime assets from `SKILL_ROOT`, identity changes, concurrent publishers, and `./setup --loadout`.
**Shadow mode for the CEO-only decider: in a driver run that turns it on, every approval gate gets one hidden model prediction per question, recorded before the owner answers (GST-537).** Before the CEO-only mode is allowed to decide for the owner, we need to know how often it would pick what he picks. The only decider before this was a fixed rule that always keeps the plan's direction, which agrees with the reviewers on every past item, so it measured nothing. Now a model (Opus 5.5 by default, about $0.07 per gate on your own API key) predicts each answer, and taste-replay round 2 in gstack-evals compares the predictions with the owner's real answers. Nothing routes to the owner differently.
### Added
- **`gstack-config set autoplan_shadow on`** (off by default; `GSTACK_AUTOPLAN_SHADOW=1|0` overrides it for one run) turns shadow mode on at a run's first `next`, stored in `run.json` so a resumed run cannot flip it. `autoplan_shadow_model` picks the model (default `claude-opus-5-5`). It stays off with `SHADOW: off (<reason>)` when there is no API key for the model, the model has no price, the run is `--no-outside` or CEO-only.
- **Every driver command that prints a gate** (`next`, `submit`, `close`, `resume`, `revise`, `run`, `check`) runs the shadow first and prints `SHADOW: recorded model=<m> usd=<x>` or `SHADOW: failed <code>`, then the gate, then the checkpoint. The prediction goes to the private `<run>/shadow/ceo-only.r<rev>.jsonl`: action, pick, confidence, cites and reason per question, beside the keep-direction rule's pick, the frozen question, the prompt hash and the cost, plus a 10% audit mark for a later live switch. The run lock is free during the call; another writer pays nothing while a call is in flight, `answer` waits for it, and after a crash `answer` records that gate first, marked retrospective. A shadow failure never blocks a gate or an answer.
- **Shadow calls count against `--spend-cap`** under their own ledger ids labeled `shadow`, show as `shadow=<usd>` on `GATE_SPEND`, and never change the per-voice price on `GATE_REC_COST`. A call that was sent and then lost to a crash settles `unknown`, never released as undispatched.
- **`gstack-autoplan shadow --out <dir> | --runs <file> [--json]`**, a read-only export for gstack-evals: one row per item of each answered gate, its shadow status (recorded, malformed, failed, missing, retrospective, off) and the owner's answer with how he gave it. An unanswered or interrogated gate refuses `SHADOW_BEFORE_ANSWER`.
- **Answered decision rows record how the owner answered**, parsed from his own words: `owner_mode` (`rec`, `rec_except`, `tokens`, `as_is`, `unparsed`), `owner_named`, the `rec_hash` answered against and `answered_role`.
- `gstack-artifact validate <run.json>` checks each shadow record against its journaled sha256.
### Changed
- The shared model-call transport can write a caller's own egress receipt, returns the provider's stop reason (a reply cut at the output limit is retried at 8,000 tokens, not treated as malformed), and sends the output limit to OpenAI models as `max_completion_tokens` when a caller sets one. Existing callers send the same bytes as before.
- **Parallel agents no longer take over each other's browser tab.** When several agents drive gstack's own browser at once, each agent process now gets its own tab on its first command, and its `goto`, `snapshot`, `click` and chain commands stay on that tab (#1961). Name a session yourself with `--session <name>` or `BROWSE_SESSION=<name>` (subagents that share one agent process need this); `BROWSE_SESSION=` turns it off. Sessions isolate tabs, not logins, so cookies and storage stay shared. A session idle for 120 minutes is closed (`BROWSE_SESSION_IDLE_MINUTES`, 0 keeps it), at most 16 run at once (`BROWSE_MAX_SESSIONS`), and only the local root token can name one. `tabs` now shows each tab's owner.
- **`BROWSE_TAB` and `--tab-id` no longer move the active tab for everyone else.** A command pinned to a tab used to switch the daemon's active tab while it ran, so another caller's command during that window landed on the pinned tab, and two overlapping pinned commands could leave the daemon on the wrong tab. Each request now runs on its own tab without touching the shared one.
- **QA can now test phone pages with real touch input.** `$B device "iPhone 15"` makes the current tab act like that phone (size, pixel ratio, touch support, mobile user agent and `pointer: coarse`) without resizing any other tab; `$B device off` restores it and `$B device --list` names every profile. `$B tap`, `$B swipe`, `$B pinch` and `$B touch down|move|up <finger> <x> <y>` send real touch events, including several fingers at once (hold a joystick while tapping fire) (#3058). This is Chromium emulating a phone, not Safari; /qa's report names the phones it emulated and what still needs a real device through /ios-qa. When the Aside browser drives, its cookbook has a touch recipe, checked on Aside 1.26.
- New result codes, each with a troubleshooting entry: `BROWSE_SESSION_INVALID`, `BROWSE_SESSION_ROOT_ONLY`, `BROWSE_SESSION_TAB_RESET`, `BROWSE_SESSION_LIMIT`, `BROWSE_DEVICE_UNKNOWN`, `BROWSE_DEVICE_CDP_FAILED`, `BROWSE_TOUCH_NO_DEVICE`.
- **`/investigate` drops its five-phase step procedure** (GST-938). Harness Evals watched Opus 5.5 run `/investigate` on 46 bug-fix sessions: the procedure loaded every time and the model followed none of its steps (0 of 30 with Claude Code's real system prompt), while a gstack session cost $0.46 against $0.22 without gstack (HARN-14, gn-hard-follow-2026-11). The skill keeps the parts that still do a job. Those are the Iron Law, the arguments table, a short "How to work" with the three lessons that cost real time before (read call sites before calling two paths duplicates, refresh the git index before timing, test a shared premise before a third fix), the 3-strike and more-than-5-files asks, the Scope Lock with its freeze hooks, the recipes and `--backlog`, the prior-learnings search and the DEBUG REPORT. The generated Claude skill shrinks from 29.8 KB to 20.2 KB. Gone: the Phase 1-5 walkthrough, the pattern table, the web-research block and the hypothesis-keyed learnings re-pull. The research block's rule stays as a guard, so a web search names the error class and library, never raw error text, hostnames, paths or secrets. BROWSER.md and docs/skills.md now describe the short skill.
- Behavior change: the model is no longer told to work through Phases 1-5 in order, and `/investigate` no longer carries the Aside web-research block. Hook-enforced versions of the two steps that could change results (reproduce first, verify after) are planned as an opt-in Claude Code candidate that ships only if a preregistered eval shows it helps.
- **Opening a tab for one browser session no longer undoes another caller's tab switch.** If a different caller switched the active tab while a session's new tab was opening, the daemon used to switch back; it now keeps the newer choice.
- **`gstack-artifact validate` catches a deleted shadow record.** A revision the journal says was recorded, but whose record file is gone, now fails as `ARTIFACT_STALE` instead of passing.
- **A shadow that fails while saving its record is journaled and never stops the run.** The failure is written as `shadow_failed` under the run lock and its lease is dropped; if the lock stays taken, the lease expires and the next command settles that revision.
## [1.92.83.0] - 2026-11-02
**The sealed guard no longer rescans commits the destination already holds, so a stacked branch retargeted after its parent squash-landed stops being refused for the parent's history.** gstack-evals#22 listed its parent #6's seven commits after #6 landed as one squash. One of them had added and later deleted a held-out file, so the guard refused a push whose every byte GitHub already had as `refs/pull/6/head`, and the queue sat blocked for about 50 minutes until a waiver landed.
### Changed
- **`lib/sealed-guard.ts`** (`evaluateSealed`, shared by the pre-push hook and `gstack-pregate sealed`): a listed commit is skipped when it is an ancestor of the destination's own `refs/pull/<n>/head` for a squash `(#n)` on the base since the merge-base (at most 50 refs, a 20-second budget, an egress receipt per remote call, fetched refs deleted afterwards), or when its own `git patch-id --verbatim` and diff equal a base commit's (off above 2,000 base or 400 listed commits; a landed commit still gets the `sealed.label_join` check, which reads the whole file). Nothing is matched against a combined diff, and any failed lookup scans everything with one line saying why. docs/pregate.md#sealed-skip.
- **`gstack-pregate sealed`**: the `SEALED:` line gains `skipped=<n>` (and `commits=` counts the commits scanned), stderr names each skipped group, and `--no-skip` scans every listed commit.
### Tests
- `test/sealed-skip.test.ts`: the #22 shape is clear with seven commits skipped as `refs/pull/6/head`, the hook and the preflight agree, `--no-skip` refuses, no fetched ref is left behind and the egress ledger records the lookup. The same parent never published refuses although its squash equals the combined diff. An unreachable remote scans everything and says so. A cherry-pick that landed on the base is skipped and named. A whitespace near-miss (`/home/alice/` against `/home/ alice/`) is scanned and refuses, and so is a landed commit that differs only in bytes that aren't valid UTF-8.
## [1.92.82.0] - 2026-11-02
**`gstack-review-dispatch verify` now catches a merged findings file edited after collect.** Verify used to compare only output hashes and line counts. So a `merged.jsonl` row whose label was lowered, or whose text was replaced with another line's summary, passed with `VERIFY: clean` once the judge agreed with the edited row. Verify now re-parses each reader's unchanged output and checks every merged row against the finding at its index, label and raw text byte for byte. A mismatch prints `merged_line_differs` on the OUTPUT and JUDGE rows, and verify is dirty. A missing output file or an output event with no file recorded (even for a reply with no findings), a judgment run on a copy of the merged file, merged rows that collect never wrote, and an output whose finding count no longer matches its recorded ids are dirty too. A reply refused for naming the wrong checkout is still allowed to hold findings collect never merged, but only when verify re-checks its tree line and finds it wrong.
### Known
- Deleting a whole output event together with its merged rows still verifies clean, because the ledger keeps no anchor for it. It is listed for the structured-findings plan.
### Tests
- `test/review-dispatch.test.ts`: a forged label, a summary copied onto a blocker's id, a deleted output file, a judgment on a forged copy and an extra merged row all verify dirty. Each verified clean at main c830c9b.
- `test/review-dispatch.test.ts`: an output event edited to look like a wrong-tree refusal (no ids, `review: false`, `tree_ok: false`) verifies dirty, even after a re-sent brief is answered, and so does one edited to drop its file and ids. A real wrong-tree reply stays `NOT_REVIEWED` until a re-sent brief gets a review, then verifies clean.
### Docs
- `docs/review-policy.md` (verify) describes the per-row check.
## [1.92.81.0] - 2026-11-02
**`gstack-ship-release finish` now accepts the owner's edit after a fix-check instead of asking for another review round (GST-965).** On gbrain #8425, the fix-check found a blocker, the owner's pre-agreed fallback was applied and "then ship" was granted. Finish still classed the edit as behavior and stopped with `insert 5–11.5 → 12–14 → 16`, a third round the R51 cap forbids, so /ship could not get through Step 16 without running its stages by hand.
### Added
- **`finish --review-loop-exception <json|@file>`** (`lib/ship-release.ts`) takes the same record `gstack-ship-receipt write --review-loop-exception` takes. Paths changed since `fix_check.head` that the branch already touched at that commit (narrowed to the record's `fix_check.files` when it lists them) route as `accepted=` on the `ROUTE:` line, a `REVIEW_LOOP_EXCEPTION:` line names them with the owner, date and channel, and the docs stage carries them (`accepted=` in the DOCS line). A path outside that scope and a gate-edited test (with or without `--reviewed-base`) still route as behavior, and with `--reviewed-base` so does a file too large for the scoped review. A malformed record, or a `fix_check.head` that is not HEAD or an ancestor of it, stops before the release commit. Without the flag nothing changes.
### Tests
- `test/ship-rereview-triggers.test.ts`: an owner fallback edit to a source file and a doc after the fix-check stops without the record and passes Step 16 with it; a new file outside the scope (even when `fix_check.files` lists it), a fix-check head off the branch, a malformed record, an unreadable record file and an empty `fix_check.files` stop; a `fix_check.files` list narrows what is accepted; a test weakened after the fix-check stops with and without `--reviewed-base`.
## [1.92.80.0] - 2026-11-02
**On a Capy machine, the git fixture tests no longer fail locally because of Capy's git shim log.** Capy's `/usr/bin/git` shim appends to `$HOME/.capy/git-shim.log`, so each fixture home that ran git during its template build ended up with a log naming the template. The leak scan in `test/helpers/git-fixture-template.ts` flagged it, and 34 tests in `test/train-backstop.test.ts`, `test/restamp-mentions.test.ts` and `test/pregate-user-policy.test.ts` failed on every Capy lane, though they passed on the remote gate.
### Tests
- `templateReferences` skips `.capy/git-shim.log`. Nothing reads that file back, and every other file is still scanned.
- `test/git-fixture-template.test.ts` plants the shim log in a template's `home/.capy/` (the copy succeeds), and plants `home/.capy/state.json` as a control (the copy still throws).
## [1.92.79.0] - 2026-11-02
**/plan-tune no longer reports a mismatch between your stated and observed preferences when it has no observed answers behind it (#3436).** `--check-mismatch` compared a declared value against an inferred value that had never moved from its 0.5 starting point, so a strong declaration with no matching answers printed `scope_appetite: declared 0.9 vs inferred 0.5 (gap 0.4)` even though no logged answer touched that dimension.
### Fixed
- **`bin/gstack-developer-profile`**: `--check-mismatch`, `--gap` and the gap that `--derive` stores compare a dimension only when at least 3 logged answers moved it. `--check-mismatch` still needs 10 logged answers overall before it reports anything. Below the 3-answer floor, `--check-mismatch` prints `no observed evidence` or `too little evidence (n of 3 events)` instead of a gap, and `--gap` lists the dimension under a new `low_evidence` field. A profile derived before `contributing_events` existed keeps the old behavior until its next derive.
- **/plan-tune "Show gap"**: says "not enough observed answers yet" for those dimensions instead of calling them drift.
### Tests
- `test/gstack-developer-profile.test.ts`: a declared dimension with no evidence is not flagged by `--check-mismatch` or `--gap` (both failed on main), 1 or 2 events report too little evidence, 3 events are compared, three same-day answers that agree with the declaration still report not enough data, and `--derive` stores no gap without evidence.
## [1.92.78.0] - 2026-11-02
**Seven flaky tests from the 2026-11-01 flaky pass stop failing correct runs, and six more now name their cause when they fail.** One product lock broke a fresh lock it had misjudged as abandoned, a /ship lane tripped the free runner's home check with its own log, and the rest were fixtures that raced their own setup, leaned on wall-clock sleeps or read the caller's state root.
### Fixed
- **`bin/gstack-codex-probe`**: a waiter that cannot read an owner-less lock's age (the holder removed it in that instant, or `stat -f` printed filesystem text on Linux) now treats the lock as fresh instead of abandoned, so it no longer deletes a live owner's lock on a misread age and the probe no longer ends `MODEL_PROBE_INCONCLUSIVE` (Free Tests 41216564482, 41235671867, 41263831361). A stat stub for both race windows fails without the fix; 64 stress runs went from 2 failures to 0.
- **`scripts/lib/free-home-guard.ts`, `bin/gstack-evidence`**: `gstack-evidence run` names its own lane log in `GSTACK_EVIDENCE_LOG`, and the real-home tripwire ignores exactly that file, so a /ship lane no longer trips over its own output. A foreign write under the home's `~/.gstack` still fails.
- **`test/autoplan-publication-guard.test.ts`**: the render-root cases clear the state-root variables they don't name, so a `GSTACK_STATE_ROOT` exported by the gstack launch file no longer picks the root. Three cases failed every time under that variable, and the lookalike controls passed for the wrong reason.
- **`test/gstack-design-detect.test.ts`**: the directory scan proves one batch by counting engine calls instead of sleeping near the 1,500 ms detect timeout (macOS job 124813933194). The product timeout is unchanged.
- **`test/pregate-regate.test.ts`**: the two fixtures are built once and copied per test through the `gitFixtureTemplate` helper from #3586, so 8 builds become 2.
- **`test/ship-queue-train.test.ts`**: the planted seam has its own name, so the fixture's own background `git maintenance` lock no longer collides with it (EEXIST, Free Tests 41288253305, 41288417894), and the test now holds a real `maintenance.lock` beside it.
- **`test/ship-budget.test.ts`**: the fake child writes its args to a temporary file and renames it, so the poll never reads an empty file (Free Tests 41242243205).
### Changed
- `scripts/test-free-shards.ts` names the recorded browser child it never saw in the daemon's process tree, and whether it was still alive, when it fails a shard for unconfirmed ownership (Free Tests 41264845860 shard 7). The shard still fails.
- `test/bootstrap-retention-shard.test.ts`, `test/office-hours-attempt.test.ts`, `test/session-update-autostash.test.ts`, `test/shard-engine.test.ts` and `test/auq-parallel.test.ts` print the process state, exit status or cleanup events behind their assertion, so the next red names its cause.
## [1.92.77.0] - 2026-11-02
**The weekly marathon evals can run /design-motion again: the CI eval image now ships ffmpeg (#3687).** The `design-motion-showcase` case joined the marathon tier on 2026-10-26, and its first scheduled run (Actions run 41200316994, main @ 088bfc5) failed 0/3. Every trial built and captured the page, then stopped at `FFMPEG_MISSING`, because the image had no ffmpeg and the skill rightly refuses to export before its checks run. The model did nothing wrong; the case could not pass on that image.
### Fixed
- **`.github/docker/Dockerfile.ci`** installs Ubuntu's `ffmpeg` (6.1.1, with `ffprobe`). The build fails if that ffmpeg lacks a filter or encoder the motion checks and exports use (`psnr`, `gblur`, `palettegen`, `paletteuse`, `signalstats`, `tile`, `libx264`, `gif`).
- **`test/ci-image-cli-pin.test.ts`** fails the free suite if the image stops installing ffmpeg or stops checking it.
The macOS `macos-named-regressions` CI job now runs `test/regression-issue3680-ubicloud-bsd-userland.test.ts`, so the ubi-runner cases for #3680 (bsdtar) and #3679 (macOS awk) run on a real Mac against `/usr/bin/tar` and `/usr/bin/awk`. Before, the file ran only on Linux CI, where bsdtar and one-true-awk aren't installed, so every case skipped and a regression in either could merge green. A new wiring test fails if the macOS job stops naming the file.
The Linux free suite now runs on 10 `ubicloud-standard-8` machines, each running its tests as 4 sandboxed sub-shards side by side, instead of main's 20 `ubicloud-standard-4` machines (set by #3710) running one file at a time. Both use 80 vCPUs per run. In a 3-run A/B at one commit against that main setup, the slowest shard dropped from 389–450 s to 224–258 s and the Ubicloud cost per run dropped from about $0.39 to $0.32 at premium prices, with the same per-file test counts and no new failures. The CI plan now stores each shard's sub-shard count (`--ci-plan --shard-jobs k`), keeps the exclusive host-state shard at one child, and `--ci-verify` rejects a shard that ran at a different count. The nightly Windows suite stays at one child per shard, because two sub-shards made Windows file-lock cleanup failures show up in two of three runs. Dispatch-only `free_jobs` and `windows_jobs` inputs exist for future experiments. See docs/TESTING_INTERNALS.md#sub-shards-inside-one-ci-job.
## [1.92.76.0] - 2026-11-02
**/retro now remembers its history across git worktrees, so a run in a linked worktree compares against last week instead of announcing "First retro recorded".** Retro snapshots used to live in `.context/retros/` inside the working tree. A worktree (Conductor, Claude Code) saw none of the main checkout's history, and its own snapshot was deleted along with the worktree (#3749).
### Fixed
- **`bin/gstack-retro-metrics --history [--next <YYYY-MM-DD>]`**: keeps retro history in `~/.gstack/projects/<slug>/retros/` (the state root), which every worktree of a repository shares. It lists earlier snapshots as `RETRO_PRIOR` lines, newest first, and reserves the next `<date>-<n>.json` as `RETRO_FILE`, so two retros running at once in different worktrees never write the same file.
- **Moving old history**: snapshots in `.context/retros/` of the current worktree and the main checkout move to the shared folder on the first run. A copy identical to a stored snapshot from the same date is dropped, and a different snapshot with the same name takes the next free number, so nothing is overwritten. A `.context/retros` that points outside its worktree (a symlink to the shared folder or anywhere else) is left alone, and moves never replace an existing file.
- **`retro/SKILL.md`**: Steps 12 and 13 use the helper, the gbrain prior-retros query reads the shared folder, and a history error is reported to the user instead of being mistaken for an outdated helper. `docs/skills.md` says where snapshots are saved.
- `gstack-retro-metrics` no longer loops forever when an option such as `--next` is given without a value.
### Tests
- `test/regression-3749-retro-history-worktree.test.ts` runs the real Step 12 block in a main checkout with a linked worktree: the worktree sees the main checkout's snapshot, its new snapshot path is outside the worktree and survives `git worktree remove`, migration keeps every snapshot, a symlinked `.context/retros` cannot delete history, and two runs get different snapshot paths. It failed 4 of 4 on the code before the fix.
**A stale local `main` no longer turns a /ship review into a full-depth gate review (GST-883).** `gstack-review-plan` handed the raw base name to `gstack-gate-diff`, which diffed against the local branch. With a local `main` four commits behind `origin/main`, the gate scan read a test edit that was already on `origin/main`, so a plain code diff planned as gate class at full depth. On gstack-evals#123 that cost about 15-20M tokens of readers the change did not need (GEVA-117).
### Fixed
- **`gstack-gate-diff <branch>`** resolves a branch name to its remote-tracking branch (`origin/main` for `main`, origin first), the way `gstack-review-plan`'s own diff and review dispatch already did (#3780). It uses the local branch only when no remote has the branch, and says so on stderr: `no remote-tracking branch for <base>`, or `<remote>/<base> shares no history with HEAD`. A repository with no remote at all uses the local branch without a note. Branches are read by their full ref names, so a tag named `main` or `origin/main` can no longer stand in for the base and hide committed edits from the scan.
- **`gstack-review-plan`** passes the merge base its own diff used to `gstack-diff-scope` and `gstack-gate-diff`, so the scope flags, the gate scan and the line counts all read the same commits. Its diff reads `refs/remotes/origin/<base>` and `refs/heads/<base>` by full name too.
### Tests
- `test/review-plan-origin-base.test.ts`: a fixture whose `origin/main` is one commit ahead of the local `main`, with a test edit on `origin/main` and a branch forked from it. `gstack-review-plan --base main` and `gstack-gate-diff main` plan the same class, depth and gate-tagged count as with a fresh `main` (before the fix: gate, full, `RH-1:1` against code, light, none). A tag named `origin/main` or `main` at HEAD leaves the plan and the scanned range unchanged. Also covers the stderr note for a branch no remote has, an unrelated `origin/main`, and the quiet path with no remote.
- **One hostile line no longer stalls review classification** (GST-898). R51's `rm -rf` rule in `lib/review-risk.ts` took quadratic time on a long flag run. One added line of `rm -` and 100,000 `r` characters held `matchRisk` for about 3.7 s, and each doubling took four times as long. The `rmSync(… recursive: true)` rule did the same on a line of repeated `rmSync(` calls. Both now run in linear time (the same line takes about 1 ms). The `rm` rule is rewritten with lookaheads and matches exactly the lines it matched before. The `rmSync` rule looks for `recursive: true` in the first 500 characters of the call, and a call with more than 500 characters before its closing parenthesis now counts as security on its own, so padding can't push the flag out of view. A slow match never lowered review depth: `gstack-review-dispatch pass` stops when the plan times out, and the queue recomputes depth itself.
- A new test holds every risk pattern under 100 ms on 100,000-character hostile lines and checks both rules against R51's on 60,000 seeded lines each. Over the last 100 main commits, no commit changes depth, class or risk class, and none of the 69,023 changed lines gets a different verdict from either rule.
### Fixed
- **/ship no longer reviews a results write-up as if it were gate code.** Three planner misreads on gstack-evals#132 (GST-911) pushed a docs PR to the five-reader full panel. A chart SVG made of drawing elements now counts as data, like the same chart as a PNG; an SVG with a script, `foreignObject`, event handler or `javascript:` URL stays code and now gets the security class. A data file named for sessions (`recovered-sessions.jsonl`) no longer sets the auth scope, so it adds no auth reader and no gate class; session code and session config still do.
- **`gstack-diff-scope` and the receipt depth read `origin/<base>` by its full ref name (GST-884).** A local `main` behind `origin/main` no longer adds upstream files to the scope, and a tag named `main` or `origin/main` can no longer stand in for the branch.
### Tests
- `test/review-planner-misreads.test.ts`: a 600-point matplotlib-style chart plans docs, light, runtime 0 (before: code, 636 runtime lines); six ways an SVG can run script keep it code with the security class, and other non-drawing constructs (external images, any CSS beyond declarations and `url(#id)`, animations, entity declarations) keep it code; session data files leave `SCOPE_AUTH` false while session code and config set it; a stale local `main` and tags named `main` or `origin/main` leave the scope and the receipt base unchanged.
- `gstack-gate-diff` and `gstack-diff-scope` now read a bare base name such as `main` as origin's tracking branch (`refs/remotes/origin/main`) when it exists and shares history with HEAD. That's the base /ship fetches and merges against. Before, a local `main` that was behind `origin/main` set the merge base. That happens in a linked worktree, and on a Capy machine, where local `main` never moves. So the gate scan and the specialist scope also read commits already on `origin/main`. One /ship run in #3650 listed a `.github/workflows/ci.yml` hunk from upstream and dispatched an extra performance reviewer. `gstack-gate-diff` now says `local main is not origin/main; base is origin/main` on stderr when that happens. A base you name with its remote (`upstream/main`), a commit or `HEAD` is used as given. A tag named `origin/main` or a fork's `origin/upstream/main` branch can't stand in for the base, and another remote's branch only replaces a local base that is missing or unrelated (#3650).
### Fixed
- **The SVG drawing check runs in linear time.** One changed SVG with a long run of spaces inside `url(` could stall `gstack-review-plan` and the queue's depth check for hours (80,000 spaces took 0.8 s, a 1 MB run over 230 s). The check now reads it in about a millisecond and refuses exactly what it refused before.
- **An SVG whose DOCTYPE points at an outside document stays code.** Only a bare `<!DOCTYPE svg>` or W3C's own SVG DTD still counts as a drawing, so an SVG with a `SYSTEM` DTD keeps its full review.
- **`gstack-diff-scope` picks the same base as `gstack-gate-diff`.** When `origin/<base>` is missing or unrelated, a related local `<base>` now comes before another remote's branch in both tools.
## [1.92.75.0] - 2026-11-02
- **The /ship receipt counts every blocker its readers found** (GST-951). `gstack-review-scope summary` read only the review-log rows, but /ship's readers report through the dispatch ledgers (`gstack-review-dispatch collect` and `judge`). So a blocker that only the native adversarial pass raised, a CRITICAL logged fixed without classify's blocking stamp, and findings logged before they were classified all left `blocking_found` at 0. That happened on #3841, #3838 and #3837. The summary now also reads each ledger's collected and judged CRITICAL findings, pairs each one with at most one row finding from the same reviewer at the same path:line so a blocker held in both places counts once and one classified below the floor does not, and counts an unclassified CRITICAL row as blocking. `unrecorded=N` names blockers no row gives a disposition, and `ledgers_read=N` says how many it read.
- **The summary refuses rather than undercount.** It now needs `--run <run dir>` (it finds every `review-ledger.jsonl` under it), `--ledger <f>`, or `--no-ledger` when no reader ran through `gstack-review-dispatch`. A missing choice, a run directory with no ledger, or an unreadable ledger, merged file or judged set, or a judged set changed since `judge`, is `REVIEW_SCOPE_LEDGER` with nothing written. /ship on Capy (`ship/DRIVER.md`) passes `--run`; the Claude Code flow passes `--no-ledger`.
## [1.92.74.0] - 2026-11-02
- **`gstack-pregate clear --run-url` needs a green run.** On a lane with no run profile, a run of the lane's own workflow that failed, was cancelled or timed out now refuses `PREGATE_RECEIPT_INVALID` (`run_not_success`), and one still queued or running refuses `run_in_progress`. The fix line says to rerun the workflow and clear with the green run. The check reads the run's newest attempt, so a job or attempt link is judged by the whole run, and the lane records the attempt it judged (`…/actions/runs/<id>/attempts/<n>`) instead of the link you passed. A green run still clears. docs/pregate.md now names `windows-smoke.yml/windows-smoke` as the free tier's Windows smoke lane, beside the narrower `free-tests.yml/cso-windows-launcher` and `windows-setup-e2e.yml/windows-setup` lanes. `test/pregate-clear-runs.test.ts` failed before the fix.
## [1.92.73.0] - 2026-11-02
### Fixed
- **A reviewer that wrote `printed: REVIEWED_TREE: …` is no longer failed.** Two readers in GBRA-218 echoed the command and then wrote the fingerprint after a `printed:` label, so /ship read no line, failed them and restarted the pass twice. The label is now read, plain, bold or inside the line's backticks, and the tree and checkout it names are still compared exactly. A wrong tree or checkout is still refused.
- **The Capy pass program stops before it sends a reader into the wrong checkout.** Capy starts every shared reader in the thread's working directory and cannot start one anywhere else. In GBRA-218 an outside voice ignored its `cd` and reviewed that checkout, so Step 11 lost its outside coverage. Now, before a stage takes a start token or sends its first reader, the program compares that directory with the checkout /ship ships. It stops when they differ or the directory can't be read. **Run /ship in the thread's own checkout**, not in a worktree beside it. A start directory outside any checkout, such as a multi-repo workspace root, still dispatches.
- **Step 11 takes the right start token.** Reusing the pass input's `logStart: ["review"]` for `stage: "voices"` used to run the voices with no `adversarial-review` start, which left Step 11.5 nothing to bind. Step 11 now takes `adversarial-review` by default and stops before anything runs when given any other list. The gstack Capy skill says Step 11 swaps `logStart` for `stage: "voices"`.
## [1.92.72.0] - 2026-11-02
**The sealed guard can now refuse a published record that puts a label next to an identifier.** gstack-evals hides which test units are decoys. Its /ship on garrytan/gstack-evals#99 published a panel where each unit's `stratum`, `tags` and `decoy_set` sat next to its `head`, `tree_sha256`, `plan_bytes` and `files`, values a model under test can compute from its own copy of the work. A model could look itself up and learn it was the decoy. No answer leaked, so `gstack-pregate sealed` said `clear` (GSTA-2, GST-620). The new rule is opt-in. A repo that sets `sealed.label_keys` gets it, and every other repo sees the same output as before, byte for byte.
### Added
- **`sealed.label_keys` and `sealed.identifier_keys` in `.gstack/pregate.json`.** With `label_keys` set, `sealed.label_join` checks every .json, .jsonl, .ndjson, .csv, .tsv and Markdown pipe table a published commit adds, modifies, renames or copies. A record that holds a label field and an identifier (a field matching an `identifier_keys` glob, by default `head`, `*_sha256`, `*_bytes`, `files` and similar names, or any 7 to 64 character hex string, as a value, in a list, or as the key of an object) is a join. The guard fingerprints each join in memory and compares it with the parent's version of the file, so unchanged rows and reformatting stay clear while a replaced, added or widened row refuses even when the row count stays the same. Findings name the file, the commit and the field names (`keys=stratum,tags,head,plan_bytes`), never the values, on stderr, in `--json` and in the pre-push hook.
- **`sealed.label_join_unchecked`.** A file whose text never names a label key, ignoring double quotes, cannot hold a join and is skipped without parsing, so a commented `tsconfig.json` passes. A file with a `\u` escape is always parsed. A supported file over 4 MiB, one that names a label key and does not parse, one nested deeper than 64 levels, or a blob git cannot read refuses instead of passing unread. The `SEALED:` line counts these as `unchecked=<n>`. Like every sealed finding, it can be waived through the base branch's `.gstack/sealed-allow.tsv`; `allow_paths` never exempt either rule.
- **The policy only tightens.** A field can count as both a label and an identifier, so adding identifier names to `label_keys` never hides a join. Both lists are the union of the base branch's and the pushed policy's, each resolved to its default first, so a pushed config can turn the rule on or widen it but cannot turn it off or narrow it.
### Changed
- **docs/pregate.md#sealed** documents the rule, its normalization (`Tree SHA-256` matches as `tree_sha_256`) and its known limits: joins across files, joins by reference, columnar JSON, labels in file names and prose. Its version-skew note now names the new keys. A gstack older than this release refuses them as unknown, so raise `min_gstack_version` before adopting them. The unknown-key error now lists `label_keys` and `identifier_keys` among the valid keys.
### Tests
- `test/sealed-guard.test.ts`: the GSTA-2 panel refuses through `gstack-pregate sealed`, `--json` and the pre-push hook, and warns on a private destination, with no value in any output. It failed on main. A golden captured from main 2d1b55d76 covers seven pushes (refused, clear, exempt, waived, warned, error, no policy) without `label_keys`; stdout, stderr, `--json`, the hook's stderr and every exit code match it byte for byte.
- `test/sealed-join.test.ts`: each format, human table headers, hex values under unlisted keys, maps keyed by sha, quoted TSV, a CSV header split by quotes (`"Strat"um`), a commented tsconfig.json that stays clear, dotted key names refused in the config, identifier names added to a pushed `label_keys`, decimal strings that stay clear, replacement at the same count, delete plus add, an added identifier field, labels then identifiers a commit later, renames, merges, five unchecked cases, waivers, `allow_paths`, pushed attempts to narrow or disable the rule, and `label_keys: []` matching an absent key.
## [1.92.71.0] - 2026-11-02
**Four free-suite waits now end when the thing they wait for happens: the six files involved spend 18–29 s less in summed per-file runtime, with every assertion intact.** `./setup` returns from a finished child up to ~0.9 s sooner too.
### Changed
- **`setup` `_wait_with_deadline`**: polls every 0.1 s and tracks the deadline on bash's `SECONDS` clock instead of sleeping a whole second per poll. A child that exits at once no longer costs a second. The kill still lands no earlier than the deadline, the exit codes (the child's status, or 124 after the kill) are unchanged, and it runs under bash 3.2.
### Tests
- `test/setup-playwright-best-effort.test.ts`: a child that exits with 3 after 50 ms returns 3 in under 500 ms (the old loop took 1,008 ms); the deadline and kill cases are unchanged. The file drops from ~20.8 s to ~5.9 s on a CI-mirror VM.
- `test/pregate-supervise.test.ts`: the leftover-child case waits for that child's pid to exit (bounded at 3 s) instead of a fixed 2.5 s sleep.
- `test/gstack-detach.test.ts`: the detach and lock bodies wait on files the test writes (bounded at ~10 s, also written in `finally`) instead of `sleep 2` and `sleep 3`.
- `test/qa-probe-gates.test.ts`: the duplicated "judge-named QA workflow gaps (C2)" block is gone (3 tests that repeated the first block).
- `test/context-identity.test.ts`: one `restore(mono)` call serves both assertions.
## [1.92.70.0] - 2026-11-02
**Five free-test files stop rebuilding the same git repositories for every test: they spend 17–24 s less in summed per-file runtime, with every assertion intact.** Each test still gets its own private copy of the fixture.
### Tests
- `test/helpers/git-fixture-template.ts` (new): builds a git fixture once per test file and copies it per test with `fs.cpSync`. The adopter rewrites every path-bearing file (remote URLs, alternates, state roots), and the copy is then scanned for any remaining reference to the template, so a test can never push into or read from the shared template. `test/git-fixture-template.test.ts` pins two-copy isolation and the leak scan.
- `test/release-cut.test.ts` (~10 s to ~6 s), `test/train-backstop.test.ts` (~4 s to ~1.6 s), `test/restamp-mentions.test.ts` (via `test/helpers/restamp-fixture.ts`), `test/pregate-user-policy.test.ts` (via `test/helpers/pregate-fixture.ts`) and `test/held-install-recovery.test.ts` (~33 s to ~22 s) use the template. Test and `expect()` counts are unchanged in every file.
- `test/helpers/restamp-fixture.ts` turns off auto-maintenance (`maintenance.auto=false`, `gc.auto=0`) in the bare origin the ship-queue train tests push to.
- `test/test-of-test-ratchet.test.ts`: one BASELINE row for the new helper test, which tests a test helper by design.
## [1.92.69.0] - 2026-11-02
**Background `--derive` runs no longer drop /office-hours sessions or fail with ENOENT when they overlap (#3437).** `gstack-question-log` starts a background `gstack-developer-profile --derive` after every append, and a multi-question AskUserQuestion logs several rows at once. Every derive wrote the same `developer-profile.json.tmp` and rewrote the whole profile without a lock. On main at 3bfe566, 30 rounds of 8 overlapping derives plus one `--log-session` saw 9 of 270 runs fail with `ENOENT` on rename, and 2 of the 30 sessions disappeared. With this change the same run has 0 failures and keeps 30 of 30 sessions.
### Fixed
- **`bin/gstack-developer-profile`**: `--derive`, `--log-session`, `--reconcile` and `--migrate` hold `developer-profile.json.lock` from before they read the profile until after they rename it, and each writes its own `mktemp` file. The owner file names the holder's process ID, and the lock is ownership-checked on release. A lock is taken over when its holder process is gone, or once it is 5 minutes old even if a process with that ID is running, since no writer takes that long and the ID may have been reused. Takeovers go one at a time under `developer-profile.json.lock.takeover`, so two waiters can't both take over. A writer that can't get the lock within 10 seconds writes nothing and exits 5. An unwritable state directory is reported right away instead of after the wait. A relative state root is anchored once, so the lock is released under the same path it was taken. Windows drive-letter roots stay as they are.
### Removed
- **`scripts/psychographic-signals.ts`**: `applySignal` and `newDimensionTotals`, which lost their last production caller when `--derive` moved onto `accumulateSignals` (#3435), and their three unit tests.
### Not covered yet
- `gstack-distill-apply` and /plan-tune's declared edits still write the profile without the lock.
### Tests
- `test/gstack-developer-profile.test.ts`: a directory at the old fixed temp path; a held lock blocking `--derive` and `--log-session`; takeover of an ownerless stale lock, of a dead holder's lock, and of a 5-minute-old lock whose process ID is alive; a takeover already in progress; a takeover that can't remove the lock still giving up; a relative state root; and 6 rounds of 6 derives plus a `--log-session` keeping every session. Each test failed on the code before its fix.
## [1.92.68.0] - 2026-11-02
**`/health` now finds its tools with gstack's detector, the same one `/verify` uses, and a test proves it still finds every check it found before.** /health Step 1 used to carry its own shell block for detecting a type checker, linter, test runner, knip, shellcheck and gbrain, a second copy of the list in `lib/checks.ts`. It now runs `gstack-checks detect --for health`. Scoring, the gbrain sub-score and history are unchanged. GST-116, PR 2 of 2.
### Changed
- **`gstack-checks detect --for health`** prints one `DIMENSION <name> <command> (<source>)` line per tool, or `DIMENSION <name> none (<reason>)` so Step 2 can record the category as skipped with its reason. A `## Health Stack` section in CLAUDE.md still wins row for row, test rows included. Otherwise it applies /health's own rules, including a knip or shellcheck found on PATH and gbrain when `~/.gbrain/config.json` exists. It reads files and PATH and runs nothing.
### Tests
- `test/health-detect-no-loss.test.ts` runs the old Step 1 block, frozen verbatim in `test/fixtures/health-detect-legacy.sh`, beside the new detector on 16 fixture repos and two PATH and HOME setups. Every check the old block printed must still be found. Dropping the knip or gbrain rule fails the test.
## [1.92.67.0] - 2026-11-02
**`./setup` no longer starts a handful of processes per skill just to read its name (#2120).** On Windows 11 on ARM64, Git Bash runs under x64 emulation, and its `fork()` can deadlock when a script starts hundreds of short-lived processes in a row. Setup did exactly that: `gstack-patch-names` and setup's skill-linking loops ran `grep | sed | tr` and `basename` for every skill. The reporter counted about 220 process starts on a full install. Thanks to @habassa5, who traced the hang to those loops.
### Fixed
- **Skill names are read with bash builtins.** A new sourced helper, `bin/gstack-skill-name.sh`, reads the `name:` line with a `read` loop. It gives the same result as the old pipeline on CRLF files, files with no final newline, and files with no `name:` line. `gstack-patch-names`, setup's Claude skill linker, its two cleanup loops and `gstack-uninstall` use it. They use parameter expansion instead of `basename`. `sed` and `mv` run only for a skill that is actually renamed.
- **What is not fixed yet.** Setup still starts `ln`, `mkdir` and `cp` per skill, and the linkers for Cursor, OpenCode, Copilot, Factory, Agy and Kiro still run `basename` per skill. Nobody has confirmed on a Windows ARM64 machine that the hang is gone.
## [1.92.66.0] - 2026-11-02
**The secrets check reads Python and Dart string context from what git diffed, and a quote inside an interpolation no longer ends a string (GST-738).** env.kv's source-syntax exemption treats a `password:` line as code only when it sits outside every string. Two inputs could make the scanner place a committed string's contents outside a string. One was a working copy that no longer matched what git diffed. The other was a quote inside an f-string or Dart `${…}` expression. A scoped review pass now applies an explicit `--diff-base` to its Red Team as well.
### Fixed
- **`lib/pregate/checks/secrets.ts`:** a changed Python or Dart file's whole text comes from a full-context `git diff`, never from the working copy. A `diff.suppressBlankEmpty` setting can't shift its lines.
- **`lib/redact-patterns.ts`:** a Python f-string's `{…}`, or t-string's, and a non-raw Dart string's `${…}` are read as code. Their nested strings count as strings, and a quote or comment inside them doesn't end the outer string. A Python format spec, such as `{n:#x}`, is text, and `\{` still opens a field. A `password:` line inside a Dart `/* */` comment is now read as text, not code. An expression left open, or nested more than 256 deep, runs to the end of the file, so the exemption fails closed.
- **`bin/gstack-review-dispatch`:** `pass --diff-base` now sets the scoped Red Team's base too.
## [1.92.65.0] - 2026-11-02
- **Every /ship reviewer brief now starts with the `REVIEWED_TREE` step instead of saving it for the last paragraph** (`lib/review-brief.ts`, GST-897). Before, the brief only asked for the fingerprint line in its closing paragraph, after the whole diff and a rule that the reply ends with its `Recommendation:` line. On GST-850, two gpt-6.1-sol outside-voice replies never ran the command, so `collect` refused both (`COLLECT_WRONG_TREE reported="none"`) and their findings were lost. Other readers misread "the line <command> prints" and pasted the command instead of its output. Now the brief opens with "First step, before anything else: run `cd <checkout> && echo "REVIEWED_TREE: …"`", says the command outputs one `REVIEWED_TREE: <tree hash> <path>` line, and asks the reader to copy that output line, not the command, as the first line of its reply. The closing paragraph repeats the request with "(the output, not the command)". `collect` is unchanged and still refuses a reply without the line, and the dispatcher never stamps the tree for a reader.
### Tests
- `test/review-dispatch.test.ts`: the rendered outside-voice brief's first line is the step, running its command prints a line `collect` accepts, and both captured GST-850 replies (`test/fixtures/review-wrong-tree/`) are still refused with `COLLECT_WRONG_TREE`. On main this test fails because the brief's first line is the filesystem boundary paragraph.
## [1.92.64.0] - 2026-11-02
**/ship's Step 11 outside voice now defaults to the model it was tested with, and a same-family outside voice is refused.** On a lane running an Anthropic model, the pass program uses `openai/gpt-6.1-sol` at medium reasoning when no outside-voice model is given. That is the pairing no-loss rounds 3 to 6 and A3668 ran. Until now the lane picked the model (gpt-6-astra in replays 6c, 7e and 8g), and the only check was that some model was given, so a Claude outside voice beside a Claude native voice would have run as "outside" (GSTA-167 plan R2 and O2).
### Changed
- **Outside-voice default, Anthropic lanes only** (`lib/capy-review-pass.js`): with no `models['outside-voice']` and an `anthropic/` `laneModel`, the voice runs on `openai/gpt-6.1-sol` at medium. Any other lane, or no lane identity, still stops with "none was given", because an OpenAI lane's pairing is not certified.
- **Same-family STOP**: an outside voice whose provider (the part before `/`, compared without regard to case) matches the lane model or the native voice's model stops the pass before either voice is created. The check runs again on every rerun of the same manifest and before a queued voice is started. An outside voice with no provider prefix, or with no lane model and no native model to compare against, also stops.
- **Voices resume on the models they were dispatched with**: the pass records each Step 11 voice's model and the lane model in `voice-models.json` in the pass directory before creating it. A rerun with different inputs starts a queued voice on its recorded model and checks it against the recorded native model, so it can't pair two voices of one family.
- **Overrides are labeled**: each outside voice prints an `OUTSIDE_VOICE:` line when it is created or started from the queue, saying whether it is the certified configuration. A different model or reasoning level still runs, marked "outside the certified configuration".
- **Step 14.5 docs audit runs once on Sonnet 5.5** (`ship/DRIVER.md`). In replay C5 the docs audit first ran on a smaller model whose reply lacked its JSON line, then ran again on Sonnet, which wasted 1.55M tokens. To stay under the driver's 12 KB cap, one sentence that repeated Phase 4 and 5 ("A fix repeats Phases 3-4.") was removed.
### Tests
- `test/review-pass.test.ts`: six new fake-host cases: a same-family override stops; a rerun or a queued start still refuses it; a queued voice resumes on its recorded model; a missing lane identity, an unprefixed id or an unprefixed lane stops; an Anthropic lane gets the certified default with every brief byte-identical to naming that model; an override with changed reasoning runs and is labeled uncertified. The Step 11 case now passes `laneModel: undefined` to keep its "none was given" check, since an Anthropic lane gets the default.
- `test/ship-driver.test.ts`: the audit-reader assertion names Step 14.5's Sonnet 5.5 run.
## [1.92.63.0] - 2026-11-02
**A slot restamp after the docs audit no longer stops `finish` with `DOCS: stale (CHANGELOG.md)` (GST-906).** When a PR lost its merge slot after Step 14.5, the new slot's restamp re-heads the CHANGELOG entry and moves VERSION, and on the lost-slot path it merges main first. `gstack-ship-release finish` read all of that as a docs change, so the only ways forward were a third docs audit, which the policy forbids, or an owner-accepted named risk. Reported by GBRA-258 on gbrain #8372.
### Fixed
- **Step 16's docs check carries a restamp.** The CHANGELOG counts as unchanged when it differs from the audited copy only by the heading move or re-heading. After a main merge, the audited copy is first merged with main's CHANGELOG by the same `mergeChangelogs` call the restamp writer makes, and that merge may only add main's sections. The version file counts as unchanged when it held a bare version at the audit and still does.
- **Anything else still stales the audit.** That includes an entry edit, a dropped or edited main section, a dropped line the audit saw, and an audited CHANGELOG that git never held.
## [1.92.62.0] - 2026-11-02
- **`gstack-pregate clear --run-url` checks the run's workflow.** On a lane with no run profile, `clear` used to record any URL as the receipt, so a `windows-smoke.yml` run could clear `windows-free-tests.yml/windows-free-shard` on a branch cut before #3806. When the base config and the GitHub origin can be read, `clear` now reads the run and refuses `PREGATE_RECEIPT_INVALID` (`workflow_mismatch`) unless its workflow file is the lane's own; a run gh cannot read (`api_error`) or a URL that is not an Actions run on this repository (`run_ref_invalid`) refuses too, and each refusal names its own fix. A run of the lane's own workflow clears as before, including a job link copied with `?pr=` or `#step`. `test_backend/*` lanes, `--bundle-lane` and non-GitHub origins are unchanged. On main the Windows obligation is `windows-smoke.yml/windows-smoke`, and a test now checks that against the repository's own workflows. The `ready` conflict hint now says to clear each lane with a run of that lane's own workflow. `test/pregate-clear-runs.test.ts` covers the mismatch, the matching control, the unreadable run, the non-run URL and the copied job links; each failed before its fix.
## [1.92.61.0] - 2026-11-02
- **The security class reaches outbound URLs, archives and every `child_process` spelling** (GST-850). `lib/review-risk.ts` now flags any call to an outbound HTTP client (`fetch`, `http(s).get` or `.request`, axios, got, undici, `requests`, `httpx`, urllib), `new URL` from input, redirect toggles, proxy settings and host allowlists; tar, zip and unzip extraction, `path.join` or `resolve` on request or entry input, and `'..'` checks; and any changed line that names the `child_process` module. The new rules parse no import clause or call arguments, and a test holds each under 100 ms on 200,000-character hostile lines.
+1
View File
@@ -206,6 +206,7 @@ the new defaults.
| Validate periodic cases plus the weekly gate census on a branch | `gh workflow run evals-periodic.yml --ref <branch> -f include_gate_census=true` | CI runners + API spend | Pushed branch, `gh` with workflow rights |
| Run the opt-in ML, gitleaks and Swift checks | `gh workflow run platform-qualification.yml --ref <branch>` | Free CI runners | Pushed branch, `gh` with workflow rights |
| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:<skill>/<id> --out-dir <dir>` (`--loadout full` for the control) | Free, about a second per host | Bun 1.4.2 |
| See which loadout a model gets, or publish it as Capy would ([loadouts](docs/loadouts.md#how-gstack-picks-a-loadout)) | `bin/gstack-modules resolve --model <id>`; `bin/gstack-modules install --model <id> --full-root ~/.claude/skills --store <dir>` | Free, under a second | Bun 1.4.2 |
| Look at past local eval runs | `bun run eval:list` / `eval:compare` / `eval:summary` | Free | Local eval history |
| See a case's pass rate across recent weekly runs | `bun run eval:pass-rates --case <case-id>` | Free | `gh` with repo read access |
| Measure one red case alone against the ship bar (MEETS 9/10) | `bun run scripts/ship-measure.ts measure --case <case-id> --round baseline` ([bar](docs/TESTING_INTERNALS.md#ship-measure)) | API spend for 10 trials (12 for behavior); asks above $2/trial, stops at $25/case | Same as paid coverage |
+9 -2
View File
@@ -2,6 +2,13 @@
## NEXT PRIORITY
### P3: gstack-clean follow-ups (filed 2026-10-31, GSTA-226 E2, #2627)
- **Codex templates delete their own scratch.** The `/codex` review, challenge and consult blocks leave `codex-err-*`, `codex-resp-*`, `codex-out-*` and `codex-prompt-*` mktemp files under `TMP_ROOT` (which falls back to `.gstack/tmp`) and the `codex-prompt.*` and `codex-focus.*` free-text files in `.gstack/tmp`; `gstack-clean` removes them after 7 days. Delete each file at the end of its block instead. Skill prose for every user, so it needs its own review. **Effort:** S. **Priority:** P3.
- **Codex scratch lands at the git toplevel.** `bin/gstack-paths` falls back to the relative `.gstack/tmp` when `TMPDIR` and `TMP` are unset, so a /codex run from a subfolder leaves scratch in `<subfolder>/.gstack/tmp`, where `gstack-clean` does not look. Make the fallback `$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp`. **Effort:** S. **Priority:** P3.
- **`gstack-clean` reclaims `browse-daemon.log.1`.** The browse CLI renames `browse-daemon.log` to `.1` past 10 MB and nothing ever removes the copy. Add it to the browse group (remove when older than an hour and the daemon is not running). **Effort:** S. **Priority:** P3.
- **Browse hints `gstack-clean` when `.gstack/tmp` is large.** Print one line at daemon start when `.gstack/tmp` or the browse logs pass a size threshold, naming `gstack-clean`. **Effort:** S. **Priority:** P3.
### P2/P3: Gemini design provider follow-ups (filed 2026-10-31, from the #990 build)
- **Run the live Gemini check once a key exists** — every Gemini request and response shape in `design/src/provider.ts` follows Google's docs and stubbed fixtures; nobody has sent one to the live API. `GEMINI_API_KEY=... bun run design/scripts/live-gemini-check.ts` (about $0.04) settles the image body, the thinking-image pick and the OpenAI-compatible vision endpoint. A JPEG answer is refused today. **Effort:** S. **Priority:** P2.
@@ -18,7 +25,7 @@
- **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1.
### P2/P3: Windows smoke follow-ups (filed 2026-11-01, from the GSTA-203 PR 1 /ship review)
- **Branches cut before windows-smoke.yml still owe the old Windows lane (P2)** — pregate reads lanes from the branch's own workflows, so such a branch keeps an uncleared `windows-free-tests.yml/windows-free-shard` obligation, and `ready --dispatch-ci` skips `windows-smoke.yml` there. The lane clears it by passing `windows-free-tests.yml` to `--dispatch-ci` (the old cost) or with a main-merge grant. Have the skip note name that route, or dispatch the old workflow in smoke's place. The skip also treats a branch that deletes or renames `windows-smoke.yml` as one that predates it; check the merge base too. **Effort:** S. **Priority:** P2.
- **Branches cut before windows-smoke.yml still owe the old Windows lane (P2)** — pregate reads lanes from the branch's own workflows, so such a branch keeps an uncleared `windows-free-tests.yml/windows-free-shard` obligation, and `ready --dispatch-ci` skips `windows-smoke.yml` there. The lane clears it by passing `windows-free-tests.yml` to `--dispatch-ci` (the old cost) or with a main-merge grant. Since GSTA-203 PR 6, `gstack-pregate clear --run-url` refuses a `windows-smoke.yml` run for that lane (`workflow_mismatch`), so the wrong-workflow clear is blocked. Have the skip note name that route, or dispatch the old workflow in smoke's place. The skip also treats a branch that deletes or renames `windows-smoke.yml` as one that predates it; check the merge base too. **Effort:** S. **Priority:** P2.
- **Someone owns the nightly (P2)** — nothing reads the scheduled `windows-free-tests.yml` result or its flake ledgers. Add a test-health-report metric for scheduled runs on main, or have the daily flaky pass read the latest nightly. **Effort:** S. **Priority:** P2.
- **Smoke /browse step waits for its page server (P3)** — `windows-smoke.yml` starts `Bun.serve` in the background and calls `browse chain goto` right away; poll the port first. **Effort:** XS. **Priority:** P3.
- **Smoke seed ceiling vs the weekly reseed (P3)** — `test/windows-native-workflows.test.ts` holds each smoke file to 5 s in the Windows seed, which `free-durations-record.yml` rewrites weekly; one slow recording turns the seed PR red. Report the overrun there or re-pick files at reseed. **Effort:** S. **Priority:** P3.
@@ -95,7 +102,7 @@ The 73 open feature requests (75 with #3061 and #3122, which the themes below al
- **Ship, review and safety (9 issues)** — `/review` shadowing the native command (#422), a silent-deploy-failures checklist section (#2089), Conductor TODO proposals and a /ship block on unresolved ones (#2090), a light-ship mode (#2188), auditing an issue before filing it (#2642), maintainer-gated CI versus failures in landing-report (#2766), Flutter pubspec versions with build metadata (#2833), a deterministic fact-consistency check for document skills (#2887), a dual-engine /careful guard (#2888). **Priority:** P3.
- **Planning reviews (8 issues)** — marketplace-aware skill recommendations (#419), founder-signal reasoning in the YC recommendation (#513), grounded verification of paths, math and flags in spec reviews (#973), a substrate-neutral existing-capability check (#1685), `/autoplan --tier` (#1955), autoplan workspace safeguards (#1959), splitting one-way architecture forks from two-way defects (#2490), an evidence ledger for "is demand real?" (#2350). **Priority:** P3.
- **State root and project identity (6 issues)** — repo-local portable context artifacts (#1161), design-doc archive and prune (#1536), umbrella directories over several repos (#1923), reclaiming per-repo `.gstack` state (#2627), nested repos declaring themselves projects (#2649), references and disposition on decision events (#2678). **Priority:** P3.
- **Browser and mobile QA (6 issues)** — a remote browser via `BROWSER_WS_ENDPOINT` (#974), per-session tab isolation (#1961), an opt-in User-Agent suffix and default headers (#2459), skipping the Aside probe on Linux and Windows (#2864), activity-ring copies on caught-up reads (#2973), touch input and mobile Safari (#3058). **Priority:** P3.
- **Browser and mobile QA (5 issues)** — a remote browser via `BROWSER_WS_ENDPOINT` (#974), an opt-in User-Agent suffix and default headers (#2459), skipping the Aside probe on Linux and Windows (#2864), activity-ring copies on caught-up reads (#2973), and mobile Safari, the half of #3058 that Chromium touch emulation (GSTA-227) did not cover. Per-session tabs (#1961) shipped in GSTA-227. **Priority:** P3.
- **Prompt cost and skill shape (4 issues)** — a response-header template (#1376), caching preamble output within a session (#1572), the Fable 5 prompting-guide audit (#1994), per-skill benefit in `gstack-context-bill` (#2889). **Priority:** P3.
- **Customization and localization (3 issues)** — locale-aware skills (#481), a tutorial on new customization dimensions (#489), a `SKILL.local.md` overlay (#1759). **Priority:** P3.
- **Retro (2 issues)** — a team-scoped multi-repo retro (#402), ADR and issue awareness in the narrative (#2284). **Priority:** P3.
+1 -1
View File
@@ -1 +1 @@
1.92.61.0
1.93.0.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.92.61.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.93.0.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,
+76
View File
@@ -0,0 +1,76 @@
<!-- gstack autoplan shadow decider, prompt version 1. Frozen for the taste-replay R2 window: any edit bumps SHADOW_PROMPT_VERSION and SHADOW_PROMPT_SHA256 in lib/autoplan-shadow.ts. -->
# Predict the owner's answer at an autoplan approval gate
You are predicting how one specific person, the owner of this plan, will answer
the questions at the final approval gate of an autoplan review. You are not
deciding for him and nothing you write is shown to him. Your prediction is
recorded before he answers and later compared with what he actually chose.
Be a forecaster, not an advocate. A good prediction is one that turns out to
match his answer. Do not argue for what you think is best for the plan.
## What you have
- The plan's Implementation plan section, as reviewed.
- Each gate question: its id, its class, its title, why it was asked, its
options (each with its text and whether choosing it changes the plan), the
option the reviewers recommended and their stated basis, and the review
findings it came from, with severity and a one-line summary.
The reviewers' recommendation is one input among several. It is often right
and sometimes wrong. Owners override recommendations when a finding is weak,
when the recommended change adds scope they did not ask for, or when the
reviewers missed something about their goals. Weigh it; do not copy it.
## How this owner tends to decide
These are the six principles the owner's review process applies. Use them to
judge which option he is likely to pick, not as rules you must follow.
1. Choose completeness: ship the whole thing; prefer the approach that covers
more edge cases.
2. Boil lakes: fix everything in the blast radius (files the plan modifies and
their direct importers) when it is small.
3. Pragmatic: when two options fix the same thing, pick the cleaner one, fast.
4. DRY: reject what duplicates existing functionality; reuse what exists.
5. Explicit over clever: a short obvious fix beats a large abstraction.
6. Bias toward action: merging beats more review cycles; flag concerns rather
than block.
## For each question, predict
- action: what the owner would do with it.
- `decide`: he answers it now by picking one option.
- `send_back`: he sends the plan back for more work before answering.
- `hold`: he leaves it open or asks questions first.
- pick: the option key he would choose (one of that question's option keys),
required for every action. For `send_back` or `hold`, give the option he
would most likely settle on afterwards.
- confidence: a number from 0 to 1, the probability that he picks exactly that
option.
- cites: the finding ids from that question's findings that most shaped your
prediction (an empty list when none did). Cite only ids listed for that
question.
- reason: one or two sentences, at most 400 characters.
## The plan
{{PLAN}}
## The questions
{{QUESTIONS}}
## Output contract
Reply with exactly one fenced `json` block holding one JSON array and nothing
else. The array has exactly one object per question id listed below, in any
order, with no duplicates and no other ids:
{{IDS}}
Each object has exactly these fields:
```json
{"id": "<question id>", "action": "decide | send_back | hold", "pick": "<option key>", "confidence": 0.0, "cites": ["<finding id>"], "reason": "<at most 400 characters>"}
```
+4 -1
View File
@@ -22,6 +22,7 @@ import {
import { resultLine } from '../lib/result-codes';
import { resolveStateRoot } from '../lib/state-root';
import { validatePublic } from '../lib/autoplan-public';
import { validateShadow } from '../lib/autoplan-shadow-reader';
const HELP = `gstack-artifact — the unattended artifact contract (docs/unattended.md)
@@ -86,7 +87,9 @@ function main(argv: string[]): number {
else { printErrors(errors); process.stdout.write(errors.length ? `ARTIFACT_INVALID: ${errors.length} error(s)\n` : `ARTIFACT_VALID: ${as} ${path.basename(file)}\n`); }
return errors.length ? EXIT.fail : EXIT.ok;
}
const result = validateRun(file);
const run = validateRun(file);
const shadow = validateShadow(run.runDir);
const result = { ...run, valid: run.valid && shadow.length === 0, errors: [...run.errors, ...shadow] };
if (json) {
process.stdout.write(JSON.stringify({ valid: result.valid, run: result.manifest?.run ?? null, status: result.manifest?.status ?? null, run_dir: result.runDir, attested: result.attested ?? 0, errors: result.errors }) + '\n');
} else {
+114 -59
View File
@@ -21,6 +21,7 @@
* gstack-autoplan budget --out <dir> [--spend-cap <usd>] [--max-rounds <n>] --by <human|agent:<id>> the owner's journaled cap change
* gstack-autoplan check --out <dir> --attempt <id> --result <file> --model <id> | --unavailable <reason> the delta check before a carry
* gstack-autoplan checkpoints --from <dir> list checkpoint generations, newest first, with hash ok|bad
* gstack-autoplan shadow --out <dir> | --runs <file> [--json] export answered gates beside their shadow predictions (read-only)
* gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2)
*
* Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer),
@@ -54,6 +55,8 @@ import { estimateCostUsd, pricingRow } from '../lib/pricing';
import { resultLine } from '../lib/result-codes';
import { release } from '../lib/spend-ledger';
import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
import { claimShadow, runShadow, shadowBeforeAnswer, shadowSwitch, type ShadowClaim, type ShadowOutcome } from '../lib/autoplan-shadow';
import { exportShadow, renderShadowExport } from '../lib/autoplan-shadow-reader';
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
DRIVER: ${driverFile()}
@@ -100,6 +103,14 @@ DRIVER: ${driverFile()}
bind the one-reviewer delta check a CARRY_CHECK: line asked for: nothing at High or above carries the owner's
last answer (CARRIED:); otherwise, or when unavailable, the gate goes to the owner as before
checkpoints --from <dir> list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current
shadow --out <dir> | --runs <file> [--json]
read-only export for taste-replay R2: one row per item of each answered gate revision, its shadow prediction
(recorded before the answer; status recorded, malformed, failed, missing, retrospective or off) beside the
owner's answer and how he gave it; counts by status, item class, owner_mode and ceo_eligible. An unanswered or
interrogated revision refuses SHADOW_BEFORE_ANSWER, a CEO-only run CEO_ONLY_NO_GATE. --runs: one run dir per line.
Shadow mode itself is off by default: gstack-config set autoplan_shadow on (or GSTACK_AUTOPLAN_SHADOW=1) at the
first next; each gate then gets one hidden model call (autoplan_shadow_model, default claude-opus-5-5) counted
against --spend-cap, and every command that prints a gate prints SHADOW: first
close --out <dir> --phase <p> --deferred "<reason>" --by <human|agent:<id>>
close the phase accepted-with-findings: open voices skipped (dispatched attempts settle unknown at their
reservation), open findings into tasks.jsonl; Critical, urgent and user-challenge findings become gate items
@@ -143,7 +154,7 @@ function nextLine(state: RunState, out: string): string {
* Admission, then under the run lock: bring an existing run forward (GSTACK_VERSION_CHANGED, legacy decision ids)
* and record an admission override as a deviation. Notices go to stdout before the command's own lines (stderr with --json).
*/
function mutating(out: string, json: boolean, fn: () => number): number {
function mutating(out: string, json: boolean, fn: () => number | Report): number | Report {
const admission = admit(process.env);
touchInstallLease(process.env);
return withRunLock(out, () => {
@@ -160,13 +171,48 @@ function mutating(out: string, json: boolean, fn: () => number): number {
notices.push(...migrated.lines);
}
if (notices.length) (json ? process.stderr : process.stdout).write(notices.join('\n') + '\n');
const code = fn();
const result = fn();
if (admission.deviation && fs.existsSync(path.join(out, FILES.run))) { const state = readState(out); if (record(state)) writeState(state); }
if (code === EXIT.ok) checkpointQuietly(out);
return code;
if (typeof result !== 'number') return claimed(out, result);
if (result === EXIT.ok) checkpointQuietly(out);
return result;
});
}
/**
* What a command that can leave a gate reports: the lines before the gate, the lines after it, and its JSON payload.
* `publish` puts the SHADOW line and the gate lines between them once the shadow has run (lib/autoplan-shadow.ts).
*/
interface Report { head: string[]; tail: string[]; payload: Record<string, unknown>; gate: boolean; claim?: ShadowClaim; claimLine?: string }
/** Still under the command's run lock: a report at a gate claims its shadow (the checkpoint waits for publish); any other checkpoints now. */
function claimed(out: string, r: Report): Report {
if (!r.gate) { checkpointQuietly(out); return r; }
try { const c = claimShadow(out); return { ...r, claim: c.claim, claimLine: c.line }; }
catch (e: any) { return { ...r, claimLine: `SHADOW: failed internal (${e?.message ?? e}); the gate stands` }; }
}
const shadowField = (o: ShadowOutcome | undefined, line: string | undefined) => (o ? { shadow: { status: o.status, ...(o.code ? { code: o.code } : {}), ...(o.model ? { model: o.model } : {}), ...(o.usd !== undefined ? { usd: o.usd } : {}) } } : line ? { shadow: { line } } : {});
/**
* The one gate-publication step every command that prints a gate shares (next, submit, close, resume, revise, run, check):
* after the locked step, run the claimed shadow with no lock held, then print SHADOW, the gate and its cost, then
* checkpoint, so a checkpoint taken at a gate holds its shadow record. A shadow failure never fails the command.
*/
async function publish(out: string, json: boolean, r: number | Report): Promise<number> {
if (typeof r === 'number') return r;
const outcome = r.claim ? await runShadow(out, r.claim) : undefined;
const line = outcome?.line ?? r.claimLine;
const state = r.gate ? readState(out) : undefined;
emit(json, [...r.head, ...(line ? [line] : []), ...(state ? gateLines(state, out) : []), ...r.tail], { ...r.payload, ...(state ? gateCostField(state, out) : {}), ...shadowField(outcome, line) });
if (r.gate) gateCheckpoint(out);
return EXIT.ok;
}
function gateCheckpoint(out: string): void {
try { withRunLock(out, () => checkpointQuietly(out), { waitMs: 30_000 }); }
catch (e: any) { process.stderr.write(`CHECKPOINT: skipped for ${out}: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}; the command's result stands\n`); }
}
/** Set by a command whose success is a checkpoint moment beyond rest: a bound voice, a phase transition, the first next. */
let moment: CheckpointMoment | undefined;
@@ -185,7 +231,7 @@ function emit(json: boolean, lines: string[], payload: Record<string, unknown>):
else process.stdout.write(lines.join('\n') + '\n');
}
function doNext(out: string, rest: string[], json: boolean, price?: NextOptions['price']): number {
function doNext(out: string, rest: string[], price?: NextOptions['price']): Report {
const cwd = process.cwd();
const initLines: string[] = [];
const fresh = !fs.existsSync(path.join(out, FILES.run));
@@ -199,20 +245,18 @@ function doNext(out: string, rest: string[], json: boolean, price?: NextOptions[
if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1);
const carryCheckCapUsd = num(rest, '--carry-check-cap');
if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
const shadow = shadowSwitch({ outsideEnabled: !rest.includes('--no-outside'), ceoOnly }, process.env);
if (shadow.line) initLines.push(shadow.line);
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly, shadow: shadow.shadow,
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
}
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch, price });
moment = fresh ? 'init' : r.attempts.some(a => a.new) || r.lines.some(l => l.startsWith('PHASE_DEFERRED: ')) ? 'transition' : undefined;
const status = r.state.status;
const lines = [...initLines, ...r.lines];
if (status === 'gate_pending') lines.push(...gateLines(r.state, out));
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
lines.push(resultLineFor(status, out));
emit(json, lines, { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, ...gateCostField(r.state, out), out });
return EXIT.ok;
return { head: [...initLines, ...r.lines], tail: [...(status === 'running' ? [`NEXT: gstack-autoplan next --out ${out}`] : []), resultLineFor(status, out)], gate: status === 'gate_pending',
payload: { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, out } };
}
/** The gate pointer, its cost so far and the price of `rec` (lib/autoplan-gate-cost.ts), plus the owner-facing render to relay under Capy (lib/captain-brief.ts). */
@@ -233,7 +277,7 @@ function gateCostField(state: RunState, out: string): Record<string, unknown> {
return cost ? { gate_cost: gateCostPayload(cost) } : {};
}
function doSubmit(out: string, rest: string[], json: boolean): number {
function doSubmit(out: string, rest: string[]): number | Report {
const phase = flag(rest, '--phase'); const voice = flag(rest, '--voice'); const result = flag(rest, '--result'); const record = flag(rest, '--record');
const model = flag(rest, '--model'); const attempt = flag(rest, '--attempt'); const unavailable = flag(rest, '--unavailable'); const failed = flag(rest, '--failed');
if (!phase || !voice) return usage('submit needs --phase and --voice');
@@ -245,28 +289,22 @@ function doSubmit(out: string, rest: string[], json: boolean): number {
const r = submitResult({ out, phase, voice, result: result ? path.resolve(result) : undefined, record: record ? path.resolve(record) : undefined, model, attempt, attest: flag(rest, '--attest'),
unavailable, failed, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() });
const status = r.state.status;
const lines = [...r.lines];
if (status === 'gate_pending') lines.push(...gateLines(r.state, out));
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
lines.push(resultLineFor(status, out));
emit(json, lines, { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, ...gateCostField(r.state, out), out });
return EXIT.ok;
return { head: [...r.lines], tail: [...(status === 'running' ? [`NEXT: gstack-autoplan next --out ${out}`] : []), resultLineFor(status, out)], gate: status === 'gate_pending',
payload: { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out } };
}
function doRevise(out: string, rest: string[], json: boolean): number {
function doRevise(out: string, rest: string[]): number | Report {
const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle');
if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>');
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>');
refuseCeoOnly(readState(out), 'revise');
const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() });
const lines = [...r.lines];
if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out));
lines.push(resultLineFor(r.state.status, out));
emit(json, lines, { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out });
return EXIT.ok;
const tail = [...(r.state.status === 'running' || r.state.status === 'amend_pending' ? [nextLine(r.state, out)] : []), resultLineFor(r.state.status, out)];
return { head: [...r.lines], tail, gate: r.state.status === 'gate_pending',
payload: { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out } };
}
function doClose(out: string, rest: string[], json: boolean): number {
function doClose(out: string, rest: string[]): number | Report {
const phase = flag(rest, '--phase'); const reason = flag(rest, '--deferred'); const by = flag(rest, '--by');
if (!phase || !reason?.trim()) return usage('close needs --phase <p> --deferred "<reason>" --by <human|agent:<id>>');
if (by === undefined || !ANSWERED_BY_RE.test(by)) throw new RunError('ANSWER_BY_REQUIRED', `close records who deferred the phase: pass --by human or --by agent:<id> (got ${JSON.stringify(by ?? null)})`, 3);
@@ -278,25 +316,22 @@ function doClose(out: string, rest: string[], json: boolean): number {
const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' });
moment = 'transition';
const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])];
if (!currentPhase(state)) lines.push(...finishRun(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : []));
if (!currentPhase(state)) lines.push(...finishRun(state));
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
writeState(state);
lines.push(resultLineFor(state.status, out));
emit(json, lines, { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, ...gateCostField(state, out), out });
return EXIT.ok;
return { head: lines, tail: [resultLineFor(state.status, out)], gate: state.status === 'gate_pending', payload: { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, out } };
}
/** `check`: the delta reviewer's verdict before a carry (lib/autoplan-carry.ts). */
function doCheck(out: string, rest: string[], json: boolean): number {
function doCheck(out: string, rest: string[]): number | Report {
const unavailable = flag(rest, '--unavailable');
const result = flag(rest, '--result');
if (unavailable === undefined && (!result || !flag(rest, '--model'))) return usage('check needs --attempt <id> and --result <file> --model <id>, or --unavailable "<reason>"');
const r = submitCarryCheck({ out, attempt: flag(rest, '--attempt'), result: result ? path.resolve(result) : undefined, model: flag(rest, '--model'), unavailable, sameFamily: flag(rest, '--same-family') });
moment = 'transition';
const status = r.state.status;
const lines = [...r.lines, ...(status === 'gate_pending' ? gateLines(r.state, out) : []), ...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)];
emit(json, lines, { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out });
return EXIT.ok;
return { head: [...r.lines], tail: [...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)], gate: status === 'gate_pending',
payload: { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out } };
}
function doBudget(out: string, rest: string[], json: boolean): number {
@@ -333,13 +368,18 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
// refused before any request left this machine bills nothing, so its attempt stays open for the rerun.
const NOT_SENT = new Set(['runner_unavailable', 'cli_missing', 'model_unselected']);
for (let guard = 0; guard < 40; guard++) {
const code = withRunLock(out, () => doNext(out, rest, true, price));
if (code !== EXIT.ok) return code;
const step = withRunLock(out, () => { const r = doNext(out, rest, price); return r.gate ? claimed(out, r) : r; });
const state = readState(out);
if (state.status !== 'awaiting_result') {
if (!json) process.stdout.write([...statusLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])].join('\n') + '\n');
process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok;
const outcome = step.claim ? await runShadow(out, step.claim) : undefined;
const line = outcome?.line ?? step.claimLine;
const at = readState(out);
emit(true, [], { ...step.payload, ...gateCostField(at, out), ...shadowField(outcome, line) });
if (!json) process.stdout.write([...statusLines(at), ...(line ? [line] : []), ...(at.status === 'gate_pending' ? gateLines(at, out) : [])].join('\n') + '\n');
if (step.gate) gateCheckpoint(out);
process.stdout.write(resultLineFor(at.status, out) + '\n'); return EXIT.ok;
}
emit(true, [], step.payload);
const phase = state.phase_order.find(p => state.phases[p]?.snapshot && !state.phases[p]?.closed_at)!;
const lost = (['native', 'outside'] as const).flatMap(voice => (state.phases[phase]![voice]!.attempts ?? []).filter(a => a.state === 'dispatched' && a.sent_at).map(a => [voice, a.attempt] as const));
if (lost.length) { withRunLock(out, () => { for (const [voice, attempt] of lost) retireAttempt(out, phase as Phase, voice, attempt, 'unknown'); }); continue; }
@@ -369,7 +409,10 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
}
const usd = usdOf(voice, res.usage);
const usage = usd === undefined ? [] : ['--usage-usd', String(usd)];
try { withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage], true)); }
try {
const bound = withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage]));
if (typeof bound !== 'number') emit(true, [], bound.payload);
}
catch (e) {
if (voice !== 'outside' || !(e instanceof RunError)) { withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); }); throw e; }
withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} result refused: ${e.code}`));
@@ -379,6 +422,17 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
return EXIT.fail;
}
/** `shadow`: the read-only export for taste-replay R2 (lib/autoplan-shadow-reader.ts). */
function doShadow(rest: string[], json: boolean): number {
const runs = flag(rest, '--runs'); const one = flag(rest, '--out');
if (!runs === !one) return usage('shadow needs --out <run dir> or --runs <file> (one run directory per line)');
const dirs = runs ? fs.readFileSync(runs, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(d => path.resolve(d)) : [path.resolve(one!)];
const r = exportShadow(dirs);
for (const x of r.refused) process.stderr.write(resultLine('gstack-autoplan shadow', x.code, x.detail) + '\n');
if (json) process.stdout.write(JSON.stringify(r) + '\n'); else process.stdout.write(renderShadowExport(r).join('\n') + '\n');
return r.rows.length === 0 && r.refused.length ? EXIT.refused : EXIT.ok;
}
async function main(argv: string[]): Promise<number> {
const [cmd, ...rest] = argv;
const json = rest.includes('--json');
@@ -391,15 +445,16 @@ async function main(argv: string[]): Promise<number> {
catch (e: any) { if (!(e instanceof RunError)) throw e; process.stderr.write(resultLine('gstack-autoplan checkpoints', e.code, e.detail) + '\n'); return EXIT.refused; }
}
const outFlag = flag(rest, '--out');
if (!outFlag) return usage(`${cmd} needs --out <dir>`);
const out = path.resolve(outFlag);
if (!outFlag && cmd !== 'shadow') return usage(`${cmd} needs --out <dir>`);
const out = path.resolve(outFlag ?? '.');
try {
if (cmd === 'next') return mutating(out, json, () => doNext(out, rest, json));
if (cmd === 'submit') return mutating(out, json, () => doSubmit(out, rest, json));
if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json));
if (cmd === 'close') return mutating(out, json, () => doClose(out, rest, json));
if (cmd === 'budget') return mutating(out, json, () => doBudget(out, rest, json));
if (cmd === 'check') return mutating(out, json, () => doCheck(out, rest, json));
if (cmd === 'shadow') return doShadow(rest, json);
if (cmd === 'next') return await publish(out, json, mutating(out, json, () => doNext(out, rest)));
if (cmd === 'submit') return await publish(out, json, mutating(out, json, () => doSubmit(out, rest)));
if (cmd === 'revise') return await publish(out, json, mutating(out, json, () => doRevise(out, rest)));
if (cmd === 'close') return await publish(out, json, mutating(out, json, () => doClose(out, rest)));
if (cmd === 'budget') return await publish(out, json, mutating(out, json, () => doBudget(out, rest, json)));
if (cmd === 'check') return await publish(out, json, mutating(out, json, () => doCheck(out, rest)));
if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); return await doRun(out, rest, json); }
if (cmd === 'status') {
const state = readState(out);
@@ -409,13 +464,11 @@ async function main(argv: string[]): Promise<number> {
if (cmd === 'resume') {
const from = flag(rest, '--from');
const restored = from !== undefined ? restoreCheckpoint(from, out) : [];
return withRunLock(out, () => {
return await publish(out, json, withRunLock(out, () => {
const r = resumeRun(out);
checkpointQuietly(out);
const lines = [...restored, ...r.lines, resultLineFor(r.state.status, out)];
emit(json, lines, { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out });
return EXIT.ok;
}, { steal: true });
return claimed(out, { head: [...restored, ...r.lines], tail: [resultLineFor(r.state.status, out)], gate: r.state.status === 'gate_pending',
payload: { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out } });
}, { steal: true }));
}
if (cmd === 'answer') {
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by');
@@ -426,7 +479,9 @@ async function main(argv: string[]): Promise<number> {
const wordsBy = flag(rest, '--words-by') ?? 'owner';
if (wordsBy !== 'owner' && (wordsBy !== by || !by.startsWith('agent:'))) return usage(`--words-by must be owner or the answering agent (--by ${by ?? '<unset>'}), got ${wordsBy}`);
const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag;
return mutating(out, json, () => {
const shadowLines = await shadowBeforeAnswer(out);
if (shadowLines.length) (json ? process.stderr : process.stdout).write(shadowLines.join('\n') + '\n');
return await publish(out, json, mutating(out, json, () => {
const state = readState(out);
refuseCeoOnly(state, 'answer');
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply, ...(wordsBy === 'owner' ? {} : { wordsBy }) });
@@ -436,19 +491,19 @@ async function main(argv: string[]): Promise<number> {
lines.push(resultLineFor(state.status, out));
emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out });
return EXIT.ok;
});
}));
}
if (cmd === 'recommend') {
const rev = flag(rest, '--gate-rev'); const item = flag(rest, '--item'); const option = flag(rest, '--option'); const basis = flag(rest, '--basis');
if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"');
return mutating(out, json, () => {
return await publish(out, json, mutating(out, json, () => {
const state = readState(out);
refuseCeoOnly(state, 'recommend');
const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent');
writeState(state);
emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out });
return EXIT.ok;
});
}));
}
if (cmd === 'export' && flag(rest, '--public') !== undefined) {
return withRunLock(out, () => {
@@ -459,7 +514,7 @@ async function main(argv: string[]): Promise<number> {
});
}
if (cmd === 'export') {
return mutating(out, json, () => {
return await publish(out, json, mutating(out, json, () => {
const state = readState(out);
const pending = pendingAmendments(state).map(a => a.decision);
if (state.status === 'complete' && pending.length) throw new RunError('PLAN_AMEND_PENDING', `run says complete with accepted changes not in the plan: ${pending.join(', ')}`, 3);
@@ -468,7 +523,7 @@ async function main(argv: string[]): Promise<number> {
for (const w of r.warnings) process.stderr.write(`gstack-autoplan export: ${w}\n`);
emit(json, [...r.files.map(f => `EXPORTED: ${f}`), dispatchLine(state)], { run: state.run, files: r.files, warnings: r.warnings, dispatches: state.dispatches, est_input_tokens: state.est_input_tokens });
return EXIT.ok;
});
}));
}
if (cmd === 'release') {
const attempt = flag(rest, '--attempt');
+43 -2
View File
@@ -5,7 +5,7 @@
# Usage:
# gstack-capy-install [--for <skill> | --only <skill> | --require <id,id>] [--with-browser]
# [--revision <ref>] [--project <path>] [--root <dir>]
# [--check] [--min-revision <v>|unknown]
# [--check] [--min-revision <v>|unknown] [--model <id>]
#
# Steps: clone or reuse the checkout under a lock (never reset, never pulled
# here; the Capy skill bootstrapper fast-forwards below-minimum checkouts and
@@ -42,6 +42,12 @@
# mode-0600 file beside the verdict; callers and their children `. <file>` it,
# never re-type it) and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
# grep that, never pipe through tail).
# --model <id>, else GSTACK_MODEL (GSTA-161, docs/loadouts.md), names the model this session runs:
# bin/gstack-modules install picks its loadout, publishes a non-full one beside
# the full Claude install and prints LOADOUT and SKILL_ROOT lines before the
# launch lines; a non-full loadout gets its own launch file, keyed by loadout
# name and digest, that adds GSTACK_LOADOUT_SKILL_ROOT. The verdict, its key and its
# launch file are unchanged, and any failure serves full.
# Exit: 0 ok, 1 a component failed, 2 usage, 3 preflight stopped before ./setup.
set -u
BASH_COMPAT=50
@@ -50,7 +56,7 @@ SELF="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="${GSTACK_CAPY_ROOT:-$SELF}"
REPO="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}"
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}"
MODEL="" FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}"
usage() { sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; }
need() { [ -n "${2:-}" ] || { echo "gstack-capy-install: $1 needs a value" >&2; usage >&2; exit 2; }; }
while [ $# -gt 0 ]; do
@@ -63,11 +69,14 @@ while [ $# -gt 0 ]; do
--root) need "$1" "${2:-}"; ROOT="$2"; shift ;;
--check) CHECK_ONLY=1 ;;
--min-revision) need "$1" "${2:-}"; MIN_REVISION="$2"; shift ;;
--model) need "$1" "${2:-}"; MODEL="$2"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "gstack-capy-install: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
esac
shift
done
MODEL_SOURCE=model-flag
[ -n "$MODEL" ] || { MODEL=${GSTACK_MODEL:-}; MODEL_SOURCE=GSTACK_MODEL; }
[ -n "$FOR" ] && [ -n "$REQUIRE" ] && { echo "gstack-capy-install: --for and --require are exclusive" >&2; exit 2; }
[ -z "$FOR" ] && [ -z "$REQUIRE" ] && REQUIRE="claude,codex,patch"
[ -z "$PROJECT" ] || { _given=$PROJECT; PROJECT=$(CDPATH= cd -- "$_given" 2>/dev/null && { git rev-parse --show-toplevel 2>/dev/null || pwd -P; }) || { echo "gstack-capy-install: --project takes a directory path; '$_given' is not a directory (pass the project checkout's path, not a repo slug)" >&2; exit 2; }; }
@@ -713,9 +722,41 @@ fi
# leaves that verdict's file in place.
[ "$RC" -eq 0 ] || { [ "$PROFILE" -eq 1 ] && rm -rf "${VERDICT%.json}.profile" 2>/dev/null; verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
LOADOUT_LINES=""
if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
_lo_full=$(. "$SELF/bin/gstack-claude-root.sh" && dirname "$(gstack_canonical_claude_root)")
if [ "$MODEL_SOURCE" = model-flag ]; then
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
else
_lo_out=$(cd "$ROOT" && GSTACK_MODEL=$MODEL bin/gstack-modules install --full-root "$_lo_full" 2>&1)
fi
_lo_line=$(printf '%s\n' "$_lo_out" | grep -m1 '^LOADOUT: ')
_lo_root=$(printf '%s\n' "$_lo_out" | sed -n 's/^SKILL_ROOT: //p' | head -1)
_lo_notes=$(printf '%s\n' "$_lo_out" | grep -E '^(LOADOUT_INVALID_MODEL|LOADOUT_FALLBACK): ')
_lo_name=$(printf '%s' "$_lo_line" | sed -n 's/^LOADOUT: \([a-z0-9-]*\) .*/\1/p')
_lo_digest=$(printf '%s' "$_lo_line" | sed -n 's/.* digest=\([0-9a-f]\{12\}\) .*/\1/p')
if [ -n "$_lo_name" ] && [ "$_lo_name" != full ]; then
_lo_file="${VERDICT%.json}.loadout-$_lo_name-$_lo_digest.env"
[ -n "$_lo_digest" ] && [ -d "$_lo_root" ] \
&& _lo_tmp=$(umask 077 && mktemp "$_lo_file.tmp.XXXXXX") \
&& { cat "$LAUNCH_FILE" && echo "export GSTACK_LOADOUT_SKILL_ROOT=$(shq "$_lo_root")"; } > "$_lo_tmp" \
&& chmod 0600 "$_lo_tmp" && mv -f "$_lo_tmp" "$_lo_file" && LAUNCH_FILE=$_lo_file \
|| { rm -f "${_lo_tmp:-}" 2>/dev/null; _lo_notes="${_lo_notes:+$_lo_notes
}LOADOUT_FALLBACK: could not publish the $_lo_name launch file; serving full (docs/troubleshooting.md#loadout-fallback)"; _lo_name=""; }
fi
if [ -z "$_lo_name" ] || [ -z "$_lo_root" ]; then
[ -n "$_lo_line" ] || _lo_notes="${_lo_notes:+$_lo_notes
}LOADOUT_FALLBACK: $(printf '%s\n' "$_lo_out" | tail -1 | tr -d '\000-\037' | cut -c1-200); serving full (docs/troubleshooting.md#loadout-fallback)"
_lo_line="LOADOUT: full model=none source=$MODEL_SOURCE reason=render-failed" _lo_root=$_lo_full
fi
LOADOUT_LINES="${_lo_notes:+$_lo_notes
}$_lo_line
SKILL_ROOT: $_lo_root"
fi
[ "$CHECK_ONLY" -eq 1 ] || [ "$PROFILE" -eq 1 ] || write_shim
printf '%s\n' "$OUT" | grep -v '^gstack: '
say "total: $(( $(now) - T0 ))s"
[ -n "$LOADOUT_LINES" ] && printf '%s\n' "$LOADOUT_LINES"
echo "GSTACK_LAUNCH: ${LAUNCH_DIR:+PATH=$LAUNCH_DIR:\$PATH }GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1} GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=$VERDICT"
[ -n "$LAUNCH_DIR" ] && echo "GSTACK_LAUNCH_PATH: $LAUNCH_DIR"
[ -n "$LAUNCH_FILE" ] && echo "GSTACK_LAUNCH_FILE: $LAUNCH_FILE"
+12 -2
View File
@@ -1,14 +1,16 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-checks — the build-and-verify loop's checks for /verify. Logic: lib/checks.ts.
* gstack-checks — the build-and-verify loop's checks for /verify, and /health's tool detection. Logic: lib/checks.ts.
*/
import { spawnSync } from 'node:child_process';
import { cancelActive } from '../lib/pregate/supervise';
import { MAX_FAIL_LINES, TESTS, isTrusted, ledgerPath, load, nothingToRun, readLedger, recordTrust, runChecks, status, type Loaded } from '../lib/checks';
import { MAX_FAIL_LINES, TESTS, healthDimensions, isTrusted, ledgerPath, load, nothingToRun, readLedger, recordTrust, runChecks, status, type Loaded } from '../lib/checks';
const USAGE = `Usage: gstack-checks <detect|trust|run|status> [options]
detect list the checks run would execute and whether they are trusted (reads files only)
detect --for health /health's dimensions: DIMENSION <name> <command> (<source>), or DIMENSION <name> none
(<reason>); a CLAUDE.md Health Stack section wins (reads files and PATH only)
trust trust the current command set for this repo (checks, the pregate runner and its
selection manifest commands); any change to one of them needs trust again
run [--only a,b] [--allow-repo-commands] [--timeout <s>]
@@ -43,6 +45,14 @@ async function main(argv: string[]): Promise<number> {
const [verb, ...args] = argv;
if (!verb || verb === '--help' || verb === '-h') { process.stdout.write(USAGE); return verb ? 0 : 64; }
if (!['detect', 'trust', 'run', 'status'].includes(verb)) { process.stderr.write(USAGE); return 64; }
const mode = flag(args, '--for');
if (args.includes('--for')) {
if (verb !== 'detect' || mode !== 'health') { process.stderr.write(USAGE); return 64; }
const d = healthDimensions(process.cwd());
for (const c of d.checks) process.stdout.write(`DIMENSION ${c.dimension} ${c.command} (${c.source})\n`);
for (const m of d.missing) process.stdout.write(`DIMENSION ${m.dimension} none (${m.reason})\n`);
return 0;
}
const root = repoRoot();
if (!root) { process.stderr.write('gstack-checks: not inside a git repository\n'); return 3; }
const timeoutS = Number(flag(args, '--timeout') ?? 600);
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-clean — reclaim throwaway files in this repository's .gstack folder
* (#2627): old codex scratch in tmp/, oversized browse logs cut in place, and
* with --audit-days N old browse audit entries. Dry run unless --apply;
* `gstack-clean --help` lists the fixed thresholds. The rules live in
* lib/repo-state-clean.ts; error codes GSTACK_CLEAN_* are documented in
* docs/troubleshooting.md.
*/
import { main } from '../lib/repo-state-clean';
process.exit(main(process.argv.slice(2)));
+2 -2
View File
@@ -662,8 +662,8 @@ _gstack_codex_lock_acquire() {
if [ "$_ohost" = "$_host" ] && ! kill -0 "$_pid" 2>/dev/null; then _dead=1; fi
[ $((_now - _ts)) -ge "$_stale" ] && _dead=1
else
_ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo 0)
case "$_ts" in ''|*[!0-9]*) _ts=0 ;; esac
_ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo "$_now")
case "$_ts" in ''|*[!0-9]*) _ts="$_now" ;; esac
[ $((_now - _ts)) -ge 5 ] && _dead=1
fi
[ "$_dead" -eq 1 ] && _gstack_codex_lock_break "$_dir" "$_owner"
+31 -6
View File
@@ -229,6 +229,17 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # /codex and /claude-code still run when you ask by name.
# # An unreadable config counts as disabled.
# # Invalid values preserve the old value.
# autoplan_shadow: off # Shadow mode for the CEO-only decider (gstack-autoplan
# # driver runs): on gives every gate one hidden model
# # prediction, recorded before the owner answers, for
# # taste-replay R2. Sends the reviewed plan and the gate
# # to that model on your own API key, about $0.07 per
# # gate, counted against --spend-cap. Read at the first
# # next of each run only. GSTACK_AUTOPLAN_SHADOW=1|0
# # overrides.
# # Invalid values are rejected, the old value kept.
# autoplan_shadow_model: claude-opus-5-5 # The model the shadow decider runs on.
# # Docs: docs/unattended.md#shadow-mode
# browse_extension_id: # Forks and self-built extensions: the 32-letter (a-p)
# # Chrome extension ID the browse sidebar terminal accepts.
# # Empty = the gstack extension. "" clears it.
@@ -316,7 +327,10 @@ lookup_default() {
skill_prefix) echo "false" ;;
explain_level) echo "default" ;;
plan_mode_preamble) echo "on" ;; # on | off — off drops the plan-mode preamble on the next ./setup (#3168)
claude_loadout) echo "full" ;; # a modules/loadouts.json name — written by ./setup --loadout (docs/loadouts.md)
codex_reviews) echo "enabled" ;;
autoplan_shadow) echo "off" ;; # on | off — autoplan shadow mode, fixed at each run's first next (lib/autoplan-shadow.ts)
autoplan_shadow_model) echo "claude-opus-5-5" ;; # the shadow decider's model; needs a lib/pricing.ts row and its family's API key
design_detector) echo "auto" ;; # auto | off — impeccable engine pre-pass in the design skills
design_detector_install_prompted) echo "false" ;; # true once the user answered the one-time engine install offer with "never ask again"
gstack_contributor) echo "false" ;;
@@ -790,6 +804,10 @@ $_gstack_legacy_root"
;;
esac
fi
if [ "$KEY" = "claude_loadout" ] && ! printf '%s' "$VALUE" | grep -Eq '^[a-z0-9-]{1,40}$'; then
echo "Warning: claude_loadout must be a loadout name such as lite or full. Using full." >&2
VALUE="full"
fi
if [ "$KEY" = "plan_mode_preamble" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Warning: plan_mode_preamble '$VALUE' not recognized. Valid values: on, off. Using on." >&2
VALUE="on"
@@ -974,6 +992,12 @@ $_gstack_legacy_root"
echo "Error: memorable_recall '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
exit 1
fi
# autoplan_shadow is a CONSENT key: `on` sends each gate's plan to a model
# API. Reject like memorable_recall -- a typo must never flip it.
if [ "$KEY" = "autoplan_shadow" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Error: autoplan_shadow '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
exit 1
fi
# browse_extension_id (D1) decides which Chrome extension may reach the
# browse daemon's token route and terminal socket. Reject anything that is
# not a Chrome extension ID; "" clears the override.
@@ -1105,8 +1129,8 @@ $_gstack_legacy_root"
echo "# ─── Active values (including defaults for unset keys) ───"
report_root_disagreement
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
skill_prefix explain_level plan_mode_preamble \
codex_reviews gstack_contributor skip_eng_review workspace_root \
skill_prefix explain_level plan_mode_preamble claude_loadout \
codex_reviews autoplan_shadow autoplan_shadow_model gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \
@@ -1130,8 +1154,8 @@ $_gstack_legacy_root"
defaults)
echo "# gstack-config defaults"
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
skill_prefix explain_level plan_mode_preamble \
codex_reviews gstack_contributor skip_eng_review workspace_root \
skill_prefix explain_level plan_mode_preamble claude_loadout \
codex_reviews autoplan_shadow autoplan_shadow_model gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \
@@ -1163,6 +1187,7 @@ $_gstack_legacy_root"
. "$SCRIPT_DIR/gstack-render-claude.sh" 2>/dev/null || { echo "$0: $SCRIPT_DIR/gstack-render-claude.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
_PLAN_MODE_ARG=""
[ "$("$0" get plan_mode_preamble 2>/dev/null || true)" != off ] || _PLAN_MODE_ARG="--plan-mode=off"
gstack_claude_loadout_arg "$0"
DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect"
DETECTION_FILE="$STATE_DIR/gbrain-detection.json"
mkdir -p "$STATE_DIR"
@@ -1231,7 +1256,7 @@ $_gstack_legacy_root"
gstack_claude_overlay "$INSTALL_DIR" "$0"
RENDER_TMP="$RENDER_DIR.tmp.$$"
rm -rf "$RENDER_TMP"
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} >/dev/null 2>&1 ); then
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ${_LOADOUT_ARG:+"$_LOADOUT_ARG"} >/dev/null 2>&1 ); then
gstack_render_lock "$RENDER_DIR"
_swap_in_render "$RENDER_DIR" "$RENDER_TMP"
gstack_render_unlock "$RENDER_DIR"
@@ -1257,7 +1282,7 @@ $_gstack_legacy_root"
# brain blocks, so removing gbrain keeps the chosen overlay.
INSTALL_DIR="$HOME/.claude/skills/gstack"
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$STATE_DIR/render/claude}"
if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model || [ -n "$_PLAN_MODE_ARG" ]; } \
if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model || [ -n "$_PLAN_MODE_ARG" ] || [ -n "$_LOADOUT_ARG" ]; } \
&& [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ] && [ -f "$INSTALL_DIR/VERSION" ] && [ -f "$INSTALL_DIR/package.json" ]; then
if ! command -v bun >/dev/null 2>&1; then
echo "Skip: bun not on PATH — the Claude render at $RENDER_DIR was not refreshed. Install bun, then re-run 'gstack-config gbrain-refresh'."
+139 -30
View File
@@ -25,6 +25,10 @@
# `completed` events in projects/*/timeline.jsonl.
# Idempotent; backfilled rows count for tenure only.
#
# Writers (--derive, --log-session, --reconcile, --migrate) hold
# developer-profile.json.lock for their read-modify-write and exit 5 when
# another writer keeps it past the give-up window (#3437).
#
# Profile file: ~/.gstack/developer-profile.json (unified schema — see
# docs/designs/PLAN_TUNING_V0.md). Event file: ~/.gstack/projects/{SLUG}/
# question-events.jsonl.
@@ -37,6 +41,10 @@ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
# A relative state root is anchored to the starting directory once, here:
# do_derive changes directory, and the profile lock must name the same path
# when it is taken and when it is released.
case "$GSTACK_HOME" in /*|[A-Za-z]:[\\/]*) ;; *) GSTACK_HOME="$PWD/$GSTACK_HOME" ;; esac
# Windows git-bash: GSTACK_HOME resolves to an MSYS path (/c/Users/...), which
# Bun on Windows cannot open as a filesystem path (bites --derive). Normalize
# once, here, before PROFILE_FILE/LEGACY_FILE are derived from it below —
@@ -46,16 +54,107 @@ case "$(uname -s)" in
esac
PROFILE_FILE="$GSTACK_HOME/developer-profile.json"
LEGACY_FILE="$GSTACK_HOME/builder-profile.jsonl"
MIN_EVIDENCE=3
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
SLUG="${SLUG:-unknown}"
CMD="${1:---read}"
shift || true
# -----------------------------------------------------------------------
# Profile lock (#3437). Every subcommand that rewrites the profile reads,
# changes and renames the whole file, and gstack-question-log starts a
# background --derive after every append, so writers overlap: a shared temp
# name made renames fail and an unlocked rewrite dropped a concurrent
# --log-session. Writers hold a mkdir lock (<profile>.lock/, whose owner file
# names the holder's pid and a random token; release is ownership-checked)
# from before their read until after their rename, and write a unique temp
# file. A lock whose holder process is gone is taken over at once; one older
# than 5 minutes is taken over even when its pid is alive, since no writer
# runs that long and the pid may have been reused. Takeovers are serialized by a second mkdir lock
# (<profile>.lock.takeover/) and re-read the owner under it, so two waiters
# cannot both take over or remove a lock a third writer just took. A lock or
# takeover directory older than 30 s with no owner belongs to a writer that
# died before writing one; cleanup removes a takeover lock this writer holds. A writer that cannot get the lock within the
# give-up window writes nothing and exits 5. Writers outside this binary
# (gstack-distill-apply, /plan-tune's declared edits) do not take it yet.
# -----------------------------------------------------------------------
LOCK_DIR="$PROFILE_FILE.lock"
TAKEOVER_DIR="$LOCK_DIR.takeover"
LOCK_TOKEN=""
HOLDS_TAKEOVER=""
PROFILE_TMP=""
cleanup() {
if [ -n "$PROFILE_TMP" ]; then rm -f "$PROFILE_TMP"; fi
if [ -n "$HOLDS_TAKEOVER" ]; then rmdir "$TAKEOVER_DIR" 2>/dev/null || true; fi
if [ -n "$LOCK_TOKEN" ] && [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$LOCK_TOKEN" ]; then
rm -rf "$LOCK_DIR"
fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
dir_age_s() {
local mtime
# GNU stat first: BSD-style stat -f on Linux prints a filesystem block.
mtime=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "")
case "$mtime" in ""|*[!0-9]*) echo 0 ;; *) echo $(( $(date +%s) - mtime )) ;; esac
}
lock_abandoned() {
if [ -n "$1" ]; then
! kill -0 "${1%%-*}" 2>/dev/null || [ "$(dir_age_s "$LOCK_DIR")" -gt 300 ]
else
[ "$(dir_age_s "$LOCK_DIR")" -gt 30 ]
fi
}
acquire_lock() {
if [ -n "$LOCK_TOKEN" ]; then return 0; fi
mkdir -p "$GSTACK_HOME"
# GSTACK_PROFILE_LOCK_TIMEOUT_MS: test-only override for the give-up.
local give_up_ms="${GSTACK_PROFILE_LOCK_TIMEOUT_MS:-10000}" waited_ms=0 started=$SECONDS vanished=0 owner
local token="$$-$RANDOM$RANDOM"
until mkdir "$LOCK_DIR" 2>/dev/null; do
# mkdir failed with no lock to wait for: retry once in case a holder just
# released it, then report the directory instead of waiting out the window.
if [ ! -e "$LOCK_DIR" ]; then
if [ "$vanished" -eq 0 ]; then vanished=1; continue; fi
echo "gstack-developer-profile: cannot create $LOCK_DIR (is $GSTACK_HOME writable?), so nothing was written (exit 5)." >&2
exit 5
fi
vanished=0
owner=$(cat "$LOCK_DIR/owner" 2>/dev/null || true)
if [ -d "$LOCK_DIR" ] && lock_abandoned "$owner"; then
if [ -d "$TAKEOVER_DIR" ] && [ "$(dir_age_s "$TAKEOVER_DIR")" -gt 30 ]; then rmdir "$TAKEOVER_DIR" 2>/dev/null || true; fi
if mkdir "$TAKEOVER_DIR" 2>/dev/null; then
HOLDS_TAKEOVER=1
if [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$owner" ] && lock_abandoned "$owner"; then
rm -rf "$LOCK_DIR" 2>/dev/null || true
fi
rmdir "$TAKEOVER_DIR" 2>/dev/null || true
HOLDS_TAKEOVER=""
if [ ! -e "$LOCK_DIR" ]; then continue; fi
fi
fi
if [ "$waited_ms" -ge "$give_up_ms" ] || [ $(( SECONDS - started )) -gt $(( give_up_ms / 1000 )) ]; then
echo "gstack-developer-profile: another writer held $LOCK_DIR for ${give_up_ms} ms, so nothing was written (exit 5)." >&2
exit 5
fi
sleep 0.05
waited_ms=$(( waited_ms + 50 ))
done
printf '%s\n' "$token" > "$LOCK_DIR/owner"
LOCK_TOKEN="$token"
}
# -----------------------------------------------------------------------
# Migration: builder-profile.jsonl → developer-profile.json
# -----------------------------------------------------------------------
do_migrate() {
acquire_lock
if [ ! -f "$LEGACY_FILE" ]; then
echo "MIGRATE: no legacy file to migrate"
return 0
@@ -68,9 +167,7 @@ do_migrate() {
fi
# Run migration in a temp file, then atomic rename.
local TMPOUT
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
trap 'rm -f "$TMPOUT"' EXIT
PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
cat "$LEGACY_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
@@ -113,11 +210,11 @@ do_migrate() {
schema_version: 1,
};
console.log(JSON.stringify(profile, null, 2));
" > "$TMPOUT"
" > "$PROFILE_TMP"
# Atomic rename.
mv "$TMPOUT" "$PROFILE_FILE"
trap - EXIT
mv "$PROFILE_TMP" "$PROFILE_FILE"
PROFILE_TMP=""
# gbrain-sync: enqueue the migrated file for cross-machine sync (no-op if off).
SCRIPT_DIR_E="$(cd "$(dirname "$0")" && pwd)"
@@ -205,13 +302,12 @@ do_log_session() {
*) echo "gstack-developer-profile: bun printed no verdict while validating the session; nothing was logged" >&2; return 1 ;;
esac
acquire_lock
ensure_profile
local TMPOUT
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
trap 'rm -f "$TMPOUT"' EXIT
PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
const fs = require('fs');
const entry = JSON.parse(process.env.RECORD_INPUT);
if (!entry.ts) entry.ts = new Date().toISOString();
@@ -238,8 +334,8 @@ do_log_session() {
fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2));
"
mv "$TMPOUT" "$PROFILE_FILE"
trap - EXIT
mv "$PROFILE_TMP" "$PROFILE_FILE"
PROFILE_TMP=""
"$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null &
}
@@ -252,13 +348,12 @@ do_log_session() {
# rewritten only when something was added.
# -----------------------------------------------------------------------
do_reconcile() {
acquire_lock
ensure_profile
local TMPOUT
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
trap 'rm -f "$TMPOUT"' EXIT
PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
const fs = require('fs');
const path = require('path');
const profile = JSON.parse(fs.readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8'));
@@ -313,13 +408,13 @@ do_reconcile() {
console.log('RECONCILE: ' + added + ' backfilled from ' + runs.length + ' office-hours run(s)');
"
if [ -s "$TMPOUT" ]; then
mv "$TMPOUT" "$PROFILE_FILE"
trap - EXIT
if [ -s "$PROFILE_TMP" ]; then
mv "$PROFILE_TMP" "$PROFILE_FILE"
PROFILE_TMP=""
"$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null &
else
rm -f "$TMPOUT"
trap - EXIT
rm -f "$PROFILE_TMP"
PROFILE_TMP=""
fi
}
@@ -418,14 +513,19 @@ do_gap() {
const p = JSON.parse(await Bun.stdin.text());
const declared = p.declared || {};
const inferred = (p.inferred && p.inferred.values) || {};
const contributing = p.inferred && p.inferred.contributing_events;
const dims = ['scope_appetite','risk_tolerance','detail_preference','autonomy','architecture_care'];
const gap = {};
const low_evidence = {};
for (const d of dims) {
if (declared[d] !== undefined && inferred[d] !== undefined) {
gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3);
if (declared[d] === undefined || inferred[d] === undefined) continue;
if (contributing && (contributing[d] || 0) < $MIN_EVIDENCE) {
low_evidence[d] = contributing[d] || 0;
continue;
}
gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3);
}
console.log(JSON.stringify({ declared, inferred, gap }, null, 2));
console.log(JSON.stringify({ declared, inferred, gap, low_evidence }, null, 2));
"
}
@@ -506,6 +606,7 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) {
# Derive: recompute inferred dimensions from question-events.jsonl
# -----------------------------------------------------------------------
do_derive() {
acquire_lock
ensure_profile
local EVENTS="$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
local REGISTRY="$ROOT_DIR/scripts/question-registry.ts"
@@ -515,8 +616,9 @@ do_derive() {
exit 1
fi
PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
cd "$ROOT_DIR"
PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
import('./scripts/question-registry.ts').then(async (regmod) => {
const sigmod = await import('./scripts/psychographic-signals.ts');
const fs = require('fs');
@@ -561,6 +663,7 @@ do_derive() {
// Recompute gap.
const gap = {};
for (const d of Object.keys(values)) {
if ((contributing[d] || 0) < $MIN_EVIDENCE) continue;
if (profile.declared && profile.declared[d] !== undefined) {
gap[d] = +(Math.abs(profile.declared[d] - values[d])).toFixed(3);
}
@@ -568,12 +671,12 @@ do_derive() {
profile.gap = gap;
profile.derived_at = new Date().toISOString();
const tmp = profilePath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(profile, null, 2));
fs.renameSync(tmp, profilePath);
fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2));
console.log('DERIVE: ok — ' + count + ' events, ' + skills.size + ' skills, ' + qids.size + ' questions');
}).catch(err => { console.error('DERIVE:', err.message); process.exit(1); });
"
mv "$PROFILE_TMP" "$PROFILE_FILE"
PROFILE_TMP=""
}
# -----------------------------------------------------------------------
@@ -631,9 +734,8 @@ do_check_mismatch() {
const declared = p.declared || {};
const inferred = (p.inferred && p.inferred.values) || {};
const sampleSize = (p.inferred && p.inferred.sample_size) || 0;
const diversity = (p.inferred && p.inferred.diversity) || {};
const contributing = p.inferred && p.inferred.contributing_events;
// Require enough data before reporting mismatch.
if (sampleSize < 10) {
console.log('MISMATCH: not enough data (' + sampleSize + ' events; need 10+)');
process.exit(0);
@@ -641,8 +743,14 @@ do_check_mismatch() {
const THRESHOLD = 0.3;
const flagged = [];
const thin = [];
for (const d of Object.keys(declared)) {
if (inferred[d] === undefined) continue;
const n = contributing ? (contributing[d] || 0) : undefined;
if (n !== undefined && n < $MIN_EVIDENCE) {
thin.push(' ' + d + ': ' + (n === 0 ? 'no observed evidence' : 'too little evidence (' + n + ' of $MIN_EVIDENCE events)'));
continue;
}
const gap = Math.abs(declared[d] - inferred[d]);
if (gap > THRESHOLD) {
flagged.push({ dim: d, declared: declared[d], inferred: inferred[d], gap: +gap.toFixed(3) });
@@ -657,6 +765,7 @@ do_check_mismatch() {
console.log(' ' + f.dim + ': declared ' + f.declared + ' vs inferred ' + f.inferred + ' (gap ' + f.gap + ')');
}
}
for (const line of thin) console.log(line);
"
}
+63 -6
View File
@@ -36,6 +36,46 @@ _default_base() {
BASE="${1:-$(_default_base)}"
HEAD_REF=""
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
# A bare branch name reads origin's tracking branch, by its full ref, when that
# shares history with HEAD: /ship merges against origin/<base>, and a linked
# worktree or a Capy clone never moves the local branch, so a stale one widens
# the scope (#3650). A remote-qualified name is taken as given.
_NOT_BRANCH='^[0-9a-f]{7,64}$|[~^]|@\{|^[A-Z_]*HEAD$|^refs/'
_QUALIFIED=false
for _remote in $(git remote 2>/dev/null); do
case "$BASE" in "$_remote"/*) _QUALIFIED=true ;; esac
done
if [[ ! "$BASE" =~ $_NOT_BRANCH ]] && [ "$_QUALIFIED" = false ] \
&& git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1 \
&& git merge-base "refs/remotes/origin/${BASE}" "${HEAD_REF:-HEAD}" >/dev/null 2>&1; then
BASE="refs/remotes/origin/${BASE}"
fi
# The base as a commit (GST-884, mirroring lib/gate-diff/git.ts resolveBase from
# GST-883): a branch name reads refs/remotes/<base> (origin/main as given), then
# refs/remotes/origin/<base> that shares history with the head, then refs/heads/<base>
# that does, then another remote's twin that does, then refs/heads/<base> as it is,
# all by full ref name and in resolveBase's order, so a stale local main never widens
# the scope and a tag named main or origin/main cannot stand in for the branch.
# A sha, a relative ref (HEAD~1, main^2, @{u}) or a full refs/ name resolves as
# given. Prints nothing when nothing resolves.
_is_branch_name() {
case "$1" in refs/*|*~*|*^*|*@\{*|HEAD|*_HEAD) return 1 ;; esac
[[ ! "$1" =~ ^[0-9a-f]{7,64}$ ]]
}
_commit() { git rev-parse --verify -q "$1^{commit}" 2>/dev/null; }
_resolve_base() {
local ref="$1" head="$2" sha loc remote
if ! _is_branch_name "$ref"; then _commit "$ref"; return; fi
sha=$(_commit "refs/remotes/$ref") && { echo "$sha"; return; }
loc=$(_commit "refs/heads/$ref")
for remote in $(git remote 2>/dev/null | grep -x origin) ${loc:+:local} $(git remote 2>/dev/null | grep -vx origin); do
if [ "$remote" = :local ]; then sha=$loc; else sha=$(_commit "refs/remotes/$remote/$ref") || continue; fi
git merge-base "$sha" "$head" >/dev/null 2>&1 && { echo "$sha"; return; }
done
[ -n "$loc" ] && { echo "$loc"; return; }
_commit "$ref"
}
FRONTEND=false
BACKEND=false
@@ -53,6 +93,19 @@ AUTH=false
# 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)$'
# Data files (lib/review-depth.ts DATA_PATH) outside the config locations its
# DATA_CONFIG and agent-prose rules keep code: .github/, config/, dotfiles,
# *.config.*, .gstack/. Case-insensitive, as DATA_PATH is.
DATA_FILE_RE='\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$'
DATA_CONFIG_RE='(^|/)\.github/|(^|/)config/|(^|/)\.[^/]+$|(^|/)[^/]+\.config\.[^/]+$|^\.gstack/'
_session_data() {
local r=1
shopt -s nocasematch
if [[ "$1" =~ $DATA_FILE_RE ]] && [[ ! "$1" =~ $DATA_CONFIG_RE ]]; then r=0; fi
shopt -u nocasematch
return "$r"
}
_print_flags() {
echo "SCOPE_FRONTEND=$FRONTEND"
echo "SCOPE_BACKEND=$BACKEND"
@@ -68,7 +121,8 @@ _print_flags() {
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
# `git diff` return an empty list — all-false with exit 0, a green that means
# "we could not look". Distinguish it before diffing.
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
BASE_SHA=$(_resolve_base "$BASE" "${HEAD_REF:-HEAD}" || true)
if [ -z "$BASE_SHA" ]; then
_print_flags
echo "SCOPE_ERROR=no_base"
echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
@@ -91,16 +145,16 @@ if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then
echo "# --head '${HEAD_REF}' is not a commit in this repository"
exit 2
fi
if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then
if ! git merge-base "${BASE_SHA}" "${HEAD_REF}" >/dev/null 2>&1; then
_print_flags
echo "SCOPE_ERROR=no_merge_base"
echo "# '${BASE}' and '${HEAD_REF}' share no merge base"
exit 2
fi
_collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null)
_collect < <(git diff -z "${BASE_SHA}...${HEAD_REF}" --name-only 2>/dev/null)
else
# Committed diff vs base (merge-base form; two-dot fallback when no merge base).
_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true)
_collect < <(git diff -z "${BASE_SHA}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE_SHA}" --name-only 2>/dev/null || true)
# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo
# with no commits; tolerated.
_collect < <(git diff -z HEAD --name-only 2>/dev/null || true)
@@ -190,9 +244,12 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
*.graphql|*.gql|openapi.*|swagger.*) m_api=true ;;
esac
# Auth: authentication, authorization, sessions, permissions
# Auth: authentication, authorization, sessions, permissions. A data file
# named for sessions (recovered-sessions.jsonl, GST-911) holds rows, not
# session handling, so it is not auth; session config still is.
case "$f" in
*auth*|*session*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
*auth*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
*session*) _session_data "$f" || m_auth=true ;;
esac
# Backend: code that isn't a frontend component/view file. Includes ESM/CJS
+14 -1
View File
@@ -233,7 +233,6 @@ gstack_result_anchor() {
BUN_RESOLUTION_DRIFT) printf '%s' 'bun-resolution-drift' ;;
BUN_INSTALL_VERSION_INVALID) printf '%s' 'bun-install-version-invalid' ;;
INSTALL_UNVERIFIED) printf '%s' 'install-unverified' ;;
BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;;
BUN_VERSION_UNREADABLE) printf '%s' 'bun-version-unreadable' ;;
BASH_UNAVAILABLE) printf '%s' 'bash-unavailable' ;;
IOS_LAUNCH_ENV_INVALID) printf '%s' 'ios-launch-env-invalid' ;;
@@ -326,6 +325,7 @@ gstack_result_anchor() {
REGEN_DEPS_MISSING) printf '%s' 'regen-deps-missing' ;;
REVIEW_SCOPE_INPUT) printf '%s' 'review-scope-input' ;;
REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;;
REVIEW_SCOPE_LEDGER) printf '%s' 'review-scope-ledger' ;;
RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;;
PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;;
REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;;
@@ -381,6 +381,19 @@ gstack_result_anchor() {
CEO_ONLY_CONFLICT) printf '%s' 'ceo-only-conflict' ;;
CEO_ONLY_AFTER_INIT) printf '%s' 'ceo-only-after-init' ;;
CEO_ONLY_NO_GATE) printf '%s' 'ceo-only-no-gate' ;;
SHADOW_BEFORE_ANSWER) printf '%s' 'shadow-before-answer' ;;
BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;;
BROWSE_SESSION_INVALID) printf '%s' 'browse-session-invalid' ;;
BROWSE_SESSION_ROOT_ONLY) printf '%s' 'browse-session-root-only' ;;
BROWSE_SESSION_TAB_RESET) printf '%s' 'browse-session-tab-reset' ;;
BROWSE_SESSION_LIMIT) printf '%s' 'browse-session-limit' ;;
BROWSE_DEVICE_UNKNOWN) printf '%s' 'browse-device-unknown' ;;
BROWSE_DEVICE_CDP_FAILED) printf '%s' 'browse-device-cdp-failed' ;;
BROWSE_TOUCH_NO_DEVICE) printf '%s' 'browse-touch-no-device' ;;
GSTACK_CLEAN_USAGE) printf '%s' 'gstack-clean-usage' ;;
GSTACK_CLEAN_TARGET_REFUSED) printf '%s' 'gstack-clean-target-refused' ;;
GSTACK_CLEAN_PARTIAL) printf '%s' 'gstack-clean-partial' ;;
GSTACK_CLEAN_INTERNAL) printf '%s' 'gstack-clean-internal' ;;
*) return 1 ;;
esac
}
+1 -1
View File
@@ -400,7 +400,7 @@ async function cmdRun(argv: string[]): Promise<number> {
try {
if (shell && !shell.ok) throw new Error(shell.error);
const spawnArgv = shell?.ok ? [shell.cmd, ...shell.argv] : cmdArgv;
proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe" });
proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: log ? { ...process.env, GSTACK_EVIDENCE_LOG: log.path } : process.env });
} catch (e: any) {
// Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127.
exitCode = 127;
+4 -2
View File
@@ -39,7 +39,8 @@ const BIN_DIR = path.dirname(Bun.fileURLToPath(import.meta.url));
const USAGE = `usage: gstack-gate-diff [base-ref] [--format table|jsonl] [--commit <sha>] [--help]
Lists every hunk in a test, CI, runner/lint-config, snapshot or golden path of the
candidate (merge-base of <base-ref> and HEAD -> working tree + untracked files),
candidate (merge-base of <base-ref> and HEAD -> working tree + untracked files;
a branch name resolves to origin/<base-ref>, the local ref only when no remote has it),
tags the gate edits it recognises (RH-1 RH-3 RH-4 RH-12 RH-13 RH-14 RH-15 RH-16),
and prints the read-level listing after one GATE_SUMMARY: line. --commit <sha>
scans that commit's first-parent delta instead of the working tree.
@@ -148,7 +149,8 @@ function main(argv: string[]): number {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : `no local branch ${baseRef}`}; base is ${base.ref}`);
if (base.local === 'fallback') console.error(`gstack-gate-diff: ${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${baseRef}`}; base is the local ${baseRef}, which may lag its remote`);
else if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : base.local === 'differs' ? `local ${baseRef} is not ${base.ref}` : `no local branch ${baseRef}`}; base is ${base.ref}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
+4 -2
View File
@@ -8,13 +8,15 @@ DO_PREFIX="$2"
# Normalize prefix arg
case "$DO_PREFIX" in true|1) DO_PREFIX=1 ;; *) DO_PREFIX=0 ;; esac
# Names are read with a bash builtin loop: no process per skill (#2120).
. "$(dirname "$0")/gstack-skill-name.sh"
PATCHED=0
for skill_dir in "$GSTACK_DIR"/*/; do
[ -f "$skill_dir/SKILL.md" ] || continue
dir_name="$(basename "$skill_dir")"
dir_name=${skill_dir%/}; dir_name=${dir_name##*/}
[ "$dir_name" = "node_modules" ] && continue
cur=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true)
gstack_skill_md_name "$skill_dir/SKILL.md"; cur=$GSTACK_SKILL_NAME
[ -z "$cur" ] && continue
[ "$cur" = "gstack" ] && continue # never prefix root skill
if [ "$DO_PREFIX" -eq 1 ]; then
+8 -6
View File
@@ -9,7 +9,7 @@
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
* gstack-pregate validate | init [--explain] [--force] the working-tree .gstack/pregate.json
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--json]
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip] [--json]
* gstack-pregate regate --against <gated-head> --bundle <evidence-bundle.json> [--require <lanes>] [--pr <n>]
* [--from <gate-ahead.json>] [--predecessor-pr <n> | --predecessor-ref <ref>] [--base <branch>] [--json]
* does the gated head's evidence still cover HEAD (lib/pregate/regate.ts)
@@ -60,8 +60,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
profile, --run-url is verified the same way
lanes print every CI lane with its platform tier (from .github/workflows/*.yml)
validate | init the working-tree ${PREGATE_CONFIG_PATH}
sealed [--range <a..b>] [--remote <name>] [--base <branch>]
the sealed-output guard (docs/pregate.md#sealed) over the commits <remote> lacks, or the range:
sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip]
the sealed-output guard (docs/pregate.md#sealed) over the commits <remote> lacks, or the range,
minus commits <remote> already holds as a squashed PR's refs/pull/<n>/head or whose patch landed (--no-skip scans all):
run it before any push or PR creation in a repo with a \`sealed\` config; exit 1 refuses (SEALED_OUTPUT_REFUSED);
it never pushes: policy=<base> + commit <sha> names where the policy was read
regate --against <gated-head> --bundle <file> [--require <lanes>] [--pr <n>] [--predecessor-pr <n> | --predecessor-ref <ref>]
@@ -115,7 +116,7 @@ const VERB_FLAGS: Record<string, [string[], string[]]> = {
lanes: [[], ['--explain', '--json']],
validate: [[], ['--json']],
init: [[], ['--explain', '--force']],
sealed: [['--range', '--base', '--remote'], ['--json']],
sealed: [['--range', '--base', '--remote'], ['--json', '--no-skip']],
regate: [['--against', '--bundle', '--require', '--pr', '--from', '--predecessor', '--predecessor-pr', '--predecessor-ref', '--base', '--current'], ['--json']],
};
const HINTS: Record<string, string> = { '--sealed': 'the sealed-output guard is the verb `gstack-pregate sealed`' };
@@ -270,12 +271,13 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
const report = 'error' in listed
? sealedErrorReport(listed.error, remoteUrl ?? remote)
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote });
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote, remoteName: remoteUrl ? remote : null, skip: !rest.includes('--no-skip') });
const blocks = report.status === 'refused' || report.status === 'error';
if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; }
process.stderr.write(renderSealed(report, 'gstack-pregate sealed'));
const unchecked = report.findings.filter(f => f.rule === 'sealed.label_join_unchecked').length;
const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`;
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`);
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} skipped=${report.skipped.length} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length}${unchecked ? ` unchecked=${unchecked}` : ''} policy=${policy}\n`);
return blocks ? EXIT.fail : EXIT.ok;
}
+1 -1
View File
@@ -611,7 +611,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se
if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target");
targets.push({ commits: published.commits, pushedSha: ref.localSha });
}
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target", remoteName });
}
function main() {
+27 -4
View File
@@ -18,6 +18,10 @@
# plan_mode_preamble is off (#3168); empty or unset means on. The committed
# files carry the preamble, so with it off the default install is rendered.
#
# Callers set _LOADOUT_ARG with gstack_claude_loadout_arg (claude_loadout,
# written by ./setup --loadout; GSTA-161): a non-full loadout is rendered
# into the render dir like a pinned overlay, and the checkout stays full.
#
# Callers define `_swap_in_render` (setup and bin/gstack-config each keep
# their tested copy) and may define `log` and `bun_cmd`; plain echo and bun
# are used otherwise.
@@ -56,6 +60,23 @@ gstack_claude_overlay() {
_gstack_render_say " note: $_GSTACK_OVERLAY_NOTE"
}
# gstack_claude_loadout_arg CONFIG_BIN — set _LOADOUT_ARG to --loadout=<name>
# for a saved non-full claude_loadout, else empty. A malformed value is
# ignored with a note, so the full skills are served.
gstack_claude_loadout_arg() {
local CONFIG_BIN="$1" value
_LOADOUT_ARG=""
value="$("$CONFIG_BIN" get claude_loadout 2>/dev/null || true)"
case "$value" in
''|full) return 0 ;;
esac
if printf '%s' "$value" | grep -Eq '^[a-z0-9-]{1,40}$'; then
_LOADOUT_ARG="--loadout=$value"
else
_gstack_render_say " note: claude_loadout is not a loadout name; serving the full skills. Fix: ./setup --loadout full"
fi
}
# gstack_claude_overlay_field RECORD KEY — one field of an activation record.
gstack_claude_overlay_field() {
[ -f "$1" ] || return 0
@@ -94,10 +115,11 @@ gstack_render_unlock() {
gstack_claude_render_action() {
local render_dir="$1" gbrain="$2" previous
if [ "$gbrain" = ok ]; then _GSTACK_RENDER_ACTION=user; return 0; fi
if [ "$_GSTACK_OVERLAY" != claude ] || [ -n "${_PLAN_MODE_ARG:-}" ]; then _GSTACK_RENDER_ACTION=plain; return 0; fi
if [ "$_GSTACK_OVERLAY" != claude ] || [ -n "${_PLAN_MODE_ARG:-}" ] || [ -n "${_LOADOUT_ARG:-}" ]; then _GSTACK_RENDER_ACTION=plain; return 0; fi
previous="$(gstack_claude_overlay_field "$render_dir.overlay" overlay)"
if [ "$gbrain" = unknown ] && [ "${previous:-claude}" = claude ] \
&& [ -z "$(gstack_claude_overlay_field "$render_dir.overlay" plan_mode)" ]; then
&& [ -z "$(gstack_claude_overlay_field "$render_dir.overlay" plan_mode)" ] \
&& [ -z "$(gstack_claude_overlay_field "$render_dir.overlay" loadout)" ]; then
_GSTACK_RENDER_ACTION=keep
else
_GSTACK_RENDER_ACTION=remove
@@ -110,8 +132,8 @@ gstack_claude_render_plain() {
local src="$1" RENDER_DIR="$2" RENDER_TMP out rc=0
RENDER_TMP="$RENDER_DIR.tmp.$$"
rm -rf "$RENDER_TMP"
_gstack_render_say "Rendering Claude skills with the $_GSTACK_OVERLAY overlay${_PLAN_MODE_ARG:+ and no plan-mode preamble} into $RENDER_DIR (source checkout stays clean)..."
out="$(cd "$src" && _gstack_render_bun run gen:skill-docs --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} 2>&1)" || rc=$?
_gstack_render_say "Rendering Claude skills with the $_GSTACK_OVERLAY overlay${_PLAN_MODE_ARG:+ and no plan-mode preamble}${_LOADOUT_ARG:+ and the ${_LOADOUT_ARG#--loadout=} loadout} into $RENDER_DIR (source checkout stays clean)..."
out="$(cd "$src" && _gstack_render_bun run gen:skill-docs --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ${_LOADOUT_ARG:+"$_LOADOUT_ARG"} 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then
rm -rf "$RENDER_TMP"
printf '%s\n' "$out" | tail -3
@@ -162,6 +184,7 @@ gstack_claude_render_settle() {
printf 'overlay=%s\nsource=%s\nmodel=%s\nversion=%s\ngbrain=%s\nrender=%s\n' \
"$served" "$_GSTACK_OVERLAY_SOURCE" "$_GSTACK_OVERLAY_ID" "${version:-unknown}" "$gbrain" "$render_kind"
[ "$render_kind" != user ] || [ -z "${_PLAN_MODE_ARG:-}" ] || printf 'plan_mode=off\n'
[ "$render_kind" != user ] || [ -z "${_LOADOUT_ARG:-}" ] || printf 'loadout=%s\n' "${_LOADOUT_ARG#--loadout=}"
} > "$record.tmp.$$" && mv "$record.tmp.$$" "$record"
fi
gstack_render_unlock "$render_dir"
+74 -3
View File
@@ -28,6 +28,19 @@
# --since midnight-aligned ISO date ("2026-03-11T00:00:00") or a relative
# expression ("24 hours ago"). Default: "7 days ago".
# --until optional window end (compare mode's prior window).
#
# gstack-retro-metrics --history [--next <YYYY-MM-DD>]
#
# Retro history for this project (#3749): prints RETRO_HISTORY_DIR (the
# state root's projects/<slug>/retros, shared by every worktree of the
# repository) and one RETRO_PRIOR line per snapshot, newest first. Moves any
# legacy .context/retros/*.json from this worktree and the main checkout
# into it first: a copy identical to a stored snapshot of the same date
# collapses, a same-name snapshot that differs takes the next free number
# for its date, and a .context/retros that resolves outside its worktree
# is left alone. --next <YYYY-MM-DD> also reserves the next unused
# <date>-<n>.json (written as {} so a concurrent run cannot take it) and
# prints it as RETRO_FILE.
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
@@ -36,9 +49,11 @@ SINCE="7 days ago"
UNTIL=""
while [ $# -gt 0 ]; do
case "$1" in
--base) BASE="$2"; shift 2 ;;
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--base) BASE="$2"; shift 2 || break ;;
--since) SINCE="$2"; shift 2 || break ;;
--until) UNTIL="$2"; shift 2 || break ;;
--history) HISTORY=1; shift ;;
--next) NEXT_DATE="$2"; shift 2 || break ;;
*) shift ;;
esac
done
@@ -51,6 +66,62 @@ _BIN="$_SCRIPT_DIR"
gstack_state_root_select
_GH="$_gstack_sr_root"
if [ -n "${HISTORY:-}" ]; then
_SLUG=$("$_BIN/gstack-slug" --get SLUG 2>/dev/null || true)
if [ -z "$_SLUG" ]; then
echo "RETRO_HISTORY_ERROR: could not resolve the project slug (gstack-slug failed). fix: reinstall with ./setup or /gstack-upgrade"
exit 0
fi
_HD="$_GH/projects/$_SLUG/retros"
mkdir -p "$_HD" || { echo "RETRO_HISTORY_ERROR: cannot create $_HD"; exit 0; }
if [ -n "${NEXT_DATE:-}" ]; then
case "$NEXT_DATE" in
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
*) echo "RETRO_HISTORY_ERROR: --next takes a YYYY-MM-DD date, got '$NEXT_DATE'"; exit 0 ;;
esac
fi
_HDR=$(cd -P "$_HD" && pwd)
_TOP=$(git rev-parse --show-toplevel 2>/dev/null || true)
_MAIN=$(git worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')
_MOVED=0
for _TREE in "$_TOP" "$_MAIN"; do
[ -n "$_TREE" ] && [ -d "$_TREE/.context/retros" ] || continue
_SRC=$(cd -P "$_TREE/.context/retros" 2>/dev/null && pwd) || continue
_TR=$(cd -P "$_TREE" 2>/dev/null && pwd) || continue
case "$_SRC" in "$_TR"/*) ;; *) continue ;; esac
[ "$_SRC" != "$_HDR" ] || continue
for _F in "$_SRC"/*.json; do
[ -f "$_F" ] && [ ! -L "$_F" ] || continue
_B=$(basename "$_F")
_DUP=""
for _E in "$_HD/$_B" "$_HD/${_B%-*}"-*.json; do
[ -f "$_E" ] && cmp -s "$_F" "$_E" && { _DUP=1; break; }
done
if [ -n "$_DUP" ]; then rm -f "$_F"; continue; fi
_DEST="$_HD/$_B"
_N=1
while [ -e "$_F" ] && [ "$_N" -le 1000 ]; do
[ -e "$_DEST" ] || mv -n "$_F" "$_DEST" 2>/dev/null
[ -e "$_F" ] || { _MOVED=$((_MOVED + 1)); break; }
_N=$((_N + 1)); _DEST="$_HD/${_B%-*}-$_N.json"
done
done
rmdir "$_TREE/.context/retros" 2>/dev/null || true
done
echo "RETRO_HISTORY_DIR: $_HD"
[ "$_MOVED" -gt 0 ] && echo "RETRO_HISTORY_MIGRATED: $_MOVED from .context/retros"
ls -t "$_HD"/*.json 2>/dev/null | sed 's/^/RETRO_PRIOR: /'
if [ -n "${NEXT_DATE:-}" ]; then
_N=1
until ( set -C; printf '{}\n' > "$_HD/$NEXT_DATE-$_N.json" ) 2>/dev/null; do
_N=$((_N + 1))
[ "$_N" -le 1000 ] || { echo "RETRO_HISTORY_ERROR: cannot reserve a snapshot file in $_HD"; exit 0; }
done
echo "RETRO_FILE: $_HD/$NEXT_DATE-$_N.json"
fi
exit 0
fi
echo "RETRO_METRICS_PROTO: 1"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
+2 -1
View File
@@ -65,6 +65,7 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
specialists (with the other --extras readers), red-team, voices (Step 11). Renders first and specialists now; prints
the plan, MANIFEST: and one READER: line per reader. A plan whose NEXT is not full, scoped or fix-check prints PASS: none.
A fix-check reader (stage specialists) renders from decision 3.5's --fix-list, --hunks, --tests and --callers files.
--diff-base sets every reader's base, a scoped pass's Red Team included (the targeted reader keeps the merge base).
An existing manifest for the same --pass is reused (resumed). --extras: [{"name","prompt_file","first"?,"model"?}]
--audit-only skips the plan: the --extras readers are the whole pass (Step 14.5's docs audit)
show --manifest <f> MANIFEST_JSON: <the manifest on one line>
@@ -193,7 +194,7 @@ function pass(): number {
const extras = extrasFile ? JSON.parse(readFile(extrasFile, 'extras')!) as Extra[] : [];
const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase, root, checklistRoot: flag('--checklist-root') ?? ROOT,
dispatches: step === 'fix-check' ? p.dispatches : p.dispatches.filter(d => d.role !== 'fix-check'), next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
targetedClasses: p.targeted?.classes, mergeBase, rangeBase,
targetedClasses: p.targeted?.classes, mergeBase, rangeBase: flag('--diff-base') ? undefined : rangeBase,
fixCheck: step === 'fix-check' && (flag('--fix-list') || flag('--hunks') || flag('--tests')) ? { fix_list: flag('--fix-list')!, hunks: flag('--hunks')!, tests: flag('--tests')!, callers: flag('--callers') } : undefined });
fs.mkdirSync(m.out, { recursive: true, mode: 0o700 });
const t = sectionTexts(ROOT);
+4 -3
View File
@@ -116,8 +116,9 @@ function main(): number {
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? '');
return g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
};
const scope = scopeOf([base]);
let gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([base]);
const diffBase = collected.mergeBase ?? base;
const scope = scopeOf([diffBase]);
let gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([diffBase]);
if (gateTagged !== null && !Number.isFinite(gateTagged)) gateTagged = null;
const force = flag('--force') === 'all' ? 'all' as const : list('--force');
const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 });
@@ -149,7 +150,7 @@ function main(): number {
const gateScan = { tagged: gateTagged, head };
if (has('--json')) {
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n');
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings }) + '\n');
return EXIT.ok;
}
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`,
+29 -5
View File
@@ -5,7 +5,7 @@
* gstack-review-scope classify --base <ref> --pass <n> [--range-base <sha>] [--policy frozen|diff-plus-blocking|all] [--findings <file>|-] [--json]
* gstack-review-scope defer --findings <classified.json> [--pr <n>] [--repo <owner/repo>] [--branch <name>] [--out <deferred.json>] [--dry-run] [--json]
* gstack-review-scope render --deferred <deferred.json> [--findings <classified.json>]
* gstack-review-scope summary --since <iso-timestamp> [--out <file>] [--json]
* gstack-review-scope summary --since <iso-timestamp> (--run <dir> | --ledger <f>... | --no-ledger) [--out <file>] [--json]
*
* `classify` reads a JSON array of findings (the Step 9.3 result) and stamps
* each with `locus` (diff | fix-range | outside), `kind` (defect | test-gap |
@@ -30,6 +30,7 @@ import {
parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
ALREADY_FILED_REASON, DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding,
} from '../lib/review-scope';
import { ledgerBlockers, type LedgerBlocker } from '../lib/review-ledger';
const HELP = `gstack-review-scope — finding scope for /ship's review loop (docs/ship-policy.md#review-scope)
@@ -42,8 +43,12 @@ const HELP = `gstack-review-scope — finding scope for /ship's review loop (doc
render --deferred <file> [--findings <classified.json>]
the PR-body sections: ## Deferred findings (references, never closing keywords)
and ## Review notes (scope=note findings)
summary --since <iso> [--out <file>] the invocation's passes from this branch's review log: REVIEW_LOOP: passes=…
blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input)
summary --since <iso> (--run <dir> | --ledger <f>... | --no-ledger) [--out <file>]
the invocation's passes from this branch's review log: REVIEW_LOOP: passes=…
blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input);
blocking_found also counts every CRITICAL the dispatch ledgers collected or judged
(--run finds each review-ledger.jsonl under the run directory); unrecorded=N names
blockers no row gives a disposition. An unreadable ledger: REVIEW_SCOPE_LEDGER
Defaults: --policy from the base branch's .gstack/ship-policy.json review_scope (frozen); an unresolvable base refuses
(POLICY_SOURCE_UNAVAILABLE) unless --policy is passed; --findings - reads stdin; --branch the current branch.
@@ -117,7 +122,7 @@ function readFindings(spec: string | undefined, what: string): ScopeFinding[] {
if (!list || !list.every((f: unknown) => f && typeof f === 'object' && !Array.isArray(f))) throw new Error(`${what}: expected a JSON array of finding objects`);
return list as ScopeFinding[];
}
function refuse(cmd: string, code: 'REVIEW_SCOPE_INPUT' | 'REVIEW_SCOPE_RANGE', detail: string): number {
function refuse(cmd: string, code: 'REVIEW_SCOPE_INPUT' | 'REVIEW_SCOPE_RANGE' | 'REVIEW_SCOPE_LEDGER', detail: string): number {
process.stderr.write(resultLineWithDocs(`gstack-review-scope ${cmd}`, code, detail) + '\n');
return EXIT.fail;
}
@@ -245,7 +250,26 @@ function cmdSummary(rest: string[], json: boolean): number {
if (!line.trim() || line === 'NO_REVIEWS') continue;
try { rows.push(JSON.parse(line)); } catch { /* malformed row: gstack-review-read already warned */ }
}
const summary = summarizeReviewLoop(rows, since);
const files: string[] = [];
for (let i = 0; i < rest.length; i++) {
if (rest[i] !== '--ledger') continue;
if (!rest[i + 1] || rest[i + 1]!.startsWith('--')) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--ledger needs a file');
files.push(rest[++i]!);
}
const run = flag(rest, '--run');
if (run) {
let found: string[];
try { found = (fs.readdirSync(run, { recursive: true }) as string[]).filter(p => path.basename(p) === 'review-ledger.jsonl').sort().map(p => path.join(run, p)); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run}: ${e.code ?? e.message}`); }
if (!found.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run} holds no review-ledger.jsonl: name the run directory the passes wrote, or each --ledger`);
files.push(...found);
}
const noLedger = rest.includes('--no-ledger');
if (noLedger && files.length) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--no-ledger contradicts --run and --ledger');
// The ledgers hold every reader's findings, the voices' too, before any row classifies them; a summary without them can undercount.
if (!noLedger && !files.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', 'name the review\'s dispatch ledgers (--run <run dir> or --ledger <f>), or --no-ledger when no reader ran through gstack-review-dispatch');
let ledgers: LedgerBlocker[][];
try { ledgers = files.map(ledgerBlockers); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', e.message); }
const summary = summarizeReviewLoop(rows, since, ledgers);
const line = reviewLoopLine(summary);
// A /ship invocation always runs Step 9 once; zero matching rows means the wrong --since or a review log written elsewhere, never a loop to record.
if (summary.passes === 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `no review-log row of this branch is a via:ship review at or after ${since}; check REVIEW_SINCE (${line})`);
+21
View File
@@ -0,0 +1,21 @@
# shellcheck shell=bash
# gstack-skill-name.sh: sourced helper (bash 3.2). gstack_skill_md_name FILE
# sets GSTACK_SKILL_NAME to the first column-0 "name:" line of FILE with every
# whitespace character removed (CR included), or to "" when there is none or
# FILE cannot be read. Always returns 0.
#
# It starts no process: no subshell, pipe or command substitution, and callers
# must not wrap it in $( ). Setup reads ~55 names per run, and the
# grep | sed | tr pipeline it replaces deadlocked MSYS2's fork() under x64
# emulation on Windows ARM64 (#2120).
gstack_skill_md_name() {
GSTACK_SKILL_NAME=
[ -f "$1" ] && [ -r "$1" ] || return 0
local _gsn_line
while IFS= read -r _gsn_line || [ -n "$_gsn_line" ]; do
case "$_gsn_line" in
name:*) _gsn_line=${_gsn_line#name:}; GSTACK_SKILL_NAME=${_gsn_line//[[:space:]]/}; return 0 ;;
esac
done < "$1"
return 0
}
+32 -1
View File
@@ -41,14 +41,18 @@ SKILL_NAME=""
MODEL_OVERLAY="none"
PARENT_PID=""
BRAIN_HEALTH="no"
LOADOUT="" LOADOUT_MODEL="" FULL_ROOT=""
while [ $# -gt 0 ]; do
case "$1" in
--skill) SKILL_NAME="$2"; shift 2 ;;
--model) MODEL_OVERLAY="$2"; shift 2 ;;
--parent-pid) PARENT_PID="$2"; shift 2 ;;
--brain-health) BRAIN_HEALTH="yes"; shift ;;
--loadout) LOADOUT="$2"; shift 2 ;;
--loadout-model) LOADOUT_MODEL="$2"; shift 2 ;;
--full-root) FULL_ROOT="$2"; shift 2 ;;
-h|--help)
echo 'Usage: gstack-skill-start --skill NAME [--model OVERLAY] [--parent-pid PID] [--brain-health]'
echo 'Usage: gstack-skill-start --skill NAME [--model OVERLAY] [--parent-pid PID] [--brain-health] [--loadout NAME --loadout-model ID --full-root DIR]'
echo 'Starts a skill session and prints its STATUS lines. --help starts nothing.'
exit 0 ;;
*) shift ;;
@@ -326,6 +330,33 @@ if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then
fi
echo "VENDORED_GSTACK: $_VENDORED"
echo "MODEL_OVERLAY: $MODEL_OVERLAY"
# Loadout (GSTA-161, docs/loadouts.md): a render chosen for one model bakes
# its loadout, that model id and the full install root into the start command.
# Every value is re-validated before it is echoed, and GSTACK_MODEL (the
# running model, when the harness sets it) is only compared, never echoed
# unless it is a plain model id.
_LO_MODEL_RE='^[a-z0-9][a-z0-9._-]{0,79}$'
if [ -n "$LOADOUT" ]; then
[[ "$LOADOUT" =~ ^[a-z0-9-]{1,40}$ ]] || LOADOUT="invalid"
[[ "$LOADOUT_MODEL" =~ $_LO_MODEL_RE ]] || LOADOUT_MODEL=""
[[ "$FULL_ROOT" =~ ^/[A-Za-z0-9_.@+/-]*$ ]] || FULL_ROOT=""
[[ "$SKILL_NAME" =~ ^[a-z0-9-]{1,60}$ ]] || FULL_ROOT=""
if [ -z "$LOADOUT_MODEL" ] || [ -z "$FULL_ROOT" ]; then
echo "LOADOUT: $LOADOUT model=unknown: the start line is malformed; read the full SKILL.md for this skill instead of this file."
else
_lo_full="$FULL_ROOT/$SKILL_NAME/SKILL.md"
echo "LOADOUT: $LOADOUT model=$LOADOUT_MODEL full_root=$FULL_ROOT"
echo "LOADOUT_RULE: this file was rendered for $LOADOUT_MODEL. If you are not running $LOADOUT_MODEL, read $_lo_full now and follow it instead of this file."
if [ -n "${GSTACK_MODEL:-}" ]; then
_lo_run=$(printf '%s' "$GSTACK_MODEL" | tr '[:upper:]' '[:lower:]' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/\[[^]]*\]$//' -e 's/[[:space:]]*$//' -e 's#^[a-z0-9-]*/##')
if ! [[ "$_lo_run" =~ $_LO_MODEL_RE ]]; then
echo "LOADOUT_MISMATCH: $LOADOUT_MODEL vs an invalid GSTACK_MODEL: read $_lo_full now and follow it instead of this file."
elif [ "$_lo_run" != "$LOADOUT_MODEL" ] && ! [[ "${_lo_run#"$LOADOUT_MODEL"-}" =~ ^([0-9]{8}|latest)$ && "$_lo_run" == "$LOADOUT_MODEL"-* ]]; then
echo "LOADOUT_MISMATCH: $LOADOUT_MODEL vs $_lo_run: read $_lo_full now and follow it instead of this file."
fi
fi
fi
fi
# Plan-mode hint for skills that branch on plan-mode state. Detected best-effort
# from CLAUDE_PLAN_FILE (set by the harness when plan mode is active); "inactive"
# is the safe default (file+execute pipeline).
+5 -2
View File
@@ -60,6 +60,9 @@ GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="$HOME/.gstack"
_STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade ($_STATE_DOC)" >&2; exit 1; }
# Skill names without a process per skill (#2120); an older copy lacking the helper keeps the pipeline.
. "$(dirname "$0")/gstack-skill-name.sh" 2>/dev/null \
|| gstack_skill_md_name() { GSTACK_SKILL_NAME=$(grep -m1 '^name:' "$1" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true); }
gstack_state_root_select
RESOLVED_ROOT="$_gstack_sr_root"
RESOLVED_VAR="$_gstack_sr_var"
@@ -290,8 +293,8 @@ _INVENTORY=" _gstack-command connect-chrome gstack-connect-chrome "
if [ -d "$GSTACK_DIR" ]; then
for _SRC in "$GSTACK_DIR"/*/; do
[ -f "$_SRC/SKILL.md" ] || continue
_SRC_NAME="$(basename "$_SRC")"
_FM_NAME=$(grep -m1 '^name:' "$_SRC/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true)
_SRC_NAME=${_SRC%/}; _SRC_NAME=${_SRC_NAME##*/}
gstack_skill_md_name "$_SRC/SKILL.md"; _FM_NAME=$GSTACK_SKILL_NAME
for _N in "$_SRC_NAME" "$_FM_NAME"; do
[ -n "$_N" ] || continue
case "$_INVENTORY" in *" $_N "*) ;; *) _INVENTORY="$_INVENTORY$_N gstack-$_N " ;; esac
+11
View File
@@ -318,6 +318,17 @@ console.log("ASIDE_DIR=" + pwd); await closeTab(pg); console.log("GSTACK_STEP_OK
'
```
**Touch (verified in Aside 1.26; add the Responsive override for phone size):**
```bash
aside repl '
const pg = await openTab("<url>"), t = (m, p) => pg._sendToTarget(m, p);
await t("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
for (const [type, touchPoints] of [["touchStart", [{ x: <tap-x>, y: <tap-y> }]], ["touchEnd", []]]) await t("Input.dispatchTouchEvent", { type, touchPoints });
await closeTab(pg); console.log("GSTACK_STEP_OK");
'
```
**Links and their status (same-origin; on a LOCAL target each link is HEAD-checked, on a real site the user's cookies would ride every request so links are listed as `LINK ?` unfetched — consent to LOOK is not consent to hit every URL):**
```bash
+5
View File
@@ -95,15 +95,20 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| `cookie <name>=<value>` | Set cookie on current page domain |
| `cookie-import <json>` | Import cookies from JSON file |
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
| `device [<name> [--landscape] | off | --list]` | Emulate a phone on the current tab only: size, pixel ratio, touch support (pointer: coarse) and user agent from Playwright's devices table (names match case-insensitively). Other tabs stay desktop. No name prints the active profile or "none"; --list prints every name; off (or reset, auto) restores. viewport and responsive refuse while a device is active. |
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
| `dialog-dismiss` | Auto-dismiss next dialog |
| `fill <sel> <val>` | Fill input |
| `header <name>:<value>` | Set custom request header (colon-separated, sensitive values auto-redacted) |
| `hover <sel>` | Hover element |
| `pinch <@ref|selector|x y> <scale>` | Two-finger pinch around an element's center or x y: scale above 1 spreads the fingers (zoom in), below 1 brings them together. Needs $B device first. |
| `press <key>` | Press a Playwright keyboard key against the focused element. Names are case-sensitive: Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete, Home, End, PageUp, PageDown. Modifiers combine with +: Shift+Enter, Control+A, Meta+K. Single printable chars (a, A, 1) work too. Full key list: https://playwright.dev/docs/api/class-keyboard#keyboard-press |
| `scroll [sel|@ref]` | With a selector, smooth-scrolls the element into view. Without a selector, jumps to page bottom. No --by/--to amount option; for pixel-precise scrolling use `js window.scrollTo(0, N)`. |
| `select <sel> <val>` | Select dropdown option by value, label, or visible text |
| `style <sel> <prop> <value> | style --undo [N]` | Modify CSS property on element (with undo support) |
| `swipe <@ref|selector|x y> <up|down|left|right> [--distance <px>] [--ms <n>]` | One-finger swipe from an element's center or x y; the finger moves in the given direction (default 300px over 300ms, 10 moves). Needs $B device first. |
| `tap <@ref|selector|x y> [--hold <ms>]` | Touch and release at an element's center (scrolled into view) or at x y. Needs $B device first. |
| `touch [down|move <finger 0-9> <x> <y> | up <finger> | cancel]` | Hold, move and lift numbered fingers for multi-finger flows (hold a joystick with finger 0, tap fire with finger 1). Lifting one finger leaves the others down. No action lists held fingers; cancel releases all. Navigation and device off cancel held fingers. Needs $B device first. |
| `type [--selector <sel>] [--] <text>` | Send keystrokes to the focused element, or to <sel> with --selector (focuses it first). Text that starts with -- goes after a -- separator. |
| `upload <sel> <file> [file2...]` | Upload file(s) |
| `useragent <string>` | Set user agent |
+5 -1
View File
@@ -42,6 +42,8 @@ export interface BrowseClientOptions {
token?: string;
/** Tab id to target (every command can scope to a tab). Default: BROWSE_TAB env or undefined (active tab). */
tabId?: number;
/** Session whose own tab runs every command (root token only; tabId wins when both are set). Default: none. */
session?: string;
/** Per-request timeout in milliseconds. Default: 30_000. */
timeoutMs?: number;
/** Override state-file path. Default: BROWSE_STATE_FILE env or <git-root>/.gstack/browse.json. */
@@ -133,6 +135,7 @@ export class BrowseClient {
readonly port: number;
readonly token: string;
readonly tabId?: number;
readonly session?: string;
readonly timeoutMs: number;
constructor(opts: BrowseClientOptions = {}) {
@@ -140,6 +143,7 @@ export class BrowseClient {
this.port = auth.port;
this.token = auth.token;
this.tabId = opts.tabId ?? parseIntegerEnvValue(process.env.BROWSE_TAB);
this.session = opts.session;
this.timeoutMs = opts.timeoutMs ?? 30_000;
}
@@ -150,7 +154,7 @@ export class BrowseClient {
const body = JSON.stringify({
command: cmd,
args,
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : {}),
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : this.session ? { session: this.session } : {}),
});
let resp: Response;
+108 -25
View File
@@ -16,18 +16,20 @@
*/
import type { ChildProcess } from 'node:child_process';
import { AsyncLocalStorage } from 'node:async_hooks';
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
import { emitActivity } from './activity';
import { validateNavigationUrl, blockedNavigationReason } from './url-validation';
import { TabSession, type RefEntry } from './tab-session';
import { TabSession, type DeviceProfile, type RefEntry } from './tab-session';
import { resolveChromiumProfile, cleanSingletonLocks, resolveConfig } from './config';
import { ensureProjectProfile } from './chromium-profiles';
import { launchWithXProtectHeal } from './xprotect-heal';
import { DIRECT_PROXY_ARG, forcesDirect } from './proxy-config';
import { readPidStartTime, shouldSpawnXvfb, spawnFreeXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb';
import { withCdpSession } from './cdp-bridge';
import { applyDevice } from './device-emulation';
import { resultLine } from '../../lib/result-codes';
import { sanitizeEcho } from '../../lib/sanitize-echo';
import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot';
@@ -352,9 +354,16 @@ export interface BrowserState {
* In-memory only.
*/
owner?: string;
/** `$B device` profile, re-applied before the restored tab's first request. */
device?: DeviceProfile;
/** The tab's id before the save, so scopes on it follow it to its restored id. In-memory only. */
tabId?: number;
}>;
}
/** The tab a scoped request runs on; owner set for session scopes (#1961). */
export interface TabScope { tabId: number; owner?: string; picked?: number }
export class BrowserManager {
private browser: Browser | null = null;
private context: BrowserContext | null = null;
@@ -366,6 +375,14 @@ export class BrowserManager {
private pages: Map<number, Page> = new Map();
private tabSessions: Map<number, TabSession> = new Map();
private activeTabId: number = 0;
/**
* The tab one request runs on (#1961). A request that names a tab or a
* session runs inside this scope, so every getActiveSession() under it, after
* any await, resolves to that tab and never moves activeTabId for other
* callers. A scope with an owner follows that owner's tab across a context
* rebuild, which gives every tab a new id.
*/
private readonly tabScope = new AsyncLocalStorage<TabScope>();
private nextTabId: number = 1;
private extraHeaders: Record<string, string> = {};
private customUserAgent: string | null = null;
@@ -395,6 +412,8 @@ export class BrowserManager {
// ─── Tab Ownership (multi-agent isolation) ──────────────
// Maps tabId → clientId. Unowned tabs (not in this map) are root-only for writes.
private tabOwnership: Map<number, string> = new Map();
/** Old tab id → its id after a context rebuild, so every scope on it follows it. */
private restoredTabIds = new Map<number, number>();
/** Headless launch with BROWSE_EXTENSIONS_DIR: a persistent context (#2281). */
private extensionContext = false;
/** Pending and recorded navigation-guard verdicts per page (D2). */
@@ -1200,19 +1219,29 @@ export class BrowserManager {
normalizedUrl = await validateNavigationUrl(url);
}
const activeBefore = this.activeTabId;
const page = await this.context.newPage();
const id = this.nextTabId++;
this.pages.set(id, page);
this.tabSessions.set(id, new TabSession(page));
this.activeTabId = id;
let id = [...this.pages].find(([, p]) => p === page)?.[0];
const adopted = id !== undefined;
if (id === undefined) {
id = this.nextTabId++;
this.pages.set(id, page);
this.tabSessions.set(id, new TabSession(page));
}
const scope = this.tabScope.getStore();
if (scope) {
scope.tabId = id;
if (this.activeTabId === id) this.activeTabId = activeBefore;
} else this.activeTabId = id;
// Record tab ownership for multi-agent isolation
if (clientId) {
this.tabOwnership.set(id, clientId);
const owner = clientId ?? scope?.owner;
if (owner) {
this.tabOwnership.set(id, owner);
}
// Wire up console/network/dialog capture
this.wirePageEvents(page);
if (!adopted) this.wirePageEvents(page);
if (normalizedUrl) {
await page.goto(normalizedUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
@@ -1222,9 +1251,10 @@ export class BrowserManager {
}
async closeTab(id?: number): Promise<void> {
const tabId = id ?? this.activeTabId;
const tabId = id ?? this.getActiveTabId();
const page = this.pages.get(tabId);
if (!page) throw new Error(`Tab ${tabId} not found`);
const scope = this.tabScope.getStore();
// Capture BEFORE close(): the page 'close' event handler wired in
// wirePageEvents() can fire while page.close() is awaited. It removes
@@ -1234,15 +1264,19 @@ export class BrowserManager {
// close() resolves varies across Playwright/Chromium versions and
// machines, and losing the race means the last-tab auto-create below
// never runs, leaving the manager with zero tabs.
const wasActive = tabId === this.activeTabId;
const wasActive = tabId === this.getActiveTabId();
await page.close();
this.pages.delete(tabId);
this.tabSessions.delete(tabId);
this.tabOwnership.delete(tabId);
// Switch to another tab if we closed the active one
if (wasActive) {
// Switch to another tab if we closed the active one. A scoped request moves
// only its own scope, to another tab of the same owner when there is one.
if (wasActive && scope && this.pages.size > 0) {
const ownedTabs = scope.owner ? this.tabsOwnedBy(scope.owner) : [];
if (ownedTabs.length > 0) scope.tabId = ownedTabs[ownedTabs.length - 1]!;
} else if (wasActive) {
const remaining = [...this.pages.keys()];
if (remaining.length === 0) {
// No tabs left — create a new blank one
@@ -1257,7 +1291,9 @@ export class BrowserManager {
switchTab(id: number, opts?: { bringToFront?: boolean }): void {
if (!this.tabSessions.has(id)) throw new Error(`Tab ${id} not found`);
this.activeTabId = id;
const scope = this.tabScope.getStore();
if (scope) scope.tabId = scope.picked = id;
else this.activeTabId = id;
// Only bring to front when explicitly requested (user-initiated tab switch).
// Internal tab pinning (BROWSE_TAB) should NOT steal focus.
if (opts?.bringToFront !== false) {
@@ -1311,7 +1347,40 @@ export class BrowserManager {
}
getActiveTabId(): number {
return this.activeTabId;
const scope = this.tabScope.getStore();
return scope ? this.resolveScopeTab(scope) : this.activeTabId;
}
/** The scope's tab; an owned scope whose tab is gone (context rebuild) or neither owned nor picked with `tab <id>` moves to its owner's newest tab. */
resolveScopeTab(scope: TabScope): number {
for (let hops = 0; hops < 64 && !this.pages.has(scope.tabId) && this.restoredTabIds.has(scope.tabId); hops++) {
const moved = this.restoredTabIds.get(scope.tabId)!;
if (scope.picked === scope.tabId) scope.picked = moved;
scope.tabId = moved;
}
const kept = this.tabOwnership.get(scope.tabId) === scope.owner || scope.picked === scope.tabId;
if (scope.owner && !(this.pages.has(scope.tabId) && kept)) {
const ownedTabs = this.tabsOwnedBy(scope.owner);
if (ownedTabs.length > 0) scope.tabId = ownedTabs[ownedTabs.length - 1]!;
}
return scope.tabId;
}
tabUrl(tabId: number): string | null {
return this.pages.get(tabId)?.url() ?? null;
}
/** Run fn with every active-tab lookup bound to scope.tabId (see tabScope). */
runInTab<T>(scope: TabScope, fn: () => Promise<T>): Promise<T> {
return this.tabScope.run(scope, fn);
}
hasTab(tabId: number): boolean {
return this.pages.has(tabId);
}
tabsOwnedBy(owner: string): number[] {
return [...this.tabOwnership].filter(([id, o]) => o === owner && this.pages.has(id)).map(([id]) => id).sort((a, b) => a - b);
}
getTabCount(): number {
@@ -1388,7 +1457,8 @@ export class BrowserManager {
* TAB_PROBE_TIMEOUT_MS: a tab whose renderer is stuck comes back with
* `busy: true` and an empty title instead of hanging the list (#3247).
*/
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean; busy: boolean }>> {
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean; busy: boolean; owner: string | null }>> {
const activeId = this.getActiveTabId();
return Promise.all([...this.pages].map(async ([id, page]) => {
const title = await withinTabBudget(
this.sharedTabRead(page, 'title', () => page.title().catch(() => '')), TAB_PROBE_TIMEOUT_MS);
@@ -1396,8 +1466,9 @@ export class BrowserManager {
id,
url: page.url(),
title: title ?? '',
active: id === this.activeTabId,
active: id === activeId,
busy: title === undefined,
owner: this.tabOwnership.get(id) ?? null,
};
}));
}
@@ -1413,7 +1484,7 @@ export class BrowserManager {
* true when a new tab had to be opened.
*/
async ensureActiveTab(): Promise<boolean> {
if (this.tabSessions.has(this.activeTabId)) return false;
if (this.tabSessions.has(this.getActiveTabId())) return false;
const remaining = [...this.tabSessions.keys()];
if (remaining.length > 0) {
this.activeTabId = remaining[remaining.length - 1]!;
@@ -1447,7 +1518,7 @@ export class BrowserManager {
/** Get the TabSession for the active tab. */
getActiveSession(): TabSession {
const session = this.tabSessions.get(this.activeTabId);
const session = this.tabSessions.get(this.getActiveTabId());
if (!session) throw new Error('No active page. Use "browse goto <url>" first.');
return session;
}
@@ -1736,13 +1807,20 @@ export class BrowserManager {
}
if (!this.context) throw new Error('Browser not launched');
const tabId = this.activeTabId;
const tabId = this.getActiveTabId();
const pinned = this.getPage();
const loaded = this.getActiveSession().getLoadedHtml();
const url = pinned.url();
const activeBefore = this.activeTabId;
const page = await this.context.newPage();
const listenerId = [...this.pages].find(([, p]) => p === page)?.[0];
if (listenerId !== undefined) {
this.pages.delete(listenerId);
this.tabSessions.delete(listenerId);
}
this.activeTabId = activeBefore;
const session = new TabSession(page);
this.wirePageEvents(page);
if (listenerId === undefined) this.wirePageEvents(page);
try {
if (loaded) await session.setTabContent(loaded.html, { waitUntil: loaded.waitUntil });
else if (url !== 'about:blank') await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
@@ -1831,6 +1909,8 @@ export class BrowserManager {
loadedHtml: loaded?.html,
loadedHtmlWaitUntil: loaded?.waitUntil,
owner,
device: session?.getDevice() ?? undefined,
tabId: id,
});
}
@@ -1887,12 +1967,16 @@ export class BrowserManager {
const newSession = new TabSession(page);
this.tabSessions.set(id, newSession);
this.wirePageEvents(page);
if (saved.tabId !== undefined) this.restoredTabIds.set(saved.tabId, id);
// Restore tab ownership for the new ID — preserves scoped-agent isolation
// across context recreation (viewport --scale, user-agent change, handoff).
if (saved.owner) {
this.tabOwnership.set(id, saved.owner);
}
if (saved.device) {
await applyDevice(newSession, saved.device).catch((err: any) => console.warn(`[browse] Failed to re-apply device for tab ${id}: ${err.message}`));
}
if (saved.loadedHtml) {
// Replay load-html content via setTabContent — this rehydrates
@@ -1938,11 +2022,10 @@ export class BrowserManager {
}
// If no pages were saved, create a blank one
if (this.pages.size === 0) {
await this.newTab();
} else {
this.activeTabId = activeId ?? [...this.pages.keys()][0];
}
if (this.pages.size === 0) await this.newTab();
this.activeTabId = activeId ?? [...this.pages.keys()][0];
const scope = this.tabScope.getStore();
if (scope && !this.pages.has(this.resolveScopeTab(scope))) scope.tabId = scope.picked = this.activeTabId;
// Clear refs — pages are new, locators are stale
this.clearRefs();
+12 -3
View File
@@ -23,6 +23,7 @@ import { profileOwner, ensureProjectProfile, runProfilesCommand } from './chromi
// Zero side effects on import (documented invariant in token-registry.ts) —
// safe to pull the shared pairing default into the CLI.
import { DEFAULT_PAIR_SCOPES } from './token-registry';
import { chooseSession, extractSession, systemProcLookup } from './session-name';
const config = resolveConfig();
const IS_WINDOWS = process.platform === 'win32';
@@ -1088,14 +1089,16 @@ export async function commandTimeoutReport(state: { pid: number; port: number }
// ─── Command Dispatch ──────────────────────────────────────────
export async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
// Precedence: CLI --tab-id flag > BROWSE_TAB env var.
// Precedence: CLI --tab-id flag > BROWSE_TAB env var > --session > BROWSE_SESSION > agent harness.
// make-pdf always passes --tab-id; human users typically rely on BROWSE_TAB
// or the active tab.
const extracted = extractTabId(args);
args = extracted.args;
const sessionFlag = extractSession(extracted.args);
const envTab = process.env.BROWSE_TAB;
const tabId = extracted.tabId ?? (envTab ? parseInt(envTab, 10) : undefined);
const body = JSON.stringify({ command, args, ...(tabId !== undefined && !isNaN(tabId) ? { tabId } : {}) });
const hasTab = tabId !== undefined && !isNaN(tabId);
const { session, auto } = chooseSession({ hasTab, flag: sessionFlag.session, env: process.env, parentPid: process.ppid, lookup: systemProcLookup() });
const body = JSON.stringify({ command, args: sessionFlag.args, ...(hasTab ? { tabId } : {}), ...(session ? { session, ...(auto ? { sessionAuto: true } : {}) } : {}) });
const timeoutMs = command === 'cookie-import-browser' ? 90_000 : 30_000;
try {
@@ -1856,6 +1859,10 @@ Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
is <prop> <sel> (visible|hidden|enabled|disabled|checked|editable|focused)
Visual: screenshot [--viewport] [--clip x,y,w,h] [@ref|sel] [path]
pdf [path] | responsive [prefix]
Touch: device ["<phone>" [--landscape] | off | --list] (this tab only)
tap <@ref|sel|x y> [--hold <ms>] | pinch <@ref|sel|x y> <scale>
swipe <@ref|sel|x y> <up|down|left|right> [--distance <px>] [--ms <n>]
touch [down|move <finger 0-9> <x> <y> | up <finger> | cancel]
Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]
-D/--diff: diff against previous snapshot
-a/--annotate: annotated screenshot with ref labels
@@ -1863,6 +1870,8 @@ Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]
Compare: diff <url1> <url2>
Multi-step: chain (reads JSON from stdin)
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
--session <name> | BROWSE_SESSION=<name>: this caller's own tab
(agents get one per agent process; BROWSE_SESSION= turns it off)
Server: status | cookie <n>=<v> | header <n>:<v>
useragent <str> | stop | restart
profiles [list] | profiles prune [--days N] (per-project headed profiles)
+8 -1
View File
@@ -27,6 +27,7 @@ export const WRITE_COMMANDS = new Set([
'upload', 'dialog-accept', 'dialog-dismiss',
'style', 'cleanup', 'prettyscreenshot',
'download', 'scrape', 'archive',
'device', 'tap', 'swipe', 'pinch', 'touch',
]);
export const META_COMMANDS = new Set([
@@ -135,6 +136,12 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
'dialog-dismiss': { category: 'Interaction', description: 'Auto-dismiss next dialog' },
// Phone emulation and touch (this tab only; gstack's own Chromium)
'device': { category: 'Interaction', description: 'Emulate a phone on the current tab only: size, pixel ratio, touch support (pointer: coarse) and user agent from Playwright\'s devices table (names match case-insensitively). Other tabs stay desktop. No name prints the active profile or "none"; --list prints every name; off (or reset, auto) restores. viewport and responsive refuse while a device is active.', usage: 'device [<name> [--landscape] | off | --list]' },
'tap': { category: 'Interaction', description: 'Touch and release at an element\'s center (scrolled into view) or at x y. Needs $B device first.', usage: 'tap <@ref|selector|x y> [--hold <ms>]' },
'swipe': { category: 'Interaction', description: 'One-finger swipe from an element\'s center or x y; the finger moves in the given direction (default 300px over 300ms, 10 moves). Needs $B device first.', usage: 'swipe <@ref|selector|x y> <up|down|left|right> [--distance <px>] [--ms <n>]' },
'pinch': { category: 'Interaction', description: 'Two-finger pinch around an element\'s center or x y: scale above 1 spreads the fingers (zoom in), below 1 brings them together. Needs $B device first.', usage: 'pinch <@ref|selector|x y> <scale>' },
'touch': { category: 'Interaction', description: 'Hold, move and lift numbered fingers for multi-finger flows (hold a joystick with finger 0, tap fire with finger 1). Lifting one finger leaves the others down. No action lists held fingers; cancel releases all. Navigation and device off cancel held fingers. Needs $B device first.', usage: 'touch [down|move <finger 0-9> <x> <y> | up <finger> | cancel]' },
// Data extraction
'download': { category: 'Extraction', description: 'Download URL or media element to disk using browser cookies. Default path: <temp>/browse-download-<timestamp>.<ext>. --base64 returns a data:<mime>;base64,... string instead of writing, capped at 10MB. Use --navigate for URLs that trigger browser downloads (CDN redirects, Content-Disposition, anti-bot protected sites).', usage: 'download <url|@ref> [path] [--base64] [--navigate]' },
'scrape': { category: 'Extraction', description: 'Bulk download all media from page to --dir (default: <temp>/browse-scrape-<timestamp>). Writes files plus manifest.json with source URL, size, type, success/failure counts. --limit defaults to 50 and caps at 200.', usage: 'scrape <images|videos|media> [--selector sel] [--dir path] [--limit N]' },
@@ -231,7 +238,7 @@ export const NEW_IN_VERSION: Record<string, string> = {
* Levenshtein distance (dynamic programming).
* O(a.length * b.length) — fast for command name sizes (<20 chars).
*/
function levenshtein(a: string, b: string): number {
export function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
+225
View File
@@ -0,0 +1,225 @@
/**
* Phone emulation per tab (`$B device`, #3058). Sizes and user agents come
* from Playwright's own `devices` table. Everything is applied through the
* tab's own CDP session, never through context options or recreateContext(),
* so one agent's phone never resizes another agent's tab.
*
* device "iPhone 15" ─▶ Emulation.setDeviceMetricsOverride (mobile, screen, orientation)
* ─▶ Emulation.setTouchEmulationEnabled (maxTouchPoints 5)
* ─▶ Emulation.setUserAgentOverride (UA, platform, UA-CH metadata)
* device off ─▶ the three clears, then Playwright's viewport re-applied
*
* The profile lives on TabSession; BrowserManager.saveState()/restoreState()
* carry it across context recreation and re-apply it before the first request.
*/
import * as fs from 'fs';
import { devices } from 'playwright';
import type { Page } from 'playwright';
import type { DeviceProfile, TabSession } from './tab-session';
import { getOrCreateCdpSession } from './cdp-bridge';
import { levenshtein } from './commands';
import { resultLine } from '../../lib/result-codes';
const DEVICE_NAMES = Object.keys(devices);
const cdpSessions: WeakMap<Page, any> = new WeakMap();
/** The tab's long-lived CDP session; emulation overrides live as long as it does. */
export function tabCdpSession(page: Page): Promise<any> {
return getOrCreateCdpSession(page, cdpSessions);
}
export function describeDevice(profile: DeviceProfile | null): string {
if (!profile) return 'none';
return profile.landscape ? `${profile.name} (landscape)` : profile.name;
}
/** Usage error for commands that would fight an active phone profile. */
export function refuseWhileDevice(session: TabSession, command: string): void {
const profile = session.getDevice();
if (!profile) return;
throw new Error(
`Usage: ${command} cannot change the size of a tab that emulates ${describeDevice(profile)}; the phone profile sets its size.\n` +
'fix: $B device off',
);
}
function findDevice(name: string): string | null {
const lower = name.toLowerCase();
return DEVICE_NAMES.find((n) => n.toLowerCase() === lower) ?? null;
}
function nearestDevices(name: string): string[] {
const lower = name.toLowerCase();
return DEVICE_NAMES
.filter((n) => !n.endsWith(' landscape'))
.map((n) => ({ n, d: levenshtein(lower, n.toLowerCase()) }))
.sort((a, b) => a.d - b.d || a.n.localeCompare(b.n))
.slice(0, 3)
.map((x) => x.n);
}
function resolveProfile(profile: DeviceProfile) {
const name = findDevice(profile.name);
if (!name) {
throw new Error(resultLine('browse', 'BROWSE_DEVICE_UNKNOWN',
`"${profile.name}"; nearest: ${nearestDevices(profile.name).map((n) => `"${n}"`).join(', ')}`));
}
const landscapeName = profile.landscape ? findDevice(`${name} landscape`) : null;
const d = devices[landscapeName ?? name]!;
const landscape = profile.landscape || name.endsWith(' landscape');
const swap = profile.landscape && !landscapeName;
const viewport = swap ? { width: d.viewport.height, height: d.viewport.width } : d.viewport;
const screen = (d as { screen?: { width: number; height: number } }).screen ?? d.viewport;
return {
profile: { name, landscape: profile.landscape },
viewport,
screen: swap ? { width: screen.height, height: screen.width } : screen,
deviceScaleFactor: d.deviceScaleFactor,
isMobile: d.isMobile,
hasTouch: d.hasTouch,
userAgent: d.userAgent,
landscape,
};
}
function userAgentMetadata(ua: string, mobile: boolean) {
const android = ua.match(/Android (\d+(?:\.\d+)*)/);
const ios = ua.match(/(?:iPhone|CPU) OS (\d+(?:_\d+)*)/);
const chrome = ua.match(/Chrome\/(\d+)\.([\d.]+)/);
const platform = android ? 'Android' : ios ? 'iOS' : /Mac OS X/.test(ua) ? 'macOS' : /Windows/.test(ua) ? 'Windows' : 'Linux';
return {
brands: chrome ? [{ brand: 'Chromium', version: chrome[1]! }, { brand: 'Not.A/Brand', version: '99' }] : [],
fullVersion: chrome ? `${chrome[1]}.${chrome[2]}` : '',
platform,
platformVersion: android ? android[1]! : ios ? ios[1]!.replace(/_/g, '.') : '',
architecture: android || ios ? 'arm' : 'x86',
model: android ? (ua.match(/Android [\d.]+; ([^;)]+)/)?.[1] ?? '') : '',
mobile,
};
}
function navigatorPlatform(ua: string): string {
if (/iPhone/.test(ua)) return 'iPhone';
if (/iPad/.test(ua)) return 'iPad';
if (/Android/.test(ua)) return 'Linux armv81';
if (/Mac OS X/.test(ua)) return 'MacIntel';
return /Windows/.test(ua) ? 'Win32' : 'Linux x86_64';
}
async function sendAll(page: Page, steps: Array<[string, Record<string, unknown>]>, partly: string): Promise<void> {
const cdp = await tabCdpSession(page);
let done = 0;
for (const [method, params] of steps) {
try {
await cdp.send(method, params);
done++;
} catch (err: any) {
const failure = new Error(resultLine('browse', 'BROWSE_DEVICE_CDP_FAILED',
`${method}: ${err?.message ?? err}; ${done === 0 ? 'the tab is unchanged' : partly}`));
throw Object.assign(failure, { partial: done > 0 });
}
}
}
/** Validate, then emulate the phone on this tab. Throws before touching the browser on an unknown name. */
export async function applyDevice(session: TabSession, requested: DeviceProfile): Promise<string> {
const r = resolveProfile(requested);
const page = session.getPage();
try {
await sendAll(page, [
['Emulation.setDeviceMetricsOverride', {
width: r.viewport.width,
height: r.viewport.height,
deviceScaleFactor: r.deviceScaleFactor,
mobile: r.isMobile,
screenWidth: r.screen.width,
screenHeight: r.screen.height,
screenOrientation: r.landscape ? { type: 'landscapePrimary', angle: 90 } : { type: 'portraitPrimary', angle: 0 },
}],
['Emulation.setTouchEmulationEnabled', { enabled: r.hasTouch, maxTouchPoints: 5 }],
['Emulation.setUserAgentOverride', {
userAgent: r.userAgent,
platform: navigatorPlatform(r.userAgent),
userAgentMetadata: userAgentMetadata(r.userAgent, r.isMobile),
}],
], 'the tab is partly emulated');
} catch (err: any) {
if (err.partial) session.setDevice(r.profile);
throw err;
}
session.setDevice(r.profile);
return `Emulating ${describeDevice(r.profile)} on this tab: ${r.viewport.width}x${r.viewport.height} @${r.deviceScaleFactor}x, ` +
`${r.hasTouch ? 'touch on' : 'no touch'}, ${r.isMobile ? 'mobile' : 'desktop'} user agent. Reload to rerun page scripts; $B device off restores.`;
}
/** Drop held fingers and clear the three overrides; Playwright's own viewport comes back. */
export async function clearDevice(session: TabSession): Promise<string> {
const page = session.getPage();
const had = session.getDevice();
session.dropFingers();
await sendAll(page, [
['Emulation.clearDeviceMetricsOverride', {}],
['Emulation.setTouchEmulationEnabled', { enabled: false }],
['Emulation.setUserAgentOverride', { userAgent: '' }],
], 'the tab is partly restored');
session.setDevice(null);
const viewport = page.viewportSize();
if (viewport) await page.setViewportSize(viewport);
return had ? `Device emulation off (was ${describeDevice(had)}); desktop size, mouse input and user agent restored` : 'No device was active on this tab';
}
/**
* Playwright re-sends its own metrics after a full-page capture, which puts the
* tab back at desktop size. Chromium skips a repeated identical override from
* the same session, so clear ours first, then apply the phone again.
*/
export async function reassertDevice(session: TabSession): Promise<void> {
const profile = session.getDevice();
if (!profile) return;
await sendAll(session.getPage(), [['Emulation.clearDeviceMetricsOverride', {}]], 'the tab is partly emulated');
await applyDevice(session, profile);
}
/**
* Screenshot that keeps a device tab at phone size. Playwright's own capture
* clips to its desktop viewport and then re-sends that viewport, so a device
* tab's viewport and full-page captures go through the tab's CDP session
* (at the phone's pixel ratio); clip captures go through Playwright. Both
* Playwright's capture and a full-page one drop parts of the emulation, so the
* phone is put back afterwards.
*/
export async function screenshotTab(session: TabSession, opts: { path?: string; fullPage?: boolean; clip?: { x: number; y: number; width: number; height: number } }): Promise<Buffer> {
const page = session.getPage();
if (!session.getDevice()) return page.screenshot(opts);
if (opts.clip) {
const clipped = await page.screenshot(opts);
await reassertDevice(session);
return clipped;
}
const cdp = await tabCdpSession(page);
const params: Record<string, unknown> = { format: 'png' };
if (opts.fullPage) {
const { cssContentSize } = await cdp.send('Page.getLayoutMetrics');
params.captureBeyondViewport = true;
params.clip = { x: 0, y: 0, width: Math.ceil(cssContentSize.width), height: Math.ceil(cssContentSize.height), scale: 1 };
}
const { data } = await cdp.send('Page.captureScreenshot', params);
if (opts.fullPage) await reassertDevice(session);
const buffer = Buffer.from(data, 'base64');
if (opts.path) fs.writeFileSync(opts.path, buffer);
return buffer;
}
export async function handleDeviceCommand(args: string[], session: TabSession): Promise<string> {
const landscape = args.includes('--landscape');
const unknownFlag = args.find((a) => a.startsWith('--') && a !== '--landscape' && a !== '--list');
if (unknownFlag) throw new Error(`Usage: unknown device flag ${unknownFlag}.\nfix: $B device <name> [--landscape] | $B device off | $B device --list`);
if (args.includes('--list')) return DEVICE_NAMES.join('\n');
const name = args.filter((a) => a !== '--landscape').join(' ').trim();
if (!name) return describeDevice(session.getDevice());
if (['off', 'reset', 'auto'].includes(name.toLowerCase())) return clearDevice(session);
const result = await applyDevice(session, { name, landscape });
session.dropFingers();
return result;
}
+11 -6
View File
@@ -17,6 +17,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { TEMP_DIR } from './platform';
import { reassertDevice, refuseWhileDevice, screenshotTab } from './device-emulation';
import { resolveConfig } from './config';
import { filterSessionCookies } from './session-persist';
import type { Frame } from 'playwright';
@@ -265,7 +266,7 @@ export async function handleMetaCommand(
case 'tabs': {
const tabs = await bm.getTabListWithTitles();
return tabs.map(t =>
`${t.active ? '→ ' : ' '}[${t.id}] ${t.busy ? '(not responding)' : t.title || '(untitled)'} — ${t.url}`
`${t.active ? '→ ' : ' '}[${t.id}] ${t.busy ? '(not responding)' : t.title || '(untitled)'} — ${t.url}${t.owner ? ` (${t.owner})` : ''}`
).join('\n');
}
@@ -511,10 +512,11 @@ export async function handleMetaCommand(
const resolved = await bm.resolveRef(targetSelector);
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
buffer = await locator.screenshot({ timeout: 5000 });
await reassertDevice(bm.getActiveSession());
} else if (clipRect) {
buffer = await page.screenshot({ clip: clipRect });
buffer = await screenshotTab(bm.getActiveSession(), { clip: clipRect });
} else {
buffer = await page.screenshot({ fullPage: !viewportOnly });
buffer = await screenshotTab(bm.getActiveSession(), { fullPage: !viewportOnly });
// Guard the most common API-bricking case (fullPage). Element /
// clip captures usually stay within the cap; we still guard the
// path-mode below for fullPage writes.
@@ -530,15 +532,16 @@ export async function handleMetaCommand(
const resolved = await bm.resolveRef(targetSelector);
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
await locator.screenshot({ path: outputPath, timeout: 5000 });
await reassertDevice(bm.getActiveSession());
return `Screenshot saved (element): ${outputPath}`;
}
if (clipRect) {
await page.screenshot({ path: outputPath, clip: clipRect });
await screenshotTab(bm.getActiveSession(), { path: outputPath, clip: clipRect });
return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`;
}
await page.screenshot({ path: outputPath, fullPage: !viewportOnly });
await screenshotTab(bm.getActiveSession(), { path: outputPath, fullPage: !viewportOnly });
if (!viewportOnly) await guardScreenshotPath(outputPath);
return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`;
}
@@ -574,6 +577,7 @@ export async function handleMetaCommand(
}
case 'responsive': {
refuseWhileDevice(bm.getActiveSession(), 'responsive');
const page = bm.getPage();
const prefix = args[0] || `${TEMP_DIR}/browse-responsive`;
validateOutputPath(prefix);
@@ -921,12 +925,13 @@ export async function handleMetaCommand(
if (action === 'save') {
const state = await bm.saveState();
const activeId = bm.getActiveTabId();
// V1: cookies + URLs only (not localStorage — breaks on load-before-navigate)
const saveData = {
version: 1,
savedAt: new Date().toISOString(),
cookies: state.cookies,
pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })),
pages: state.pages.map(p => ({ url: p.url, isActive: p.tabId === activeId })),
};
writeSecureFile(statePath, JSON.stringify(saveData, null, 2));
return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages)\n⚠️ Cookies stored in plaintext. Delete when no longer needed.`;
+1 -1
View File
@@ -86,7 +86,7 @@ export const commandRoutes: RouteEntry[] = [
continue;
}
const cr = await ctx.commands.handleInternal(
{ command: cmd.command, args: cmd.args, tabId: cmd.tabId },
{ command: cmd.command, args: cmd.args, tabId: cmd.tabId, session: cmd.session ?? body.session },
tokenInfo,
{ skipRateCheck: true, skipActivity: true },
);
+7 -29
View File
@@ -14,6 +14,7 @@
*/
import { BrowserManager, markDaemonProcess } from './browser-manager';
import { runInRequestTab } from './session-tabs';
import { handleReadCommand, hasOutArg } from './read-commands';
import { handleWriteCommand } from './write-commands';
import { handleMetaCommand } from './meta-commands';
@@ -968,11 +969,11 @@ interface CommandResult {
* chainDepth: recursion guard — reject nested chains (depth > 0 means inside a chain)
*/
async function handleCommandInternalImpl(
body: { command: string; args?: string[]; tabId?: number },
body: { command: string; args?: string[] },
tokenInfo?: TokenInfo | null,
opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number },
): Promise<CommandResult> {
const { args = [], tabId } = body;
const { args = [] } = body;
const rawCommand = body.command;
if (!rawCommand) {
@@ -1049,17 +1050,7 @@ async function handleCommandInternalImpl(
if (!opts?.skipRateCheck && tokenInfo.token) recordCommand(tokenInfo.token);
}
// Pin to a specific tab if requested (set by BROWSE_TAB env var, e.g. per-tab agent contexts).
// This prevents parallel agents from interfering with each other's tab context.
// Safe because Bun's event loop is single-threaded — no concurrent handleCommand.
let savedTabId: number | null = null;
if (tabId !== undefined && tabId !== null) {
savedTabId = browserManager.getActiveTabId();
// bringToFront: false — internal tab pinning must NOT steal window focus
try { browserManager.switchTab(tabId, { bringToFront: false }); } catch (err: any) {
console.warn('[browse] Failed to pin tab', tabId, ':', err.message);
}
}
// Pin to a specific tab: runInRequestTab (session-tabs.ts) already bound this request's tab (#1961).
// ─── Tab ownership check (own-only tokens / pair-agent isolation) ──
//
@@ -1070,7 +1061,7 @@ async function handleCommandInternalImpl(
//
// Skip for `newtab` — it creates a tab rather than accessing one.
if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && tokenInfo.tabPolicy === 'own-only') {
const targetTab = tabId ?? browserManager.getActiveTabId();
const targetTab = browserManager.getActiveTabId();
if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: isWriteInvocation(command, args), ownOnly: true })) {
return {
status: 403, json: true,
@@ -1277,21 +1268,8 @@ async function handleCommandInternalImpl(
});
browserManager.resetFailures();
// Restore original active tab if we pinned to a specific one
if (savedTabId !== null) {
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
console.warn('[browse] Failed to restore tab after command:', restoreErr.message);
}
}
return { status: 200, result: tabWarning ? `${tabWarning}\n${result}` : result };
} catch (err: any) {
// Restore original active tab even on error
if (savedTabId !== null) {
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
console.warn('[browse] Failed to restore tab after error:', restoreErr.message);
}
}
// Activity: emit command_end (error) — skipped for chain subcommands
const errorDuration = Date.now() - startTime;
if (!opts?.skipActivity) {
@@ -1337,11 +1315,11 @@ async function handleCommandInternalImpl(
* Do not bypass this by calling handleCommandInternalImpl directly.
*/
async function handleCommandInternal(
body: { command: string; args?: string[]; tabId?: number },
body: { command: string; args?: string[]; tabId?: number; session?: string },
tokenInfo?: TokenInfo | null,
opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number },
): Promise<CommandResult> {
const cr = await handleCommandInternalImpl(body, tokenInfo, opts);
const cr = await runInRequestTab(browserManager, body, tokenInfo, () => handleCommandInternalImpl(body, tokenInfo, opts));
return { ...cr, result: stripLoneSurrogates(cr.result) };
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Which session a CLI call belongs to (#1961), so parallel agents on one
* daemon each get their own tab. Precedence: an explicit tab (--tab-id,
* BROWSE_TAB) wins and sends no session; then --session <name>; then
* BROWSE_SESSION (empty or "off" turns sessions off); then the agent harness
* the call runs under. The harness is the first ancestor that is not a shell,
* the same rule as bin/gstack-harness-pid.sh: every tool call an agent makes
* starts a fresh shell under the same agent process, so its pid names the
* agent across calls without any exported variable surviving between them.
* Only known agent harnesses get an automatic session; a human terminal and a
* harness shared by every session on the machine (capy-server) keep today's
* shared active tab.
*/
import * as fs from 'node:fs';
import { spawnSync } from 'node:child_process';
export interface ProcInfo { ppid: number; comm: string }
export type ProcLookup = (pid: number) => ProcInfo | null;
export interface SessionChoice { session?: string; auto?: boolean }
const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'mksh', 'ash', 'fish', 'busybox']);
const AGENT_HARNESSES = new Set(['claude', 'codex', 'cursor-agent', 'gemini', 'opencode', 'amp', 'droid']);
const SHARED_HARNESSES = new Set(['capy-server']);
/** Strip `--session <name>` from args, the way extractTabId strips --tab-id. */
export function extractSession(args: string[]): { session: string | undefined; args: string[] } {
const stripped: string[] = [];
let session: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--session') session = args[++i] ?? session;
else stripped.push(args[i]!);
}
return { session, args: stripped };
}
/** The first non-shell ancestor of `pid` (at most 8 hops), or null when the walk fails. */
export function harnessOf(pid: number, lookup: ProcLookup): { pid: number; comm: string } | null {
let current = pid;
for (let hops = 0; hops < 8; hops++) {
const info = lookup(current);
if (!info) return null;
if (!SHELLS.has(info.comm)) return { pid: current, comm: info.comm };
if (info.ppid <= 1) return null;
current = info.ppid;
}
return null;
}
export function autoSessionName(env: NodeJS.ProcessEnv, parentPid: number, lookup: ProcLookup): string | undefined {
const harness = harnessOf(parentPid, lookup);
if (!harness || SHARED_HARNESSES.has(harness.comm)) return undefined;
if (!AGENT_HARNESSES.has(harness.comm) && env.CLAUDECODE !== '1') return undefined;
return `agent-${harness.pid}`;
}
export function chooseSession(opts: {
hasTab: boolean; flag: string | undefined; env: NodeJS.ProcessEnv; parentPid: number; lookup: ProcLookup;
}): SessionChoice {
if (opts.hasTab) return {};
if (opts.flag !== undefined) return opts.flag === '' || opts.flag === 'off' ? {} : { session: opts.flag };
const fromEnv = opts.env.BROWSE_SESSION;
if (fromEnv !== undefined) return fromEnv === '' || fromEnv === 'off' ? {} : { session: fromEnv };
const auto = autoSessionName(opts.env, opts.parentPid, opts.lookup);
return auto ? { session: auto, auto: true } : {};
}
function basename(comm: string): string {
const name = comm.trim().split('/').pop() ?? '';
return name.startsWith('-') ? name.slice(1) : name;
}
/** Linux reads /proc; macOS takes one `ps` table; anything else has no automatic session. */
export function systemProcLookup(): ProcLookup {
if (process.platform === 'linux') {
return (pid) => {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
const open = stat.indexOf('(');
const close = stat.lastIndexOf(')');
const ppid = Number(stat.slice(close + 2).split(' ')[1]);
return Number.isFinite(ppid) ? { ppid, comm: basename(stat.slice(open + 1, close)) } : null;
} catch { return null; }
};
}
if (process.platform !== 'darwin') return () => null;
let table: Map<number, ProcInfo> | null = null;
return (pid) => {
if (!table) {
table = new Map();
const ps = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', timeout: 2000, windowsHide: true });
for (const line of (ps.stdout ?? '').split('\n')) {
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
if (m) table.set(Number(m[1]), { ppid: Number(m[2]), comm: basename(m[3]!) });
}
}
return table.get(pid) ?? null;
};
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Per-session tabs for agents sharing one browse daemon (#1961).
*
* A request that names a session runs inside BrowserManager.runInTab with a
* scope owned by `session:<name>`: the session's first command opens its own
* tab, and every later command, chain subcommand included, resolves to that
* tab without moving the daemon's global active tab. Sessions isolate tab
* routing only; cookies and storage stay shared by every tab in the browser.
* Only the root token may name a session, so a paired or tunnel agent cannot
* open tabs through it (a session the CLI derived on its own is ignored for
* them instead). A request that names a tab id runs in the same kind of
* scope with no owner, which replaces the old switch-then-restore pin.
*/
import type { BrowserManager, TabScope } from './browser-manager';
import type { TokenInfo } from './token-registry';
import { resultLine } from '../../lib/result-codes';
interface Result { status: number; result: string; json?: boolean; headers?: Record<string, string> }
interface SessionRecord extends TabScope {
owner: string;
lastUsed: number;
inFlight: number;
lastUrl: string;
opening: Promise<void> | null;
}
const SESSION_NAME = /^[A-Za-z0-9._:-]{1,64}$/;
const REOPENING_COMMANDS = new Set(['goto', 'newtab']);
const DAEMON_COMMANDS = new Set(['status', 'stop', 'restart']);
const DEFAULT_IDLE_MINUTES = 120;
const DEFAULT_MAX_SESSIONS = 16;
const sessions = new Map<string, SessionRecord>();
const MAX_DROPPED = 256;
/** Sessions the idle sweep or the cap dropped, with their last URL, so a later read reports the reset instead of a blank tab. */
const dropped = new Map<string, string>();
function numberFromEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.trim() === '') return fallback;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function refuse(status: number, line: string): Result {
return { status, json: true, result: JSON.stringify({ error: line }) };
}
/** Close (headless) or release (headed) every tab of sessions idle past BROWSE_SESSION_IDLE_MINUTES (0 = never). */
async function sweepIdle(bm: BrowserManager, now: number, keep: string): Promise<void> {
const idleMinutes = numberFromEnv('BROWSE_SESSION_IDLE_MINUTES', DEFAULT_IDLE_MINUTES);
if (idleMinutes === 0) return;
for (const [name, record] of sessions) {
if (name === keep || record.inFlight > 0 || now - record.lastUsed < idleMinutes * 60_000) continue;
await dropSession(bm, name, record);
}
}
async function dropSession(bm: BrowserManager, name: string, record: SessionRecord): Promise<void> {
sessions.delete(name);
dropped.delete(name);
dropped.set(name, record.lastUrl);
if (dropped.size > MAX_DROPPED) dropped.delete(dropped.keys().next().value!);
if (bm.getConnectionMode() === 'headed') {
bm.releaseClientTabs(record.owner);
return;
}
for (const id of bm.tabsOwnedBy(record.owner)) await bm.closeTab(id).catch(() => {});
}
/** Make room for session `name` by dropping the least recently used idle session. */
async function makeRoom(bm: BrowserManager, name: string): Promise<boolean> {
const others = [...sessions].filter(([n]) => n !== name);
if (others.length < numberFromEnv('BROWSE_MAX_SESSIONS', DEFAULT_MAX_SESSIONS)) return true;
const idle = others.filter(([, r]) => r.inFlight === 0 && !r.opening).sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
if (!idle) return false;
await dropSession(bm, idle[0], idle[1]);
return true;
}
/** Open the session's tab once; concurrent first commands share the same promise. */
function openTab(bm: BrowserManager, name: string, record: SessionRecord): Promise<void> {
record.opening ??= (async () => {
if (!(await makeRoom(bm, name))) throw new Error('limit');
await bm.runInTab(record, () => bm.newTab(undefined, record.owner));
})().finally(() => { record.opening = null; });
return record.opening;
}
async function runInSession<R extends Result>(bm: BrowserManager, name: string, command: string, quiet: boolean, fn: () => Promise<R>): Promise<Result> {
const now = Date.now();
await sweepIdle(bm, now, name);
let record = sessions.get(name);
let notice = '';
if (!record && dropped.has(name)) {
if (!REOPENING_COMMANDS.has(command)) {
const last = dropped.get(name) || 'about:blank';
return refuse(409, resultLine('browse', 'BROWSE_SESSION_TAB_RESET', `session ${name} was closed after going idle or to make room; it was on ${last}`));
}
dropped.delete(name);
}
try {
if (!record) {
record = { tabId: 0, owner: `session:${name}`, lastUsed: now, inFlight: 0, lastUrl: '', opening: null };
sessions.set(name, record);
await openTab(bm, name, record);
notice = `[browse] session ${name} → tab ${record.tabId}`;
} else if (record.opening) {
await record.opening;
} else if (!bm.hasTab(bm.resolveScopeTab(record))) {
if (!REOPENING_COMMANDS.has(command)) {
const last = record.lastUrl || 'about:blank';
return refuse(409, resultLine('browse', 'BROWSE_SESSION_TAB_RESET', `session ${name}'s tab ${record.tabId} was closed; it was on ${last}`));
}
await openTab(bm, name, record);
notice = `[browse] session ${name}: its tab was closed, so this runs on new tab ${record.tabId}`;
}
} catch (err: any) {
if (record && !bm.hasTab(bm.resolveScopeTab(record))) sessions.delete(name);
if (err?.message !== 'limit') throw err;
return refuse(429, resultLine('browse', 'BROWSE_SESSION_LIMIT', `${sessions.size} sessions are running a command`));
}
record.inFlight++;
record.lastUsed = Date.now();
try {
const cr = await bm.runInTab(record, fn);
return notice && !cr.json && !quiet ? { ...cr, result: `${notice}\n${cr.result}` } : cr;
} finally {
record.inFlight--;
record.lastUsed = Date.now();
record.lastUrl = bm.tabUrl(bm.resolveScopeTab(record)) ?? record.lastUrl;
}
}
/**
* Run one command on the tab its request names: `tabId` first (BROWSE_TAB,
* --tab-id), then `session`. A request naming neither runs in whatever scope
* it was called from, so chain subcommands inherit their parent's tab.
*/
export async function runInRequestTab<R extends Result>(
bm: BrowserManager,
body: { command: string; args?: string[]; tabId?: number; session?: string; sessionAuto?: boolean },
tokenInfo: TokenInfo | null | undefined,
fn: () => Promise<R>,
): Promise<Result> {
if (typeof body.tabId === 'number') {
if (bm.hasTab(body.tabId)) return bm.runInTab({ tabId: body.tabId }, fn);
console.warn('[browse] Failed to pin tab', body.tabId, ': not found');
return fn();
}
if (body.session === undefined || body.session === null || body.session === '' || DAEMON_COMMANDS.has(body.command)) return fn();
if (tokenInfo && tokenInfo.clientId !== 'root') {
if (body.sessionAuto) return fn();
return refuse(403, resultLine('browse', 'BROWSE_SESSION_ROOT_ONLY', `client ${tokenInfo.clientId}`));
}
const name = String(body.session);
if (!SESSION_NAME.test(name)) {
return refuse(400, resultLine('browse', 'BROWSE_SESSION_INVALID', JSON.stringify(name.slice(0, 80))));
}
return runInSession(bm, name, body.command, Boolean(body.args?.includes('--json')), fn);
}
+3
View File
@@ -24,6 +24,7 @@ import { TEMP_DIR, isPathWithin } from './platform';
import { escapeEnvelopeSentinels } from './content-security';
import { stripLoneSurrogates } from './sanitize';
import { guardScreenshotPath } from './screenshot-size-guard';
import { reassertDevice } from './device-emulation';
import { SNAPSHOT_FLAGS, type SnapshotOptions } from './snapshot-flags';
export { SNAPSHOT_FLAGS } from './snapshot-flags';
@@ -430,6 +431,7 @@ export async function handleSnapshot(
}, boxes);
await page.screenshot({ path: screenshotPath, fullPage: true });
await reassertDevice(session);
await guardScreenshotPath(screenshotPath);
// Always remove overlays
@@ -560,6 +562,7 @@ export async function handleSnapshot(
}, boxes);
await page.screenshot({ path: heatmapPath, fullPage: true });
await reassertDevice(session);
await guardScreenshotPath(heatmapPath);
// Remove heatmap overlays
+46
View File
@@ -26,6 +26,12 @@ export interface RefEntry {
export type SetContentWaitUntil = 'load' | 'domcontentloaded' | 'networkidle';
/** Phone profile applied by `$B device` (a Playwright `devices` name). */
export interface DeviceProfile {
name: string;
landscape: boolean;
}
export class TabSession {
readonly page: Page;
@@ -63,6 +69,12 @@ export class TabSession {
private loadedHtml: string | null = null;
private loadedHtmlWaitUntil: SetContentWaitUntil | undefined;
// ─── Phone emulation and held touch fingers (device-emulation.ts, touch-commands.ts) ─
private device: DeviceProfile | null = null;
private fingers: Map<number, { x: number; y: number }> = new Map();
private fingerCancel: (() => void) | null = null;
private gestureChain: Promise<unknown> = Promise.resolve();
constructor(page: Page) {
this.page = page;
}
@@ -176,6 +188,40 @@ export class TabSession {
this.activeFrame = null;
this.loadedHtml = null;
this.loadedHtmlWaitUntil = undefined;
this.dropFingers();
}
// ─── Phone emulation and touch ─────────────────────────────
getDevice(): DeviceProfile | null {
return this.device;
}
setDevice(profile: DeviceProfile | null): void {
this.device = profile;
}
/** Held fingers by finger number; touch-commands.ts mutates this map. */
heldFingers(): Map<number, { x: number; y: number }> {
return this.fingers;
}
/** How to tell the browser the held fingers are gone (a CDP touchCancel). */
setFingerCancel(cancel: () => void): void {
this.fingerCancel = cancel;
}
/** Forget held fingers and cancel them in the page. Navigation and device changes call this. */
dropFingers(): void {
if (this.fingers.size === 0) return;
this.fingers.clear();
this.fingerCancel?.();
}
/** Run gestures on this tab one at a time. */
runGesture<T>(fn: () => Promise<T>): Promise<T> {
const next = this.gestureChain.then(fn, fn);
this.gestureChain = next.catch(() => {});
return next;
}
// ─── Loaded HTML (load-html replay) ───────────────────────
+1
View File
@@ -396,6 +396,7 @@ function spawnClaude(cols: number, rows: number, lifecycle: PtyLifecycle) {
BROWSE_STATE_FILE: STATE_FILE,
BROWSE_NO_AUTOSTART: '1',
BROWSE_HEADED: '1',
BROWSE_SESSION: '',
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
};
+4
View File
@@ -56,6 +56,7 @@ export const SCOPE_WRITE = new Set([
'upload', 'viewport', 'newtab', 'closetab',
'dialog-accept', 'dialog-dismiss',
'download', 'scrape', 'archive',
'device', 'tap', 'swipe', 'pinch', 'touch',
]);
/** Page-level power tools — JS execution, credential access, page mutations */
@@ -134,6 +135,9 @@ export function assertValidClientId(clientId: unknown): asserts clientId is stri
if (clientId.trim().toLowerCase() === 'root') {
throw new ReservedClientIdError("clientId 'root' is reserved");
}
if (clientId.trim().toLowerCase().startsWith('session:')) {
throw new ReservedClientIdError("clientId prefix 'session:' is reserved for agent sessions");
}
}
// ─── Types ──────────────────────────────────────────────────────
+185
View File
@@ -0,0 +1,185 @@
/**
* Touch input for phone-emulated tabs (`$B tap|swipe|pinch|touch`, #3058).
* Everything goes through Input.dispatchTouchEvent on the tab's own CDP
* session (the one device-emulation.ts set up), so it needs `$B device` first.
*
* held fingers (touch down 0-9) ── TabSession.heldFingers()
* gesture fingers (tap/swipe/pinch) use ids 10+, added beside held ones
*
* Chromium 151 dispatches an event only for the points it lists: touchStart
* with the new finger, touchMove with the moved ones, touchEnd with the lifted
* one (measured: a touchMove that omits a finger does not release it). Fingers
* not listed stay down, which is what multi-finger chords need. Gestures on one
* tab run one at a time (TabSession.runGesture).
*/
import type { TabSession } from './tab-session';
import { tabCdpSession } from './device-emulation';
import { resultLine } from '../../lib/result-codes';
type Point = { x: number; y: number };
type Points = Map<number, Point>;
const GESTURE_ID = 10;
const STEPS = 10;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function usage(problem: string, fix: string): Error {
return new Error(`Usage: ${problem}\nfix: ${fix}`);
}
async function dispatch(session: TabSession, type: 'touchStart' | 'touchMove' | 'touchEnd', points: Points): Promise<void> {
const cdp = await tabCdpSession(session.getPage());
session.setFingerCancel(() => {
cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }).catch(() => {});
});
const touchPoints = [...points].map(([id, p]) => ({ id, x: p.x, y: p.y, radiusX: 1, radiusY: 1, force: 1 }));
await cdp.send('Input.dispatchTouchEvent', { type, touchPoints });
}
function parseNumber(raw: string | undefined, what: string, fix: string): number {
const n = Number(raw);
if (raw === undefined || raw === '' || !Number.isFinite(n)) throw usage(`${what} must be a number, got "${raw ?? ''}".`, fix);
return n;
}
function flag(args: string[], name: string, fallback: number, fix: string): number {
const i = args.indexOf(name);
if (i === -1) return fallback;
const n = parseNumber(args[i + 1], name, fix);
if (n < 0 || n > 10000) throw usage(`${name} must be between 0 and 10000, got ${n}.`, fix);
args.splice(i, 2);
return n;
}
/** `<@ref|selector|x y>` → a viewport point; element targets scroll into view first. */
async function resolvePoint(session: TabSession, args: string[], fix: string): Promise<Point> {
const [a, b] = args;
if (!a) throw usage('missing target (@ref, selector, or x y).', fix);
if (Number.isFinite(Number(a)) && Number.isFinite(Number(b)) && b !== undefined && b !== '') {
args.splice(0, 2);
return { x: Number(a), y: Number(b) };
}
args.splice(0, 1);
const resolved = await session.resolveRef(a);
const locator = 'locator' in resolved ? resolved.locator : session.getActiveFrameOrPage().locator(resolved.selector).first();
await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
const box = await locator.boundingBox();
if (!box) throw usage(`${a} has no visible box to touch.`, `$B snapshot -i, then target a visible element`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
async function viewportClamp(session: TabSession): Promise<(p: Point) => Point> {
const [w, h] = await session.getPage().evaluate(() => [innerWidth, innerHeight]);
return (p) => ({ x: Math.min(Math.max(p.x, 0), w - 1), y: Math.min(Math.max(p.y, 0), h - 1) });
}
/** Press gesture fingers at `from`, move them to `to` in STEPS moves over `ms`, lift them; held fingers stay down. */
async function stroke(session: TabSession, from: Points, to: Points, ms: number): Promise<void> {
await dispatch(session, 'touchStart', from);
let at: Points = from;
for (let i = 1; i <= STEPS; i++) {
await sleep(ms / STEPS);
at = new Map([...from].map(([id, a]) => {
const b = to.get(id)!;
return [id, { x: a.x + (b.x - a.x) * i / STEPS, y: a.y + (b.y - a.y) * i / STEPS }];
}));
await dispatch(session, 'touchMove', at);
}
await dispatch(session, 'touchEnd', at);
}
const fmt = (p: Point) => `(${Math.round(p.x)}, ${Math.round(p.y)})`;
async function tap(session: TabSession, args: string[]): Promise<string> {
const fix = '$B tap <@ref|selector|x y> [--hold <ms>]';
const hold = flag(args, '--hold', 0, fix);
const point = await resolvePoint(session, args, fix);
const finger: Points = new Map([[GESTURE_ID, point]]);
await dispatch(session, 'touchStart', finger);
if (hold) await sleep(hold);
await dispatch(session, 'touchEnd', finger);
return `Tapped ${fmt(point)}${hold ? ` held ${hold}ms` : ''}`;
}
async function swipe(session: TabSession, args: string[]): Promise<string> {
const fix = '$B swipe <@ref|selector|x y> <up|down|left|right> [--distance <px>] [--ms <n>]';
const distance = flag(args, '--distance', 300, fix);
const ms = flag(args, '--ms', 300, fix);
const start = await resolvePoint(session, args, fix);
const direction = args[0];
const delta: Record<string, Point> = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } };
const unit = direction ? delta[direction] : undefined;
if (!unit || args.length > 1) throw usage(`swipe direction must be up|down|left|right, got "${args.join(' ')}".`, fix);
const clamp = await viewportClamp(session);
const end = clamp({ x: start.x + unit.x * distance, y: start.y + unit.y * distance });
await stroke(session, new Map([[GESTURE_ID, start]]), new Map([[GESTURE_ID, end]]), ms);
const moved = Math.round(Math.hypot(end.x - start.x, end.y - start.y));
return `Swiped ${direction} ${moved}px from ${fmt(start)} in ${ms}ms${moved < distance ? ' (stopped at the viewport edge)' : ''}`;
}
async function pinch(session: TabSession, args: string[]): Promise<string> {
const fix = '$B pinch <@ref|selector|x y> <scale> (2 spreads two fingers apart, 0.5 pinches them together)';
const centre = await resolvePoint(session, args, fix);
const scale = parseNumber(args[0], 'pinch scale', fix);
if (scale <= 0 || scale > 10 || args.length > 1) throw usage(`pinch scale must be one number above 0 and at most 10, got "${args.join(' ')}".`, fix);
const clamp = await viewportClamp(session);
const startHalf = scale >= 1 ? 40 : 40 / scale;
const at = (half: number) => new Map([
[GESTURE_ID, clamp({ x: centre.x - half, y: centre.y })],
[GESTURE_ID + 1, clamp({ x: centre.x + half, y: centre.y })],
]);
await stroke(session, at(startHalf), at(startHalf * scale), 300);
return `Pinched at ${fmt(centre)} to scale ${scale}`;
}
function fingerArg(raw: string | undefined, fix: string): number {
const finger = Number(raw);
if (!Number.isInteger(finger) || finger < 0 || finger > 9) throw usage(`finger must be 0-9, got "${raw ?? ''}".`, fix);
return finger;
}
function listFingers(held: Points): string {
if (held.size === 0) return 'Held fingers: none';
return 'Held fingers:\n' + [...held].map(([f, p]) => ` finger ${f} at ${fmt(p)}`).join('\n');
}
async function touch(session: TabSession, args: string[]): Promise<string> {
const fix = '$B touch down|move <finger 0-9> <x> <y> | $B touch up <finger> | $B touch cancel | $B touch';
const held = session.heldFingers();
const [sub, rawFinger, rawX, rawY] = args;
if (!sub) return listFingers(held);
if (sub === 'cancel') {
if (held.size === 0) return 'No fingers held; nothing to cancel';
const cdp = await tabCdpSession(session.getPage());
await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
held.clear();
return 'Cancelled all held fingers';
}
if (!['down', 'move', 'up'].includes(sub)) throw usage(`unknown touch action "${sub}".`, fix);
const finger = fingerArg(rawFinger, fix);
if (sub === 'down' && held.has(finger)) throw usage(`finger ${finger} is already down.`, `$B touch move ${finger} <x> <y> or $B touch up ${finger}`);
if (sub !== 'down' && !held.has(finger)) {
throw usage(`finger ${finger} is not down (${listFingers(held).toLowerCase()}).`, `$B touch down ${finger} <x> <y>`);
}
if (sub === 'up') {
await dispatch(session, 'touchEnd', new Map([[finger, held.get(finger)!]]));
held.delete(finger);
return `Finger ${finger} up; ${listFingers(held).toLowerCase()}`;
}
const point = { x: parseNumber(rawX, 'x', fix), y: parseNumber(rawY, 'y', fix) };
await dispatch(session, sub === 'down' ? 'touchStart' : 'touchMove', new Map([[finger, point]]));
held.set(finger, point);
return `Finger ${finger} ${sub} at ${fmt(point)}`;
}
export const TOUCH_COMMANDS = new Set(['tap', 'swipe', 'pinch', 'touch']);
export async function handleTouchCommand(command: string, args: string[], session: TabSession): Promise<string> {
if (!session.getDevice()) {
throw new Error(resultLine('browse', 'BROWSE_TOUCH_NO_DEVICE', `$B ${command} on this tab`));
}
const rest = [...args];
const run = { tap, swipe, pinch, touch }[command as 'tap' | 'swipe' | 'pinch' | 'touch'];
return session.runGesture(() => run(session, rest));
}
+6
View File
@@ -21,6 +21,8 @@ import { TEMP_DIR } from './platform';
import { SAFE_DIRECTORIES } from './path-security';
import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector';
import { withCdpSession } from './cdp-bridge';
import { handleDeviceCommand, reassertDevice, refuseWhileDevice } from './device-emulation';
import { TOUCH_COMMANDS, handleTouchCommand } from './touch-commands';
/**
* Aggressive page cleanup selectors and heuristics.
@@ -141,6 +143,8 @@ export async function handleWriteCommand(
// Frame-aware target for locator-based operations (click, fill, etc.)
const target = session.getActiveFrameOrPage();
const inFrame = session.getFrame() !== null;
if (command === 'device') return handleDeviceCommand(args, session);
if (TOUCH_COMMANDS.has(command)) return handleTouchCommand(command, args, session);
switch (command) {
case 'goto': {
@@ -526,6 +530,7 @@ export async function handleWriteCommand(
}
case 'viewport': {
refuseWhileDevice(session, 'viewport');
// Parse args: [<WxH>] [--scale <n>]. Either may be omitted, but NOT both.
let sizeArg: string | undefined;
let scaleArg: number | undefined;
@@ -1126,6 +1131,7 @@ export async function handleWriteCommand(
if (viewportWidth && originalViewport) {
await page.setViewportSize(originalViewport);
}
await reassertDevice(session);
const parts = ['Screenshot saved'];
if (doCleanup) parts.push('(cleaned)');
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Touch fixture</title>
<style>
body { margin: 0; font-family: sans-serif; touch-action: none; }
#carousel, #zoom, #joystick, #fire { position: absolute; display: flex; align-items: center; justify-content: center; }
#carousel { left: 10px; top: 10px; width: 300px; height: 120px; background: #def; }
#zoom { left: 10px; top: 150px; width: 300px; height: 200px; background: #fed; }
#joystick { left: 10px; top: 380px; width: 120px; height: 120px; background: #dfd; border-radius: 60px; }
#fire { left: 200px; top: 400px; width: 80px; height: 80px; background: #fdd; border-radius: 40px; }
</style>
</head>
<body>
<div id="carousel" data-index="0">item 0</div>
<div id="zoom" data-scale="1">zoom 1</div>
<div id="joystick" data-held="false">joystick</div>
<button id="fire" data-shots="0">fire</button>
<script>
window.__log = [];
for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel']) {
document.addEventListener(type, (e) => {
window.__log.push({ type, touches: e.touches.length, changed: e.changedTouches.length, target: e.target.id || e.target.tagName });
}, { passive: false });
}
const carousel = document.getElementById('carousel');
let swipeStart = null;
carousel.addEventListener('touchstart', (e) => { swipeStart = { x: e.touches[0].clientX }; carousel.dataset.lastX = String(swipeStart.x); });
carousel.addEventListener('touchmove', (e) => { carousel.dataset.lastX = String(e.touches[0].clientX); });
carousel.addEventListener('touchend', () => {
if (!swipeStart) return;
const dx = Number(carousel.dataset.lastX) - swipeStart.x;
let index = Number(carousel.dataset.index);
if (dx < -50) index += 1;
if (dx > 50) index -= 1;
carousel.dataset.index = String(index);
carousel.textContent = 'item ' + index;
swipeStart = null;
});
const zoom = document.getElementById('zoom');
let pinchStart = null;
const spread = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
zoom.addEventListener('touchmove', (e) => {
if (e.touches.length < 2) return;
if (pinchStart === null) pinchStart = { distance: spread(e.touches), scale: Number(zoom.dataset.scale) };
const scale = pinchStart.scale * spread(e.touches) / pinchStart.distance;
zoom.dataset.scale = scale.toFixed(2);
zoom.textContent = 'zoom ' + zoom.dataset.scale;
});
zoom.addEventListener('touchend', (e) => { if (e.touches.length < 2) pinchStart = null; });
const joystick = document.getElementById('joystick');
const fire = document.getElementById('fire');
joystick.addEventListener('touchstart', (e) => { joystick.dataset.touchId = String(e.changedTouches[0].identifier); joystick.dataset.held = 'true'; });
document.addEventListener('touchend', (e) => {
for (const t of e.changedTouches) if (String(t.identifier) === joystick.dataset.touchId) joystick.dataset.held = 'false';
});
fire.addEventListener('touchstart', () => { fire.dataset.shots = String(Number(fire.dataset.shots) + 1); });
</script>
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
/**
* Which session a CLI call sends (#1961): precedence, the off switch, and the
* agent-harness walk that gives each agent its own session across fresh
* shells. The live case runs a real process named "claude" on Linux.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { autoSessionName, chooseSession, extractSession, harnessOf, type ProcLookup } from '../src/session-name';
const table = (rows: Record<number, [number, string]>): ProcLookup => (pid) => {
const row = rows[pid];
return row ? { ppid: row[0], comm: row[1] } : null;
};
// 400 bash -> 300 sh -> 200 <harness> -> 1
const under = (harness: string) => table({ 400: [300, 'bash'], 300: [200, 'sh'], 200: [1, harness] });
describe('extractSession', () => {
test('strips --session and its value anywhere in args', () => {
expect(extractSession(['https://x', '--session', 'a1', '--json'])).toEqual({ session: 'a1', args: ['https://x', '--json'] });
expect(extractSession(['text'])).toEqual({ session: undefined, args: ['text'] });
});
});
describe('harness walk', () => {
test('skips shells to the first other ancestor', () => {
expect(harnessOf(400, under('claude'))).toEqual({ pid: 200, comm: 'claude' });
});
test('a known agent harness names the session by its pid', () => {
for (const h of ['claude', 'codex', 'cursor-agent']) expect(autoSessionName({}, 400, under(h))).toBe('agent-200');
});
test('node counts only with CLAUDECODE=1', () => {
expect(autoSessionName({}, 400, under('node'))).toBeUndefined();
expect(autoSessionName({ CLAUDECODE: '1' }, 400, under('node'))).toBe('agent-200');
});
test('a human terminal and a shared harness get no session', () => {
expect(autoSessionName({}, 400, under('tmux: server'))).toBeUndefined();
expect(autoSessionName({ CLAUDECODE: '1' }, 400, under('capy-server'))).toBeUndefined();
expect(autoSessionName({}, 400, () => null)).toBeUndefined();
});
});
describe('chooseSession precedence', () => {
const base = { hasTab: false, flag: undefined, env: {}, parentPid: 400, lookup: under('claude') };
test('an explicit tab wins and sends no session', () => {
expect(chooseSession({ ...base, hasTab: true, flag: 'a' })).toEqual({});
});
test('--session beats BROWSE_SESSION, which beats the harness', () => {
expect(chooseSession({ ...base, flag: 'f', env: { BROWSE_SESSION: 'e' } })).toEqual({ session: 'f' });
expect(chooseSession({ ...base, env: { BROWSE_SESSION: 'e' } })).toEqual({ session: 'e' });
expect(chooseSession(base)).toEqual({ session: 'agent-200', auto: true });
});
test('empty or off turns sessions off', () => {
expect(chooseSession({ ...base, env: { BROWSE_SESSION: '' } })).toEqual({});
expect(chooseSession({ ...base, env: { BROWSE_SESSION: 'off' } })).toEqual({});
expect(chooseSession({ ...base, flag: 'off' })).toEqual({});
});
});
describe.if(process.platform === 'linux')('live /proc walk', () => {
test('two fresh shells under one "claude" process get the same session, named by its pid', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-name-'));
try {
const claude = path.join(dir, 'claude');
fs.copyFileSync('/bin/bash', claude);
fs.chmodSync(claude, 0o755);
const probe = `${JSON.stringify(process.execPath)} -e 'import("${path.resolve(import.meta.dir, '../src/session-name.ts')}").then(m => console.log(m.autoSessionName({}, process.ppid, m.systemProcLookup())))'`;
const r = spawnSync(claude, ['-c', `echo HARNESS=$$; bash -c '${probe.replace(/'/g, `'\\''`)}'; sh -c '${probe.replace(/'/g, `'\\''`)}'; true`], { encoding: 'utf8', timeout: 30_000 });
const harness = r.stdout.match(/HARNESS=(\d+)/)?.[1];
const names = r.stdout.split('\n').filter(l => l.startsWith('agent-'));
expect(harness).toBeDefined();
expect(names).toEqual([`agent-${harness}`, `agent-${harness}`]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('sidebar terminal agent', () => {
test('its claude keeps the user\'s tab: the spawn env turns the automatic session off', () => {
const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/terminal-agent.ts'), 'utf8');
const start = source.indexOf('function spawnClaude');
const spawnEnv = source.slice(start, source.indexOf('--append-system-prompt', start));
expect(spawnEnv).toContain("BROWSE_SESSION: '',");
const lookup: ProcLookup = () => ({ ppid: 0, comm: 'claude' });
expect(chooseSession({ hasTab: false, flag: undefined, env: { BROWSE_SESSION: '' }, parentPid: 7, lookup })).toEqual({});
});
});
describe('CLI retry after a token change', () => {
test('the retried request keeps the --session the caller named', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-session-retry-'));
try {
const stateFile = path.join(dir, 'browse.json');
fs.writeFileSync(stateFile, JSON.stringify({ port: 9, token: 'fresh-token' }));
const script = path.join(dir, 'retry.ts');
fs.writeFileSync(script, `
const bodies = [];
globalThis.fetch = async (_url, init) => {
bodies.push(JSON.parse(init.body));
return bodies.length === 1 ? new Response('Unauthorized', { status: 401 }) : new Response('ok');
};
const { sendCommand } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cli.ts'))});
await sendCommand({ port: 9, token: 'stale-token' }, 'click', ['#delete', '--session', 'intended']);
console.log('BODIES=' + JSON.stringify(bodies));
`);
const r = spawnSync(process.execPath, [script], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, BROWSE_STATE_FILE: stateFile, BROWSE_SESSION: '' } });
const bodies = JSON.parse(r.stdout.match(/BODIES=(.*)/)?.[1] ?? '[]');
expect(bodies).toHaveLength(2);
for (const body of bodies) expect(body).toMatchObject({ command: 'click', args: ['#delete'], session: 'intended' });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('gstack-render browse calls', () => {
test('turn the automatic session off: render pins its own tab ids', () => {
const source = fs.readFileSync(path.resolve(import.meta.dir, '../../lib/aside-render.ts'), 'utf8');
expect(source).toContain("const browseEnv = { ...process.env, BROWSE_SESSION: '' };");
const calls = source.match(/runProc\(bin,[^\n]*/g) ?? [];
expect(calls.length).toBeGreaterThanOrEqual(2);
for (const call of calls) expect(call).toContain('browseEnv');
});
});
+414
View File
@@ -0,0 +1,414 @@
/**
* Per-session tabs (#1961) through the real command dispatch (server.ts
* handleCommandInternal on the module's own BrowserManager). Four sessions
* drive their own tabs at the same time; a slow fixture route forces the
* commands to overlap instead of relying on scheduler timing. The first two
* steps are the before: unsessioned callers share one tab, and the BROWSE_TAB
* pin moved the global active tab for everyone else while it ran. Runs in a
* child process so the module-level server state never touches this process.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
let dir = '';
let steps: Record<string, any> = {};
const SCRIPT = (root: string) => `
import { __testInternals__ } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))};
const fixture = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) {
const u = new URL(req.url);
const s = u.searchParams.get('s') || 'x';
const ms = Number(u.searchParams.get('ms') || 0);
if (ms) await Bun.sleep(ms);
const html = '<!doctype html><title>' + s + '</title><h1>' + s + '</h1><input id="q" aria-label="query">'
+ '<button onclick="document.title = document.title + \\'-clicked\\'">Go ' + s + '</button>';
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
} });
const U = (s, ms = 0) => 'http://127.0.0.1:' + fixture.port + '/page?s=' + s + '&ms=' + ms;
const bm = __testInternals__.browserManager;
const run = (command, args = [], extra = {}, tokenInfo = null) => __testInternals__.handleCommandInternal({ command, args, ...extra }, tokenInfo);
const owned = (owner) => [...bm.pages.keys()].filter(id => bm.getTabOwner(id) === owner);
const steps = {};
await bm.launch();
await run('goto', [U('root')]);
const rootTab = bm.getActiveTabId();
{ // before: two unsessioned agents share the one active tab
const a = run('goto', [U('a', 400)]);
await Bun.sleep(100);
const b = run('goto', [U('b')]);
await Promise.all([a, b]);
steps.shared = { title: (await run('js', ['document.title'])).result };
await run('goto', [U('root')]);
}
{ // BROWSE_TAB pin: an unsessioned caller during the pinned command, and the global tab afterwards
const t2 = JSON.parse((await run('newtab', ['--json'])).result).tabId;
const t3 = JSON.parse((await run('newtab', ['--json'])).result).tabId;
bm.switchTab(rootTab, { bringToFront: false });
const pinned = run('goto', [U('p', 400)], { tabId: t2 });
await Bun.sleep(100);
const during = (await run('url')).result;
await pinned;
const p1 = run('goto', [U('p1', 200)], { tabId: t2 });
await Bun.sleep(100);
const p2 = run('goto', [U('p2', 400)], { tabId: t3 });
await Promise.all([p1, p2]);
steps.pin = { during, activeAfter: bm.getActiveTabId(), rootTab };
await bm.closeTab(t2); await bm.closeTab(t3);
bm.switchTab(rootTab, { bringToFront: false });
}
{ // four sessions at once, three rounds, overlapping on the slow route
const names = ['a', 'b', 'c', 'd'];
const seen = [];
const flow = async (s, round) => {
await run('goto', [U(s, 60 * (names.indexOf(s) + 1) + 40 * round)], { session: s });
const snap = (await run('snapshot', ['-i'], { session: s })).result;
await run('click', ['button'], { session: s });
await run('fill', ['#q', 'hello-' + s], { session: s });
const title = (await run('js', ['document.title'], { session: s })).result;
const value = (await run('js', ["document.querySelector('#q').value"], { session: s })).result;
seen.push({ s, round, snapOk: snap.includes('Go ' + s), title, value });
};
let unsessioned = '';
for (let round = 0; round < 3; round++) {
const all = Promise.all(names.map(s => flow(s, round)));
await Bun.sleep(80);
unsessioned = (await run('url')).result;
await all;
}
steps.sessions = { seen, unsessioned, activeAfter: bm.getActiveTabId(), rootTab,
tabsPer: names.map(s => owned('session:' + s).length) };
}
{ // first use from four parallel commands opens one tab
await Promise.all([1, 2, 3, 4].map(() => run('url', [], { session: 'e' })));
steps.firstUse = { tabs: owned('session:e').length };
}
{ // chain subcommands stay on the session tab
const r = await run('chain', [JSON.stringify([['goto', U('ch', 200)], ['js', 'document.title']])], { session: 'a' });
steps.chain = { result: r.result, rootUrl: (await run('url')).result };
}
{ // per-session newtab and tabs
await run('newtab', [U('n2')], { session: 'c' });
steps.newtab = { title: (await run('js', ['document.title'], { session: 'c' })).result,
tabsC: owned('session:c').length, activeAfter: bm.getActiveTabId(), rootTab,
tabs: (await run('tabs', [], { session: 'c' })).result };
}
{ // a closed session tab: reads fail with the reset code, goto reopens
const id = owned('session:a')[0];
await bm.closeTab(id);
const text = await run('text', [], { session: 'a' });
const goto = await run('goto', [U('a2')], { session: 'a' });
steps.reset = { textStatus: text.status, text: text.result, gotoStatus: goto.status, goto: goto.result,
title: (await run('js', ['document.title'], { session: 'a' })).result };
}
{ // context recreation keeps the session's page
await run('goto', [U('rb')], { session: 'b' });
const ua = await run('useragent', ['Mozilla/5.0 gstack-session-test'], { session: 'b' });
steps.rebuild = { uaStatus: ua.status, url: (await run('url', [], { session: 'b' })).result, tabsB: owned('session:b').length };
}
{ // context recreation under a tab pin keeps every tab and the global active tab
const home = bm.getActiveTabId();
const t = JSON.parse((await run('newtab', [U('pinrb'), '--json'])).result).tabId;
bm.switchTab(home, { bringToFront: false });
const before = bm.pages.size;
const ua = await run('useragent', ['Mozilla/5.0 gstack-pin-test'], { tabId: t });
const pinned = [...bm.pages.entries()].find(([, p]) => p.url().includes('s=pinrb'))?.[0];
steps.pinRebuild = { status: ua.status, result: ua.result, before, after: bm.pages.size, rootUrl: (await run('url')).result,
pinnedUrl: pinned === undefined ? null : (await run('url', [], { tabId: pinned })).result };
if (pinned !== undefined) await bm.closeTab(pinned);
}
{ // only root may name a session; names are validated
const scoped = { token: 't', clientId: 'agent-x', type: 'session', scopes: ['read', 'write', 'admin', 'meta'], tabPolicy: 'shared', rateLimit: 0, createdAt: new Date().toISOString(), commandCount: 0 };
const denied = await run('url', [], { session: 'z' }, scoped);
const bad = await run('url', [], { session: 'bad name!' });
steps.auth = { deniedStatus: denied.status, denied: denied.result, zTabs: owned('session:z').length, badStatus: bad.status, bad: bad.result };
}
{ // idle sessions are closed on the next session lookup
process.env.BROWSE_SESSION_IDLE_MINUTES = '0.01';
await Bun.sleep(1200);
await run('url', [], { session: 'fresh' });
steps.sweep = { a: owned('session:a').length, d: owned('session:d').length, fresh: owned('session:fresh').length };
}
{ // a session the CLI derived on its own is ignored for a scoped token
const scoped = { token: 't', clientId: 'agent-y', type: 'session', scopes: ['read', 'write', 'admin', 'meta'], tabPolicy: 'shared', rateLimit: 0, createdAt: new Date().toISOString(), commandCount: 0 };
const auto = await run('url', [], { session: 'auto1', sessionAuto: true }, scoped);
steps.auto = { status: auto.status, tabs: owned('session:auto1').length };
}
{ // the session cap: refused while every other session is busy, LRU eviction once one is idle
process.env.BROWSE_SESSION_IDLE_MINUTES = '0';
process.env.BROWSE_MAX_SESSIONS = '1';
const busy = run('goto', [U('busy', 600)], { session: 'fresh' });
await Bun.sleep(150);
const limited = await run('url', [], { session: 'limit' });
await busy;
const freshTab = owned('session:fresh')[0];
const evicted = await run('url', [], { session: 'limit2' });
steps.limit = { status: limited.status, result: limited.result, limitTabs: owned('session:limit').length,
evictedStatus: evicted.status, fresh: owned('session:fresh').length, freshOpen: bm.pages.has(freshTab),
limit2: owned('session:limit2').length };
process.env.BROWSE_MAX_SESSIONS = '16';
const victim = await run('url', [], { session: 'fresh' });
steps.evictedRead = { status: victim.status, result: victim.result, tabs: owned('session:fresh').length };
await run('goto', [U('back')], { session: 'fresh' });
steps.evictedRead.reopened = (await run('js', ['document.title'], { session: 'fresh' })).result;
process.env.BROWSE_MAX_SESSIONS = '1';
}
{ // in headed mode an evicted session's tab is released to the user, not closed
const tab = owned('session:limit2')[0];
const mode = bm.getConnectionMode;
bm.getConnectionMode = () => 'headed';
try { await run('url', [], { session: 'limit3' }); } finally { bm.getConnectionMode = mode; }
steps.headed = { open: bm.pages.has(tab), owner: bm.getTabOwner(tab) ?? null, limit2: owned('session:limit2').length };
}
{ // tab <id> inside a session sticks for the session's next command
await run('goto', [U('mine')], { session: 'pick' });
const other = JSON.parse((await run('newtab', [U('theirs'), '--json'])).result).tabId;
const switched = await run('tab', [String(other)], { session: 'pick' });
const next = (await run('js', ['document.title'], { session: 'pick' })).result;
steps.pick = { switched: switched.status, next, owner: bm.getTabOwner(other) ?? null };
}
{ // another session's context rebuild leaves this session on the tab it picked
process.env.BROWSE_MAX_SESSIONS = '16';
await run('goto', [U('first')], { session: 'two' });
const first = owned('session:two')[0];
await run('newtab', [U('second')], { session: 'two' });
await run('tab', [String(first)], { session: 'two' });
const ua = await run('useragent', ['Mozilla/5.0 gstack-other-rebuild'], { session: 'other' });
steps.otherRebuild = { ua: ua.status, title: (await run('js', ['document.title'], { session: 'two' })).result };
}
{ // a first-use notice never prefixes --json output
const r = await run('newtab', [U('j'), '--json'], { session: 'jsonfirst' });
let parsed = null;
try { parsed = JSON.parse(r.result); } catch {}
steps.jsonFirst = { status: r.status, parsed: parsed !== null && typeof parsed.tabId === 'number' };
}
{ // headed mode: a session's new tab is registered once and the user's active tab stays put
const ctx = bm.context;
const listener = (page) => {
const id = bm.nextTabId++;
bm.pages.set(id, page);
bm.tabSessions.set(id, new (bm.tabSessions.values().next().value.constructor)(page));
bm.activeTabId = id;
};
ctx.on('page', listener);
const userTab = bm.getActiveTabId();
const before = bm.pages.size;
try { await run('url', [], { session: 'headednew' }); } finally { ctx.off('page', listener); }
const tab = owned('session:headednew')[0];
steps.headedNew = { activeKept: bm.getActiveTabId() === userTab, added: bm.pages.size - before,
sameObject: [...bm.pages.values()].filter(p => p === bm.pages.get(tab)).length };
}
{ // headed viewport auto under a tab pin rebuilds the pinned tab, not the global one
const userTab = bm.getActiveTabId();
const userPage = bm.pages.get(userTab);
const pinned = JSON.parse((await run('newtab', [U('vp'), '--json'])).result).tabId;
bm.switchTab(userTab, { bringToFront: false });
const mode = bm.connectionMode;
const listener = (page) => {
const id = bm.nextTabId++;
bm.pages.set(id, page);
bm.tabSessions.set(id, new (bm.tabSessions.values().next().value.constructor)(page));
bm.activeTabId = id;
};
bm.context.on('page', listener);
bm.connectionMode = 'headed';
let r;
try { r = await run('viewport', ['auto'], { tabId: pinned }); } finally { bm.connectionMode = mode; bm.context.off('page', listener); }
const pinnedPage = bm.pages.get(pinned);
steps.headedViewport = { status: r.status, userTracked: bm.pages.get(userTab) === userPage,
pinnedLive: bm.pages.has(pinned), pinnedUrl: pinnedPage?.url() ?? null, activeKept: bm.getActiveTabId() === userTab,
sameObject: [...bm.pages.values()].filter(p => p === pinnedPage).length, rootUrl: (await run('url')).result };
}
{ // state save and load inside a session land the session on its own saved page
await run('goto', [U('st')], { session: 'st' });
const save = await run('state', ['save', 'gsta227-session'], { session: 'st' });
const load = await run('state', ['load', 'gsta227-session'], { session: 'st' });
const after = await run('js', ['document.title'], { session: 'st' });
steps.stateLoad = { save: save.status, load: load.status, loadText: load.result, afterStatus: after.status, title: after.result,
rootStatus: (await run('url')).status };
}
{ // an unsessioned tab switch made while a session opens a tab survives the open; closing a pinned tab that is also the global tab moves the global tab
const base = bm.getActiveTabId();
const other = JSON.parse((await run('newtab', ['--json'])).result).tabId;
bm.switchTab(base, { bringToFront: false });
const ctx = bm.context;
const newPage = ctx.newPage;
let release, entered;
const gate = new Promise(r => { release = r; });
const inside = new Promise(r => { entered = r; });
ctx.newPage = async (...a) => { entered(); await gate; return newPage.apply(ctx, a); };
const opening = run('newtab', [], { session: 'race' });
await inside;
bm.switchTab(other, { bringToFront: false });
release();
await opening;
ctx.newPage = newPage;
const raceActive = bm.activeTabId;
bm.switchTab(other, { bringToFront: false });
const closed = await run('closetab', [], { tabId: other });
steps.race = { raceActive, other, closed: closed.status, activeLive: bm.pages.has(bm.activeTabId) };
}
await Bun.write(${JSON.stringify(path.join(root, 'steps.json'))}, JSON.stringify(steps));
fixture.stop(true);
await Promise.race([bm.close(), Bun.sleep(3000)]);
process.exit(0);
`;
beforeAll(() => {
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'browse-session-tabs-')));
fs.writeFileSync(path.join(dir, 'drive.ts'), SCRIPT(dir));
const r = spawnSync(process.execPath, [path.join(dir, 'drive.ts')], {
encoding: 'utf8', timeout: 150_000,
env: { ...process.env, GSTACK_HOME: path.join(dir, 'state'), CHROMIUM_PROFILE: path.join(dir, 'profile'),
BROWSE_STATE_FILE: path.join(dir, 'state', 'browse.json'), BROWSE_PARENT_PID: '0' },
});
if (!fs.existsSync(path.join(dir, 'steps.json'))) throw new Error(`driver failed (${r.status}): ${r.stderr.slice(-2000)}`);
steps = JSON.parse(fs.readFileSync(path.join(dir, 'steps.json'), 'utf8'));
}, 180_000);
afterAll(() => { if (dir) fs.rmSync(dir, { recursive: true, force: true }); });
describe('before: callers without a session share one tab', () => {
test('the second agent navigates the first agent away (negative control)', () => {
expect(steps.shared.title).toBe('b');
});
});
describe('a pinned tab no longer moves the global active tab', () => {
test('an unsessioned caller during a pinned command still sees its own tab', () => {
expect(steps.pin.during).toContain('s=root');
});
test('overlapping pinned commands leave the global active tab where it was', () => {
expect(steps.pin.activeAfter).toBe(steps.pin.rootTab);
});
});
describe('four sessions at once (#1961)', () => {
test('every session only ever sees its own page', () => {
expect(steps.sessions.seen).toHaveLength(12);
for (const row of steps.sessions.seen) {
expect(row).toMatchObject({ snapOk: true, title: row.s + '-clicked', value: 'hello-' + row.s });
}
});
test('each session owns one tab and the global active tab is untouched', () => {
expect(steps.sessions.tabsPer).toEqual([1, 1, 1, 1]);
expect(steps.sessions.activeAfter).toBe(steps.sessions.rootTab);
expect(steps.sessions.unsessioned).toContain('s=root');
});
test('parallel first commands of one session open a single tab', () => {
expect(steps.firstUse.tabs).toBe(1);
});
test('chain subcommands run on the session tab', () => {
expect(steps.chain.result).toContain('ch');
expect(steps.chain.rootUrl).toContain('s=root');
});
test('newtab inside a session moves only that session', () => {
expect(steps.newtab).toMatchObject({ title: 'n2', tabsC: 2 });
expect(steps.newtab.activeAfter).toBe(steps.newtab.rootTab);
expect(steps.newtab.tabs).toContain('session:c');
});
});
describe('session lifecycle', () => {
test('an unsessioned tab switch during a session newtab is kept', () => {
expect(steps.race.raceActive).toBe(steps.race.other);
});
test('closing a pinned tab that is the global active tab moves the global tab to a live one', () => {
expect(steps.race).toMatchObject({ closed: 200, activeLive: true });
});
test('a read on a closed session tab fails with the last URL; goto reopens it', () => {
expect(steps.reset.textStatus).not.toBe(200);
expect(steps.reset.text).toContain('BROWSE_SESSION_TAB_RESET');
expect(steps.reset.text).toContain('s=ch');
expect(steps.reset.gotoStatus).toBe(200);
expect(steps.reset.title).toBe('a2');
});
test('context recreation keeps the session on its page', () => {
expect(steps.rebuild.uaStatus).toBe(200);
expect(steps.rebuild.url).toContain('s=rb');
expect(steps.rebuild.tabsB).toBe(1);
});
test('context recreation under a tab pin keeps every tab', () => {
expect(steps.pinRebuild.status).toBe(200);
expect(steps.pinRebuild.result).not.toContain('Browser reset');
expect(steps.pinRebuild.after).toBe(steps.pinRebuild.before);
expect(steps.pinRebuild.rootUrl).toContain('s=root');
expect(steps.pinRebuild.pinnedUrl).toContain('s=pinrb');
});
test('a scoped token cannot name a session and opens no tab', () => {
expect(steps.auth.deniedStatus).toBe(403);
expect(steps.auth.denied).toContain('BROWSE_SESSION_ROOT_ONLY');
expect(steps.auth.zTabs).toBe(0);
});
test('an invalid session name is refused', () => {
expect(steps.auth.badStatus).toBe(400);
expect(steps.auth.bad).toContain('BROWSE_SESSION_INVALID');
});
test('idle sessions are closed on the next lookup', () => {
expect(steps.sweep).toEqual({ a: 0, d: 0, fresh: 1 });
});
test('an automatic session from a scoped token falls through and opens no tab', () => {
expect(steps.auto).toEqual({ status: 200, tabs: 0 });
});
test('at the cap a new session is refused while the others are busy', () => {
expect(steps.limit.status).toBe(429);
expect(steps.limit.result).toContain('BROWSE_SESSION_LIMIT');
expect(steps.limit.limitTabs).toBe(0);
});
test('at the cap the least recently used idle session is evicted', () => {
expect(steps.limit).toMatchObject({ evictedStatus: 200, fresh: 0, freshOpen: false, limit2: 1 });
});
test('tab <id> in a session moves that session to the tab for its next command', () => {
expect(steps.pick).toEqual({ switched: 200, next: 'theirs', owner: null });
});
test('a session evicted at the cap reports the reset on its next read; goto reopens it', () => {
expect(steps.evictedRead.status).toBe(409);
expect(steps.evictedRead.result).toContain('BROWSE_SESSION_TAB_RESET');
expect(steps.evictedRead.result).toContain('s=busy');
expect(steps.evictedRead.tabs).toBe(0);
expect(steps.evictedRead.reopened).toBe('back');
});
test('another session\'s context rebuild keeps this session on the tab it picked', () => {
expect(steps.otherRebuild).toEqual({ ua: 200, title: 'first' });
});
test('the first-use notice never corrupts --json output', () => {
expect(steps.jsonFirst).toEqual({ status: 200, parsed: true });
});
test('headed: a session tab is registered once and the user keeps the active tab', () => {
expect(steps.headedNew).toEqual({ activeKept: true, added: 1, sameObject: 1 });
});
test('headed: viewport auto under a tab pin rebuilds only the pinned tab', () => {
expect(steps.headedViewport).toMatchObject({ status: 200, userTracked: true, pinnedLive: true, activeKept: true, sameObject: 1 });
expect(steps.headedViewport.rootUrl).not.toContain('s=vp');
expect(steps.headedViewport.pinnedUrl).toContain('s=vp');
});
test('state save and load inside a session leave it on its saved page', () => {
expect(steps.stateLoad).toMatchObject({ save: 200, load: 200, afterStatus: 200, title: 'st', rootStatus: 200 });
});
test('in headed mode an evicted session keeps its tab open and unowned', () => {
expect(steps.headed).toEqual({ open: true, owner: null, limit2: 0 });
});
});
+5
View File
@@ -30,6 +30,11 @@ describe('token-registry', () => {
expect(() => createToken({ clientId: ' root ' })).toThrow(ReservedClientIdError);
});
it('createToken rejects the session: prefix agent sessions own tabs under', () => {
expect(() => createToken({ clientId: 'session:agent-1234' })).toThrow(ReservedClientIdError);
expect(() => createToken({ clientId: 'Session:x' })).toThrow(ReservedClientIdError);
});
it('createToken rejects empty / whitespace clientId', () => {
expect(() => createToken({ clientId: '' })).toThrow(ReservedClientIdError);
expect(() => createToken({ clientId: ' ' })).toThrow(ReservedClientIdError);
+257
View File
@@ -0,0 +1,257 @@
/**
* Phone emulation and touch input on gstack's own Chromium (#3058, GSTA-227 F3).
*
* Drives a real in-process BrowserManager (same harness as snapshot.test.ts)
* against browse/test/fixtures/touch.html: a touch logger, a swipe carousel,
* a two-finger zoom surface and a joystick plus fire button.
*/
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
import { handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
import { WRITE_COMMANDS, COMMAND_DESCRIPTIONS } from '../src/commands';
import { SCOPE_WRITE } from '../src/token-registry';
import { TUNNEL_COMMANDS } from '../src/routes/commands';
const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE;
let CHROMIUM_PROFILE_DIR: string | undefined;
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
let baseUrl: string;
const shutdown = async () => {};
const VERBS = ['device', 'tap', 'swipe', 'pinch', 'touch'];
setDefaultTimeout(20_000);
beforeAll(async () => {
CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-'));
process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR;
testServer = startTestServer(0);
baseUrl = testServer.url;
bm = new BrowserManager();
await bm.launch();
});
afterAll(async () => {
try { testServer.server.stop(true); } catch {}
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE;
if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} }
});
const run = (cmd: string, args: string[] = []) => handleWriteCommand(cmd, args, bm.getActiveSession(), bm);
const page = () => bm.getActiveSession().getPage();
const probe = () => page().evaluate(() => ({
coarse: matchMedia('(pointer: coarse)').matches,
maxTouchPoints: navigator.maxTouchPoints,
ua: navigator.userAgent,
width: innerWidth,
}));
const data = (sel: string, key: string) => page().evaluate(([s, k]) => (document.querySelector(s) as HTMLElement).dataset[k], [sel, key]);
async function freshTouchTab(device: string | null = 'iPhone 15'): Promise<number> {
const id = await bm.newTab(baseUrl + '/touch.html');
bm.switchTab(id, { bringToFront: false });
if (device) await run('device', [device]);
await page().reload({ waitUntil: 'domcontentloaded' });
return id;
}
describe('device and touch: registration', () => {
test('verbs are write commands with descriptions, in the write scope, never on the tunnel', () => {
for (const verb of VERBS) {
expect(WRITE_COMMANDS.has(verb)).toBe(true);
expect(SCOPE_WRITE.has(verb)).toBe(true);
expect(TUNNEL_COMMANDS.has(verb)).toBe(false);
expect(COMMAND_DESCRIPTIONS[verb]?.usage).toContain(verb);
}
});
});
describe('device', () => {
test('unknown name suggests the nearest names and leaves the tab alone', async () => {
await freshTouchTab(null);
const before = await probe();
let message = '';
try { await run('device', ['iPhone 99']); } catch (err: any) { message = err.message; }
expect(message).toContain('BROWSE_DEVICE_UNKNOWN');
expect(message).toContain('iPhone 99');
expect(message).toContain('fix: $B device --list');
expect(message.match(/iPhone \d/g)?.length ?? 0).toBeGreaterThanOrEqual(3);
expect(await probe()).toEqual(before);
expect(await run('device')).toContain('none');
});
test('--list prints Playwright device names', async () => {
const list = await run('device', ['--list']);
expect(list).toContain('iPhone 15');
expect(list).toContain('Pixel 7');
});
test('emulates the phone on this tab only; device off restores', async () => {
const desktopId = await freshTouchTab(null);
const desktop = await probe();
await freshTouchTab('iphone 15');
const phone = await probe();
expect(phone.coarse).toBe(true);
expect(phone.maxTouchPoints).toBe(5);
expect(phone.ua).toContain('iPhone');
expect(phone.width).toBe(393);
expect(await run('device')).toContain('iPhone 15');
const other = bm.getSession(desktopId).getPage();
const otherNow = await other.evaluate(() => ({ coarse: matchMedia('(pointer: coarse)').matches, maxTouchPoints: navigator.maxTouchPoints, ua: navigator.userAgent, width: innerWidth }));
expect(otherNow).toEqual(desktop);
expect(otherNow.ua).not.toContain('iPhone');
await run('device', ['off']);
await page().reload({ waitUntil: 'domcontentloaded' });
expect(await probe()).toEqual(desktop);
expect(await run('device')).toContain('none');
});
test('--landscape swaps the orientation', async () => {
await freshTouchTab(null);
await run('device', ['Pixel 7', '--landscape']);
const p = await page().evaluate(() => ({ w: innerWidth, h: innerHeight }));
expect(p.w).toBeGreaterThan(p.h);
await run('device', ['off']);
});
test('viewport and responsive refuse while a device is active', async () => {
await freshTouchTab();
await expect(run('viewport', ['800x600'])).rejects.toThrow('fix: $B device off');
await expect(handleMetaCommand('responsive', [], bm, shutdown)).rejects.toThrow('fix: $B device off');
expect((await probe()).width).toBe(393);
await run('device', ['off']);
});
test('screenshots capture the phone and keep its metrics', async () => {
await freshTouchTab();
const out = path.join(os.tmpdir(), `touch-shot-${process.pid}.png`);
for (const extra of [[], ['--viewport'], ['#zoom'], ['--clip', '0,0,50,50']]) {
await handleMetaCommand('screenshot', [...extra, out], bm, shutdown);
const png = fs.readFileSync(out);
if (extra.length === 0 || extra[0] === '--viewport') expect(png.readUInt32BE(16)).toBe(393 * 3);
const p = await probe();
expect({ extra, width: p.width, coarse: p.coarse, touch: p.maxTouchPoints }).toEqual({ extra, width: 393, coarse: true, touch: 5 });
}
fs.rmSync(out, { force: true });
await run('device', ['off']);
});
test('annotated and heatmap snapshots and prettyscreenshot keep the phone', async () => {
await freshTouchTab();
const out = path.join(os.tmpdir(), `touch-annotate-${process.pid}.png`);
const captures: Array<[string, string[]]> = [
['snapshot', ['-i', '-a', '-o', out]],
['snapshot', ['-i', '-H', '{"@e1":"green"}', '-o', out]],
['prettyscreenshot', [out]],
];
for (const [cmd, args] of captures) {
if (cmd === 'prettyscreenshot') await run(cmd, args);
else await handleMetaCommand(cmd, args, bm, shutdown);
const p = await probe();
expect({ cmd, args, width: p.width, coarse: p.coarse }).toEqual({ cmd, args, width: 393, coarse: true });
}
fs.rmSync(out, { force: true });
await run('device', ['off']);
});
test('survives a useragent context rebuild; the first restored request carries the phone UA', async () => {
const id = await bm.newTab(baseUrl + '/echo');
bm.switchTab(id, { bringToFront: false });
await run('device', ['iPhone 15']);
await run('useragent', ['GstackDesktop/1.0']);
expect(await run('device')).toContain('iPhone 15');
const echoed = JSON.parse(await page().evaluate(() => document.body.innerText));
expect(echoed['user-agent']).toContain('iPhone');
expect((await probe()).coarse).toBe(true);
await run('device', ['off']);
});
});
describe('touch input', () => {
test('touch verbs need an active device', async () => {
await freshTouchTab(null);
await expect(run('tap', ['#fire'])).rejects.toThrow('BROWSE_TOUCH_NO_DEVICE');
await expect(run('touch', ['down', '0', '10', '10'])).rejects.toThrow('fix: $B device "iPhone 15"');
});
test('tap fires touchstart and touchend', async () => {
await freshTouchTab();
await run('tap', ['#fire']);
expect(await data('#fire', 'shots')).toBe('1');
const types = await page().evaluate(() => (window as any).__log.map((e: any) => e.type));
expect(types).toContain('touchstart');
expect(types).toContain('touchend');
await run('tap', ['240', '440', '--hold', '50']);
expect(await data('#fire', 'shots')).toBe('2');
});
test('swipe left advances the carousel; right goes back', async () => {
await freshTouchTab();
await run('swipe', ['#carousel', 'left', '--distance', '200']);
expect(await data('#carousel', 'index')).toBe('1');
await run('swipe', ['#carousel', 'right', '--distance', '200', '--ms', '100']);
expect(await data('#carousel', 'index')).toBe('0');
});
test('pinch spreads above 1 and pinches below 1', async () => {
await freshTouchTab();
await run('pinch', ['#zoom', '2']);
const spread = Number(await data('#zoom', 'scale'));
expect(spread).toBeGreaterThan(1.5);
await run('pinch', ['#zoom', '0.5']);
expect(Number(await data('#zoom', 'scale'))).toBeLessThan(spread * 0.7);
});
test('holding finger 0 while tapping finger 1 leaves finger 0 down', async () => {
await freshTouchTab();
await run('touch', ['down', '0', '70', '440']);
await run('touch', ['down', '1', '240', '440']);
await run('touch', ['up', '1']);
expect(await data('#fire', 'shots')).toBe('1');
expect(await data('#joystick', 'held')).toBe('true');
expect(await page().evaluate(() => (window as any).__log.at(-1).touches)).toBe(1);
const held = await run('touch');
expect(held).toContain('0');
expect(held).not.toMatch(/\b1\b.*\(/);
await run('touch', ['move', '0', '90', '440']);
await run('touch', ['up', '0']);
expect(await data('#joystick', 'held')).toBe('false');
expect(await run('touch')).toContain('none');
});
test('usage errors name the problem and give a fix', async () => {
await freshTouchTab();
const message = (cmd: string, args: string[]) => run(cmd, args).then(() => '', (err: Error) => err.message);
expect(await message('touch', ['up', '3'])).toMatch(/finger 3 is not down[\s\S]*fix:/);
expect(await message('touch', ['down', '12', '1', '1'])).toMatch(/0-9[\s\S]*fix:/);
expect(await message('swipe', ['#carousel', 'sideways'])).toMatch(/up\|down\|left\|right[\s\S]*fix:/);
expect(await message('pinch', ['#zoom', '-1'])).toMatch(/scale[\s\S]*fix:/);
});
test('held fingers are cancelled on navigation and by touch cancel', async () => {
await freshTouchTab();
await run('touch', ['down', '0', '70', '440']);
expect(await run('touch')).toContain('0');
await page().goto(baseUrl + '/touch.html', { waitUntil: 'domcontentloaded' });
expect(await run('touch')).toContain('none');
await run('touch', ['down', '2', '240', '440']);
expect(await data('#fire', 'shots')).toBe('1');
await run('touch', ['cancel']);
expect(await run('touch')).toContain('none');
expect(await page().evaluate(() => (window as any).__log.at(-1).type)).toBe('touchcancel');
});
test('touch cancel with no finger held is a no-op, not a protocol error', async () => {
await freshTouchTab();
expect(await run('touch', ['cancel'])).toContain('nothing to cancel');
});
});
@@ -42,6 +42,8 @@ export interface BrowseClientOptions {
token?: string;
/** Tab id to target (every command can scope to a tab). Default: BROWSE_TAB env or undefined (active tab). */
tabId?: number;
/** Session whose own tab runs every command (root token only; tabId wins when both are set). Default: none. */
session?: string;
/** Per-request timeout in milliseconds. Default: 30_000. */
timeoutMs?: number;
/** Override state-file path. Default: BROWSE_STATE_FILE env or <git-root>/.gstack/browse.json. */
@@ -133,6 +135,7 @@ export class BrowseClient {
readonly port: number;
readonly token: string;
readonly tabId?: number;
readonly session?: string;
readonly timeoutMs: number;
constructor(opts: BrowseClientOptions = {}) {
@@ -140,6 +143,7 @@ export class BrowseClient {
this.port = auth.port;
this.token = auth.token;
this.tabId = opts.tabId ?? parseIntegerEnvValue(process.env.BROWSE_TAB);
this.session = opts.session;
this.timeoutMs = opts.timeoutMs ?? 30_000;
}
@@ -150,7 +154,7 @@ export class BrowseClient {
const body = JSON.stringify({
command: cmd,
args,
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : {}),
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : this.session ? { session: this.session } : {}),
});
let resp: Response;
+1 -1
View File
@@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation
Token caps on /ship: pass a brief's cap as `--token-cap <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam id>" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading <n> --credits <c>`, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`).
/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '<launch file>' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "<launch file>", cwd: "<repo>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: "<RANGE_BASE>"`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events <file>` on your jam's transcript since /ship began. A small waiting child saves nothing.
/ship on Capy is about a dozen calls, each an execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '<launch file>' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "<launch file>", cwd: "<repo>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family, pinned in `voice-models.json`); Step 11 swaps `logStart` for `stage: "voices"`; a full repeat pass, `diffBase: "<RANGE_BASE>"`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (14.5 once). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Else re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events <file>` on your transcript since /ship began. A small waiting child saves nothing.
## Plan, approve, ship
+11
View File
@@ -331,6 +331,17 @@ console.log("ASIDE_DIR=" + pwd); await closeTab(pg); console.log("GSTACK_STEP_OK
'
```
**Touch (verified in Aside 1.26; add the Responsive override for phone size):**
```bash
aside repl '
const pg = await openTab("<url>"), t = (m, p) => pg._sendToTarget(m, p);
await t("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
for (const [type, touchPoints] of [["touchStart", [{ x: <tap-x>, y: <tap-y> }]], ["touchEnd", []]]) await t("Input.dispatchTouchEvent", { type, touchPoints });
await closeTab(pg); console.log("GSTACK_STEP_OK");
'
```
**Links and their status (same-origin; on a LOCAL target each link is HEAD-checked, on a real site the user's cookies would ride every request so links are listed as `LINK ?` unfetched — consent to LOOK is not consent to hit every URL):**
```bash
+1 -1
View File
@@ -94,7 +94,7 @@ gstack/
│ ├── test/ # Integration tests
│ └── dist/ # Compiled binary
├── agents-digest/ # Committed 2KB instruction-tier rules digest (gstack-AGENTS.md) for rules-reading hosts
├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, pinned modules and named loadouts; read by scripts/modules.ts, rendered by bin/gstack-modules (docs/loadouts.md)
├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, the model map, pinned modules and named loadouts; read by scripts/modules.ts and lib/model-detect.ts, rendered by bin/gstack-modules (docs/loadouts.md)
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; design-catalog.ts = the typed design anti-pattern catalog every design skill renders from; design-lint/ = the static and render tiers behind bin/gstack-design-lint, ported from hallmark's sloplint; design-detect-contract.ts = detector sentinel vocabulary; design-md.ts = open DESIGN.md reader/writer; dom-dump-script.ts + generated dom-dump.js = rendered-DOM dump for the detector; review-evidence.ts = review-start receipt binding, computed freshness and shared-code snapshot eligibility; frontend-scope.ts; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/)
│ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads
+1 -1
View File
@@ -154,7 +154,7 @@ CSS selectors. Always `snapshot -i` first, then use the refs.
Paired agents get `read+write+admin+meta` by default; the pairing ceremony is the trust boundary. `--restrict` narrows the list (it can never grant `control`). `--control` adds the control scope (`--admin` is a legacy alias). Over the tunnel, the `js`/`cookies`/`storage` commands are blocked by the command allowlist regardless of scope; `eval` works. Pair with `--restrict "read,write"` when the agent will read untrusted web content — scope caps the prompt-injection blast radius.
To tighten an already-paired agent, re-pair it with the **same `--client` name** and the narrower `--restrict`/`--domain`: a reducing re-pair revokes the previous session and releases its tabs immediately (the agent must reconnect with the new key), so the old wide access never lingers. Broadening or refreshing keeps the working session with no outage. Re-pairing without `--client` mints a new agent instead. `root` is a reserved client name.
To tighten an already-paired agent, re-pair it with the **same `--client` name** and the narrower `--restrict`/`--domain`: a reducing re-pair revokes the previous session and releases its tabs immediately (the agent must reconnect with the new key), so the old wide access never lingers. Broadening or refreshing keeps the working session with no outage. Re-pairing without `--client` mints a new agent instead. `root` and names starting with `session:` (kept for local agent sessions) are reserved client names.
## Tab Isolation
+38 -17
View File
@@ -195,15 +195,18 @@ fallback; unknown files get 99th-percentile pessimism, and both full-suite and
the long pole. Packed
shards get duration-aware walls (`max(base, predicted × 3, files × 5s)`). The
legacy `--shards N --shard i` path keeps stable hash indices. Required CI uses
one duration-packed `--ci-plan`, 20 ordinary `--ci-run` shards plus a separate
one duration-packed `--ci-plan`, 10 ordinary `--ci-run` shards plus a separate
exclusive-fixture shard, and a `--ci-verify` aggregate. CI shards still run on
independent machines without ordering unrelated jobs. Two workflow-level
independent machines without ordering unrelated jobs. Three workflow-level
constants in `free-tests.yml` set that size for every event: `FREE_SUITE_RUNNER`
(`ubicloud-standard-4`) and `FREE_SUITE_SHARDS` (`'20'`), which `free-plan`
resolves into the shards' runner and shard count. A `workflow_dispatch` run may
override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`) and
`free_shards` (1–40) for runner-sizing experiments only; its job summary says
so, and it is never cited as a PR or train Free Tests result. Each shard prints
(`ubicloud-standard-8`), `FREE_SUITE_SHARDS` (`'10'`) and `FREE_SUITE_JOBS`
(`'4'`, the [sub-shards](#sub-shards-inside-one-ci-job) each shard runs side by
side), which `free-plan` resolves into the shards' runner, shard count and plan.
GSTA-203 chose them by a 3-run A/B against 20 × `ubicloud-standard-4` at k = 1
(the PR that set them has the table). A `workflow_dispatch` run may
override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`),
`free_shards` (1–40) and `free_jobs` (1–8) for runner-sizing experiments only;
its job summary says so, and it is never cited as a PR or train Free Tests result. Each shard prints
`RESOURCES: peak_used_mib=… min_free_disk_mib=… samples=…` from a memory and
disk sample every 2 seconds, even when the suite fails. The public
`TREE_MUTATING` map now classifies exclusive host-state fixtures; its sole
@@ -1553,7 +1556,7 @@ refresh to a maintainer.
`scripts/ubicloud/setup-free-suite.sh` and the Windows seed through
`windows-free-tests.yml`'s `record_durations` path. The recorder stays on
`ubicloud-standard-8` while the free-suite shards run on `FREE_SUITE_RUNNER`
(`ubicloud-standard-4`); both are Ubicloud standard runners. When either recording adds
(`ubicloud-standard-8`); both are Ubicloud standard runners. When either recording adds
or drops a test file, it force-updates the `bot/free-duration-seeds` branch,
opens or refreshes one seed PR whose body states the ratchet result, and
dispatches Free Tests, Windows Smoke and Quality gate on it. Those
@@ -1575,14 +1578,24 @@ k sub-shards side by side on the same machine (`scripts/lib/shard-jobs.ts`). Eac
sub-shard is an ordinary free shard child: one `bun test` process, serial
inside, with its own sandbox, process-group kill and log. Bun's worker
`--parallel` mode is still not used (decision V3/D6 in
`scripts/test-free-shards.ts`). The default is `k = 1`, which runs today's one
child, so no workflow changes behavior until it passes `--jobs`.
`scripts/test-free-shards.ts`). Without `--jobs`, a shard runs at the plan's
per-shard k (below); a shard with no planned k runs one child (`k = 1`).
- `--jobs` applies only to `--ci-run`. It takes a positive whole number up to the
shard's file count (an empty shard takes 1); anything else stops with
`FREE_JOBS_INVALID`. `GSTACK_FREE_JOBS` sets the local full-suite process count
and never changes `--ci-run`. The exclusive host-state shard has one file, so
a workflow passes it `--jobs 1` or no `--jobs`.
- Workflows set k in the plan, per shard: `--ci-plan <plan> --shards N
--shard-jobs k` stores `jobs = min(k, the shard's files that may run in
parallel)` on each shard, and only when that is above 1. The exclusive
host-state shard holds only `TREE_MUTATING` files, so it stays one child, and
a plan made at k = 1 is byte-identical to one made without `--shard-jobs`.
`--ci-run` runs the shard at the plan's k, and `--ci-verify` rejects any
shard result that ran at a different k. `free-tests.yml` reads k from
`FREE_SUITE_JOBS` and `windows-free-tests.yml` from `WINDOWS_FREE_JOBS`; the
`free_jobs` and `windows_jobs` dispatch inputs override them for experiments
only.
- `--jobs` applies only to `--ci-run` and overrides the plan's k, for local
reproduction. It takes a positive whole number up to the shard's file count
(an empty shard takes 1); anything else stops with `FREE_JOBS_INVALID`, as
does a bad `--shard-jobs` or one without `--ci-plan`. `GSTACK_FREE_JOBS` sets
the local full-suite process count and never changes `--ci-run`.
- Files are split into disjoint sub-shards packed by the duration seed. Each
sub-shard has an id (`<plan id prefix>:<shard>.<n>`) bound to its file list.
Files in `TREE_MUTATING` or `SLOW_UNDER_LOAD` run after the parallel
@@ -1606,6 +1619,13 @@ reproduce command. If the file passes serially, add it to `SLOW_UNDER_LOAD` in
`scripts/lib/shard-jobs.ts` with that run id and a reason. Never raise the
timeout. `SLOW_UNDER_LOAD` is empty today.
The nightly Windows lane stays at `WINDOWS_FREE_JOBS: '1'`. In GSTA-203's A/B, k = 2
made the slowest Windows shard faster, but two of three runs needed the flaky
retry for file-lock cleanup errors (`EBUSY` in
`browse/test/stealth-webdriver.test.ts` and a native fixture cleanup in
`browse/test/cookie-import-native-job.test.ts`), which k = 1 never showed. Fix
those cleanups before raising it.
Every `--ci-run` result, at any k, records per-file test and `expect()` counts
in `outcome.counts` (`tests`, `pass`, `fail`, `skip`, `todo`, `expects` per
file, plus shard totals). They come from bun's JUnit reporter, which the runner
@@ -1624,8 +1644,9 @@ test list in its last step, where each file's reason sits beside it.
`test/windows-native-workflows.test.ts` keeps that list Windows-safe and fast.
The curated suite below runs nightly on main and on dispatch, never on a PR.
`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6`),
runs one strict serial shard per `windows-latest` job
`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6
--shard-jobs $WINDOWS_FREE_JOBS`), runs one strict serial shard per
`windows-latest` job
(`bun run test:windows --ci-run`), and verifies every shard receipt on Linux
(`--windows-only --ci-verify`). Packing uses Windows-measured durations from
`scripts/free-test-durations-windows.json`; the weekly
+7
View File
@@ -472,6 +472,13 @@ make <dir> writable, or set GSTACK_STATE_ROOT to a writable directory
(LAUNCH_FILE_UNWRITABLE)`, and a failing run prints no `GSTACK_LAUNCH_FILE` line
and removes the file an earlier passing run left beside the verdict it rewrites
(a profile check that keeps a fuller ok verdict leaves that verdict's file).
With `--model <id>`, or `GSTACK_MODEL=<id>` in its environment (what the Capy
skill passes), an ok run other than `--for review-voice` also prints `LOADOUT: …`
and `SKILL_ROOT: <dir>` just before `GSTACK_LAUNCH`. For a loadout other than
`full`, `GSTACK_LAUNCH_FILE` then names `<verdict file without .json>.loadout-<name>-<digest>.env`,
a copy of the verdict's launch file that adds `GSTACK_LOADOUT_SKILL_ROOT`; the
verdict and its own launch file do not change, and any failure prints
`LOADOUT_FALLBACK` and serves `full` ([loadouts](loadouts.md#capy)).
`. <that file>` at the start of a shell applies them without ever printing them,
which is the only way to hand them to a child agent: pass the path, never the
values. None of the assignments is a secret, but during GSTA-96's unattended
+6 -3
View File
@@ -180,9 +180,12 @@ only history need is calibration, which `--commit <sha>` covers by scanning
one commit's first-parent delta without the working tree.) Positional
base defaults the way `gstack-diff-scope` does (the remote's `HEAD` target,
origin first, then the other remotes → `main` → `master`); an
already-qualified ref is used as given, and a branch name with no local
branch resolves through its remote-tracking twin (`origin/main` for `main`:
the merge-base is the same commit), noted once on stderr. stdout: one
already-qualified ref is used as given, and a branch name resolves through
its remote-tracking twin (`origin/main` for `main`), so a stale local `main`
never adds commits the branch only inherited (GST-883). Only when no remote
has the branch does the local ref stand, noted once on stderr (a repository
with no remote at all uses it quietly). Branches are read by full ref name,
so a tag named `main` or `origin/main` cannot stand in for them. stdout: one
`GATE_SUMMARY: listed=N read=N tagged={RH-1:2,RH-15:1} unmatched=N
coverage=<patterns> languages_unlisted=<…> candidate=<sha+wtree>` line, then
the read-level listing (bounded; the full listing goes to `--out`).
+1 -1
View File
@@ -105,7 +105,7 @@ After weighing Codex's argument, we chose to roll back CEO EXPANSION and ship an
}
```
`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace <dim>` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. Nothing displays `contributing_events` yet (#3436).
`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace <dim>` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. `--check-mismatch`, `--gap`, the stored `gap` and /plan-tune's profile view compare a dimension only when its `contributing_events` is at least 3; `--gap` lists the others under `low_evidence` and the stored `gap` omits them (#3436).
**Diversity check** (Codex #13): `inferred` is considered "enough data" only when `sample_size >= 20 AND skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`. Below this, `/plan-tune profile` shows "not enough observed data yet" instead of a potentially-misleading inferred value.
+49 -4
View File
@@ -1,8 +1,8 @@
# Module loadouts
gstack can render a smaller or larger set of its instructions for a given model. Each toggleable piece is a **module** with an id. A **loadout** is a named set of modules, and `modules/loadouts.json` maps each model **rung** to a loadout. Today every rung loads `full`, which is the same render gstack has always shipped. A loadout changes only when the shared skill eval (GSTA-2) measures a smaller one that matches `full` within its preregistered margin.
gstack can render a smaller or larger set of its instructions for a given model. Each toggleable piece is a **module** with an id. A **loadout** is a named set of modules, and `modules/loadouts.json` maps each model **rung** to a loadout. Every rung loads `full`, which is the same render gstack has always shipped. A loadout changes only when a preregistered eval measures a smaller one that matches `full` within its margin.
Install-time model detection, which picks a loadout for the model you run, ships with the first loadout that is not `full`. Until then, loadouts are an eval tool: `bin/gstack-modules render` produces any loadout as a byte-exact tree with a lock file.
One model has its own loadout: Claude Opus 5.5 gets `lite` ([below](#lite-on-claude-opus-55)). Every other model, and any model gstack is not told about, gets `full`. `bin/gstack-modules render` also produces any loadout as a byte-exact tree with a lock file for evals.
## Terms
@@ -12,6 +12,7 @@ Install-time model detection, which picks a loadout for the model you run, ships
| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. |
| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. |
| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. |
| Model map | `models` in `modules/loadouts.json`: an exact model id to a loadout, checked before rungs. The id also matches its dated snapshot (`-YYYYMMDD`) and `-latest`, never a point release or any other suffix (`claude-opus-5-5` matches `claude-opus-5-5-20261101`, not `claude-opus-5-50` or `claude-opus-5-5-1`). |
## Commands
@@ -22,9 +23,11 @@ bin/gstack-modules render --host claude --loadout full --out-dir ~/arms/control
bin/gstack-modules render --host claude \
--modules 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design' \
--out-dir ~/arms/oh-lean
bin/gstack-modules resolve --model claude-opus-5-5 # the loadout an install would serve, and why
bin/gstack-modules install --full-root "$HOME/.claude/skills" --model claude-opus-5-5 # publish it and print SKILL_ROOT
```
`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`.
`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`. `--bind-model <id> --full-root <abs>` (both or neither, on a loadout that excludes something) bakes the start lines an install for that model serves ([below](#the-start-line-carries-the-model)).
`bun run gen:skill-docs --loadout <name> --out-dir <dir>` and `--modules <spec> --out-dir <dir>` apply the same filter to a generation run without the staging, dangling-reference check or lock file; a selection with exclusions and no `--out-dir` is refused with `MODULE_SPEC_INVALID`, because the committed tree is always the `full` render. A non-`full` render adds `<!-- loadout: <name> -->` to each SKILL.md header.
@@ -35,6 +38,48 @@ bin/gstack-modules render --host claude \
3. Excluding a section removes its STOP pointer, its Section-index row and its file. The step becomes `> Not loaded in the <loadout> loadout: skip <trigger>. Do not offer it, and treat it as not run.` Skeleton text that belongs to the section, such as office-hours' Phase 3.5 offer, sits inside `{{IF_SECTION:<id>}}…{{/IF_SECTION}}` in the template and goes with it.
4. The autoplan runner (`gstack-autoplan`) reads review methodology from the source tree, not from a rendered loadout, so autoplan-runner arms always measure `full` methodology.
## Lite on Claude Opus 5.5
`lite` is `full` minus eleven coaching pieces of the shared preamble: voice, writing style, completeness, confusion, evidence, context health, question tuning, search before building, repo mode, brain health and context recovery. It keeps every pinned piece, the upgrade check (the PROACTIVE and skill-prefix rules) and brain sync (the privacy stop-gate). Only `claude-opus-5-5` maps to it, because only Opus 5.5 has been measured: office-hours showed no loss on Opus 5.5 (GSTA-161 preamble run), and the land-and-deploy confirmation (GSTA-161, PR 1b) compared full, lite and no gstack on the same tasks. Haiku 5.5 leaned slightly against a larger cut on the same office-hours run, so small models stay on `full`. Two skills point at a preamble section lite drops: office-hours and plan-ceo-review name ETHOS.md through the "Search Before Building" section, so under lite they find ETHOS.md in the gstack checkout instead.
### How gstack picks a loadout
gstack never guesses the model. It uses, in order:
1. An explicit loadout: `./setup --loadout <name>` on Claude Code, or `bin/gstack-modules install --loadout <name>`. It is served as asked and bound to no model.
2. An explicit model id: `--model <id>` on the Capy installer (`scripts/install.sh --model <id>`, which passes it to `bin/gstack-capy-install`), else `GSTACK_MODEL`, which the installer also reads.
3. Nothing: `full`.
A model id is lowercased, its `provider/` prefix and a trailing `[…]` are dropped, and it must then be a plain id (`^[a-z0-9][a-z0-9._-]{0,79}$`). An unmapped id serves `full`; an id that is not plain also serves `full` and prints `LOADOUT_INVALID_MODEL`. Nothing reads Claude Code settings or an inherited loadout variable, so a Sonnet subagent of an Opus session gets `full` unless its own install names Opus.
### Capy
The Capy skill passes the thread's model id in the environment: `GSTACK_MODEL=<id> bash scripts/install.sh --check --for <skill>`. The variable, unlike `--model`, is safe on a checkout whose installer predates loadouts: that installer ignores it instead of refusing an unknown argument. The installer prints two lines before the launch lines:
```
LOADOUT: lite model=claude-opus-5-5 source=GSTACK_MODEL digest=3f2a9c01d4e7 reused=yes
SKILL_ROOT: /home/user/.gstack/loadouts/lite-3f2a9c01d4e7
```
Read skills from `SKILL_ROOT` (`<root>/gstack/SKILL.md` routes, `<root>/<skill>/SKILL.md` runs) and source the printed `GSTACK_LAUNCH_FILE`, which adds `GSTACK_LOADOUT_SKILL_ROOT`. For `full`, `SKILL_ROOT` is `~/.claude/skills` and the launch file is the install verdict's own.
The lite tree is published by `bin/gstack-modules install` under `<state root>/loadouts/<loadout>-<digest12>/`, beside the full install; the checkout and its committed SKILL.md files stay the full render. The digest covers the selection, the bound model, the full root, host, overlay, plan mode and the source tree (the git HEAD tree plus any uncommitted change), so an upgrade or a different model gets a new folder and an unchanged one is reused after its files are re-hashed against `loadout.lock.json`. Each folder is a complete skill tree: the rendered SKILL.md and section files plus every tracked runtime asset of each skill (templates, references, scripts). A published folder is never replaced. Pruning removes only folders it published (a `<loadout>-<digest12>` name with an install lock) beyond the three most recently used per loadout, and never one used in the last day. When `--full-root` is not `~/.claude/skills`, the tree's command paths point at that install's `gstack` folder. Two Capy threads with different models on one machine share one install verdict and get one launch file each (`<verdict>.loadout-<name>-<digest>.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`.
### The start line carries the model
A lite tree chosen for a model bakes that model and the full root into each skill's start command, so `gstack-skill-start` prints:
```
LOADOUT: lite model=claude-opus-5-5 full_root=/home/user/.claude/skills
LOADOUT_RULE: this file was rendered for claude-opus-5-5. If you are not running claude-opus-5-5, read /home/user/.claude/skills/qa/SKILL.md now and follow it instead of this file.
```
When `GSTACK_MODEL` is set to a different model, it adds `LOADOUT_MISMATCH`. A preamble already read cannot be unread, so the remedy is always to read the full file. An explicit loadout (`./setup --loadout lite`) carries no model and prints neither line.
### Claude Code
`./setup --loadout lite` saves `claude_loadout` and renders the Claude skills into the untracked render dir (`<state root>/render/claude`) through setup's own render, the same path a pinned overlay or `plan_mode_preamble=off` takes, so later upgrades and `gstack-config gbrain-refresh` keep it. `./setup --loadout full` returns to the committed files. A plain `./setup` never selects lite.
## Changing a loadout
Add or edit an entry under `loadouts` and cite the GSTA-2 run (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`.
Add or edit an entry under `loadouts` (and `models`, for a model-specific one) and cite the measurement (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`.
+118 -14
View File
@@ -195,7 +195,10 @@ Two stages with separate clocks, both printed:
changed Python (`.py`, `.pyi`) or Dart file is scanned whole and only
findings on its added lines count, so a line added inside an existing
`"""` or `'''` string is still read as a literal; its rows give the file's
line number (`app.py, line 10`).
line number (`app.py, line 10`). A tracked file's whole text is the version
git diffed (a full-context `git diff`), never the working copy; an untracked
file is read whole from disk. A file over the size limit, or whose full diff
does not parse, is scanned on its added lines alone, without that context.
- `strays`: a new file at the repository root, a new top-level directory, or a
scratch name (`zz-*`, `tmp*`, `scratch*`) anywhere.
- `literals`: for every changed exported constant or named timeout/retry
@@ -380,7 +383,7 @@ The record is `.gstack/tmp/pregate.json` (`gstack-artifact validate --as pregate
(`PREGATE_STALE`: rerun after the final stamp), green (`PREGATE_LANE_FAILED`,
`PREGATE_INCOMPLETE`, or `PREGATE_ENVIRONMENT` when every incomplete check is
the machine's) and every `requires-remote` obligation carries a receipt
(`PREGATE_REMOTE_UNCLEARED`: dispatch the job, then
(`PREGATE_REMOTE_UNCLEARED`: dispatch the job, wait for it to finish green, then
`gstack-pregate clear --remote <workflow.yml/job> --run-url <url>`). The ship
receipt then carries `pregate` and `requires_remote`. `gstack-pregate verify`
runs the same check standalone.
@@ -751,8 +754,25 @@ reads `remote-gate tree=<t12> tests=<n> runs=<id>/<attempt>,… profiles=<p>,…
heads=<h12> verified=<time>` (with `accepted-for=` and the substitution for a
stamp-only predecessor). A lane with its own profile gets `run tree=… runs=…`
the same way; for such a lane `--run-url` is verified as `--run` is, and
`--bundle-lane` is refused. A lane with no profile keeps today's `--run-url`
recording. When the base config or the GitHub origin cannot be read, `--run`
`--bundle-lane` is refused. A lane with no profile still clears by
`--run-url`, with run checks (GSTA-203): when the base config and the GitHub
origin can be read, `clear` reads the run once and refuses
`PREGATE_RECEIPT_INVALID` unless it is a run of this repository whose
workflow file is the lane's own `<workflow.yml>`. A run of another workflow
refuses with `REUSE: refused reason=workflow_mismatch — run <id> attempt <n>
ran .github/workflows/<other>.yml, not .github/workflows/<lane's>.yml`; a
run gh cannot read refuses with `api_error`, and a URL that is not an Actions
run URL or id on this repository with `run_ref_invalid`. The run must also
have finished green (GSTA-203 PR 8): a run that is still queued or running
refuses with `run_in_progress`, and one that concluded anything but `success`
(`failure`, `cancelled`, `timed_out`, …) with `run_not_success`; each fix line
says to rerun the workflow and clear with the green run. The check reads the
run's newest attempt, so a `/job/<id>` or `/attempts/<n>` link is judged by
the whole run, not that job or attempt, and the lane records
`https://github.com/<repo>/actions/runs/<id>/attempts/<n>` for the attempt it
judged rather than the link you passed. The job, tree and age are not
checked there; that is what a run profile adds. `test_backend/<backend>` lanes and `--bundle-lane` are
unchanged. When the base config or the GitHub origin cannot be read, `--run`
fails, and so do `--run-url` and `--bundle-lane` unless the base was read and
shows the lane has no profile; then the lane is recorded as before, with a
warning naming the cause. `gstack-ship-queue ready --dispatch-ci` still
@@ -768,7 +788,18 @@ aggregate, `os` Windows). Each accepts `pull_request` and `workflow_dispatch`
runs and reads the `tested-tree` artifact, which both aggregates upload only
after their strict verification passes. The main-push reuse job and
`--from-run` read them. No lane has a profile, so every macOS and Windows lane
still clears by `--run-url` as before. A pull request cut before these
still clears by `--run-url`, checked for its workflow file and a green
conclusion. On main the free tier's Windows smoke lane is
`windows-smoke.yml/windows-smoke`, and a `windows-smoke.yml` run clears it.
Two narrower Windows lanes are owed only when a branch selects their own test
files: `free-tests.yml/cso-windows-launcher` (cleared by a `free-tests.yml`
run) and `windows-setup-e2e.yml/windows-setup` (cleared by a
`windows-setup-e2e.yml` run). The six-shard
`windows-free-tests.yml/windows-free-shard` job is `manual`, so it is never an
obligation. A branch cut before
`windows-smoke.yml` landed still owes `windows-free-tests.yml/windows-free-shard`
from its own workflows, and only a `windows-free-tests.yml` run clears that. A
pull request cut before these
workflows uploaded `tested-tree` has no record a lane profile could verify, so
lane profiles wait until open pull requests carry the producer (GST-555).
@@ -923,8 +954,11 @@ those, each with its scope and its reason:
(`session: HttpSession,`) and a dict entry or Dart named argument that reads
a name (`"password": request.password`) are code, not credentials, as they
are in TypeScript (#3630). Python's keyword form (`password=request.password`)
still reports. The same line inside a `"""` docstring is text and still
reports; allowlist it by its value. The pre-push hook sees only added lines,
still reports. The same line inside a `"""` docstring, any other string or
a Dart `/* */` comment is text and still reports; allowlist it by its value.
Code inside a Python f-string or t-string `{…}` field, or a non-raw Dart
string's `${…}`, is read as code, and its nested strings and quotes do not
end the outer string (GST-738). The pre-push hook sees only added lines,
so it scans Python and Dart lines without this exemption and may still warn
(MEDIUM, non-blocking) on them.
@@ -1071,7 +1105,8 @@ with a timing flake: never raise the bound.
"answer_markers": ["SEALED ANSWER:"],
"seed_patterns": ["shuffle_seed=\\d+"],
"local_paths": true,
"case_insensitive": true } }
"case_insensitive": true,
"label_keys": ["stratum", "decoy_set"] } }
```
A repo with a `sealed` key gets a configured local check against accidental
@@ -1079,13 +1114,33 @@ publication whenever the managed pre-push hook (`gstack-redact
install-prepush-hook`, no reinstall needed) or the standalone preflight runs:
```
gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--json]
gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--no-skip] [--json]
```
- **Boundary.** Every commit the push publishes: `git rev-list <local sha>
--not --remotes=<remote>` (and the remote tip when the ref exists); a
destination with no refs means everything reachable. `--range` names the
commits explicitly.
- <a id="sealed-skip"></a>**Skipped commits.** When the base branch resolves, a
listed commit is not rescanned when the destination already holds it or its
patch already landed. Held: an ancestor of the destination's own
`refs/pull/<n>/head` for a squash commit `(#n)` on the base since the
merge-base; the subject is only a hint, the fetched ref is the proof. That
lookup lists and fetches at most 50 refs within 20 seconds (receipted, sink
`sealed-guard`) and deletes the fetched refs afterwards. Landed: the commit's
own `git patch-id --verbatim` equals a base commit's since the merge-base and
so does its diff, apart from blob ids and line numbers; a merge never matches,
and above 2,000 base or 400 listed commits this check is off. A landed commit
skips only the path and text rules: `sealed.label_join` judges the whole file
at that commit, which the base may never have held, so it still runs. Nothing
matches a combined diff, so a parent whose commits were never published is
scanned whole even when its squash landed. A lookup that fails, runs out of
time or exceeds a limit prints one line and scans everything. Each PR group
and landed commit is listed (`skipped <n> commit(s) the destination already
has as refs/pull/<n>/head`, `skipped commit <sha> (its patch landed on the
base as <sha>)`), the `SEALED:` line counts them as `skipped=<n>` (its
`commits=` counts the commits scanned), and `--no-skip` scans every listed
commit. The pre-push hook and `gstack-pregate sealed` skip the same way.
- **Rules.** `sealed.path` and `sealed.transcript` check every path a commit
adds, renames or copies, whatever its content (binary, empty, deleted again
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
@@ -1117,11 +1172,60 @@ gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--js
sealed data. Each exempt path is listed on every push (`exempt <rule>
<file> commit <sha> (sealed.allow_paths <glob>)`) and counted as
`exempt=<n>` on the `SEALED:` line.
- **Version skew.** A gstack older than the release whose CHANGELOG adds
`sealed.allow_paths` refuses `allow_paths` and `case_insensitive` as
unknown keys, so every push and `gstack-pregate run` in the repository
fail on that machine. Raise `min_gstack_version` in
`.gstack/ship-policy.json` to that release before landing either key.
- **Label joins.** Off unless `label_keys` names at least one field. Then
`sealed.label_join` refuses a published record that puts a label field next
to an identifier, the way a panel row with `stratum` beside `head` and
`plan_bytes` tells a model under test which unit it is. A record is a JSON
object at any depth (its own fields plus one level of nested objects, named
like `meta.head`), a JSONL or NDJSON line, a CSV or TSV row, or a row of a
Markdown pipe table (CSV and TSV cells may be quoted). Field names and table headers are compared after
trimming, lowercasing and turning spaces and hyphens into underscores
(`Tree SHA-256` is `tree_sha_256`); this is the rule's own normalization,
separate from `case_insensitive`. An identifier is a field whose name
matches an `identifier_keys` glob (`*` is the only wildcard; the default
is `head`, `head_sha`, `sha`, `commit`, `tree`, `tree_sha*`, `*_sha`,
`*_sha256`, `sha256`, `digest`, `*_digest`, `hash`, `*_hash`,
`*_bytes`, `bytes`, `size`, `files`, `lines`), or any string value (or a
list holding one) of 7 to 64 hex characters with at least one digit and
one letter a to f. An
object key of that shape counts as an identifier of the object it holds
(shown as `(key)`), so a map keyed by sha joins too. Numbers count only
through the field name. A field can be both a label and an identifier, and
a record joins when it has a label field and a different identifier field,
so adding identifier names to `label_keys` never hides a join. Both lists take bare field names: a nested field
matches by its own name (`stratum`, never `meta.stratum`). For each supported file a commit adds,
modifies, renames or copies, the guard fingerprints every joined record
(sha256 over its label and identifier fields and their values, kept in
memory) and compares the set with the parent's version of the file (the
rename or copy source; every parent for a merge). A fingerprint no parent
has is a finding, so unchanged records and formatting-only edits stay clear,
and a replaced, added or widened record refuses even when the count stays
the same. A file whose text never names a label key, ignoring double
quotes (so `"Strat"um` still names `stratum`), cannot hold a join, so
it is skipped without parsing (a commented `tsconfig.json` passes). A file
with a `\u` escape, which could spell a label key, is always parsed, so a
commented one is `sealed.label_join_unchecked`. A
supported file over 4 MiB, one that names a label key and does not parse (one bad
JSONL line is enough), one nested deeper than 64 levels, or a blob git
cannot read is `sealed.label_join_unchecked`. It refuses too, and the
`SEALED:` line counts it as `unchecked=<n>`. Findings print field names
(`keys=`, label fields first), never values; `--json` carries them as
`keys`. `allow_paths` never exempt either rule; a base-branch waiver does.
Both lists are the union of the base branch's and the pushed policy's, each
resolved to its default first, so a push can turn the rule on or widen it
but never turn it off or narrow it. Without `label_keys` the guard reads no
file contents and its output is unchanged. Known limits: joins across files,
joins by reference inside a file (a `decoy_set` list naming units described
elsewhere), a map from an identifier straight to a label value with no
label field name, columnar JSON (`columns` plus `rows`), labels encoded in
file names, and prose.
- **Version skew.** A gstack older than the release that adds a `sealed` key
refuses it as unknown, so every push and `gstack-pregate run` in the
repository fail on that machine. `allow_paths` and `case_insensitive` came
with the release whose CHANGELOG adds `sealed.allow_paths`; `label_keys`
and `identifier_keys` came with the release whose CHANGELOG adds
`sealed.label_join`. Raise `min_gstack_version` in
`.gstack/ship-policy.json` to that release before landing the key.
- **Policy.** The union of the destination base branch's `sealed` and the
pushed commit's: a push can tighten it, never relax it (`allow_paths` come
from the base branch alone, and a pushed deny glob cannot override one). The
+65 -25
View File
@@ -181,7 +181,14 @@ follows that file, four files deep. Precedence: agent prose (`.gstack/**`), a te
fixture path, a gate path, a config file (`.github/**`, anything under a `config/`
directory, dotfiles, `*.config.*`, `package.json`, lockfiles, `tsconfig*.json`,
`manifest.json` and the other tool manifests `lib/review-depth.ts` lists) and a named
consumer each keep the path code, and YAML, TOML and SVG are always code. More than
consumer each keep the path code, and YAML and TOML are always code. An SVG counts
as data only when its whole text at the head is a drawing (GST-911): SVG drawing
elements and RDF metadata, CSS made of declarations only (no `@` rule and no
function but a same-file `url(#id)`, which closes `@import`, `image-set()`,
`image()`, `element()` and every other CSS load), no backslash escape, every
`href` a same-file `#` reference or an inline raster image, no event handler and no
entity declaration, and no DOCTYPE but a bare `<!DOCTYPE svg>` or W3C's own SVG DTD, read after character references are decoded; anything else, a
script or `foreignObject` included, keeps it code. More than
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same
@@ -215,23 +222,25 @@ input, redirect toggles, proxy settings and host allowlists, archive extraction
and path traversal (`tar.x`, adm-zip, yauzl, extract-zip, `extractall`, `tar -x`,
`unzip`, `path.join` or `resolve` on request or entry input, a `'..'` check added
or removed), and any changed line that names the `child_process` or
`node:child_process` module, in any import or require spelling. The GST-850
`node:child_process` module, in any import or require spelling. A changed SVG
line that can run code (a script or `foreignObject` element, an event handler, a
`javascript:` URL) is security too (GST-911). The GST-850
rules are coarse on purpose and parse no import clause or call arguments;
`test/review-risk-linear.test.ts` holds each of them under 100 ms on
200,000-character hostile lines, its anchor followed by a long run of the
characters it repeats included. That claim covers only the rules GST-850 adds,
not R51's older ones. **data** is user data: a path naming pii, gdpr, ccpa, privacy,
characters it repeats included. `test/review-risk-redos.test.ts` (GST-898) holds every risk pattern, R51's `rm` and `rmSync` rules included, under 100 ms on 100,000-character hostile lines. An `rmSync(` call with more than 500 characters before the first `)` after it counts as security on its own. **data** is user data: a path naming pii, gdpr, ccpa, privacy,
personal, account, customer, cookie, persistence, storage or database, or a line
that drops a table or column, deletes rows or bulk-deletes records. Auth scope,
migrations and deleted code paths were already gate class. `RISK_RULES` hashes
the patterns and `DEPTH_RULES` includes it.
**UI text and SVGs** stay code (light unless a risk class applies). A docs path
for UI copy, locale values and chart SVGs was built as an allowlist in GST-850 and
dropped for review cost: its first review round found a new bypass class in each
version, the same pattern as three rounds of R51. When a diff publishes numbers,
the batched specialist's diff keeps the write-up's text files instead of leaving
docs out.
**UI text** stays code (light unless a risk class applies). A docs path for UI
copy and locale values was built as an allowlist in GST-850 and dropped for
review cost: its first review round found a new bypass class in each version,
the same pattern as three rounds of R51. Chart SVGs use the drawing allowlist
above, which reads the whole file rather than changed lines. When a diff
publishes numbers, the batched specialist's diff keeps the write-up's text files
instead of leaving docs out.
**Batched checklists (the lean ensemble, GSTA-167 part 2).** One specialist
subagent reads Testing and Security on every code or gate diff, plus
@@ -350,7 +359,7 @@ and matching is token-bounded: `costume` never reads as cost.
|---|---|
| money | a path segment naming cost, spend, budget, ledger, billing, price, fee or invoice; or a changed line with an identifier ending in `_usd` or `Usd`, any `spend_cap` spelling, or a call to a paid provider `lib/pricing.ts` prices |
| gate | a hunk the gate scan tagged: a relaxed threshold, a skipped test, a loosened check |
| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name |
| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name; a data file named for sessions (`recovered-sessions.jsonl`) outside a config location does not set `SCOPE_AUTH` |
| secret | a secrets, credentials, keychain or token path, or a changed line that reads a key-named environment variable (`OPENAI_API_KEY`, `GH_TOKEN`) or a credential store |
An input the classifier cannot read (the diff, `gstack-diff-scope`, the gate
@@ -387,14 +396,17 @@ narrowed to the gate scan's tagged hunks.
## What the receipt records
`gstack-review-scope summary --since <REVIEW_SINCE>` reads this invocation's
review-log rows and writes the counters `gstack-ship-receipt write --review-loop`
`gstack-review-scope summary --since <REVIEW_SINCE> --run <run dir>` reads this invocation's
review-log rows and the dispatch ledgers under the run directory (`--ledger <f>` names one;
`--no-ledger` when no reader ran through `gstack-review-dispatch`) and writes the counters `gstack-ship-receipt write --review-loop`
puts in the receipt:
| Field | Meaning |
|---|---|
| `review_loop.passes` | full review passes this ship ran (1 or 2; up to 3 under `review_scope: all`) |
| `review_loop.blocking_found` | distinct blocking findings across the passes |
| `review_loop.blocking_found` | every blocker found (GST-951): the rows' blocking findings (classified, or an unclassified CRITICAL), plus each ledger CRITICAL (as collected or judged) that no row finding from the same reviewer at the same path:line accounts for (each row finding pairs with one per ledger), so an adversarial, outside-voice or not-yet-classified blocker counts and one whose newest classification is below the floor does not. An unreadable ledger, merged file or judged set, or a judged set changed since `judge`, is `REVIEW_SCOPE_LEDGER`, never a count |
| `review_loop.unrecorded` | blockers found that no row gives a disposition (fixed, deferred, noted, skipped, a gate answer); absent when 0 |
| `review_loop.ledgers_read` | dispatch ledgers the count read; absent under `--no-ledger` |
| `review_loop.deferred` | blocking findings in unchanged code filed as issues (a Step 11 late defer, logged as a `review-late-defer` row so it never displaces the newest `review` pass, counts here and is not a pass) |
| `review_loop.noted` | below-floor findings listed in the PR body |
| `review_loop.fixed` | findings fixed in this PR |
@@ -417,7 +429,14 @@ rules hash, base and registry commits, the gate scan bound to the head and the r
the queue refuses a receipt below the depth its head needs
([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner
accepted after a fix-check edit goes in `review_loop_exception`, never in prose
under `REVIEW_LOOP:`.
under `REVIEW_LOOP:`. Step 16 takes the same record:
`gstack-ship-release finish --review-loop-exception <json|@file>` routes the
paths the branch touched at `fix_check.head` (narrowed to the record's
`fix_check.files` when it lists them) and changed since as `accepted=` in
`ROUTE:`, prints them on a `REVIEW_LOOP_EXCEPTION:` line, and carries them
through the docs stage. A path outside that scope and a gate-edited test still
route as behavior, with `--reviewed-base` (as /ship passes it) so does a file
too large for the scoped review, and without the record nothing changes.
The receipt's summary line prints `passes=<n> blocking_found=<k> deferred=<m>`,
which is what a scorecard tracks (target: a median of 1 pass per PR). A
@@ -560,14 +579,26 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
missing coverage leaves the review incomplete. A mismatched send re-sent
byte-exact before anything was collected is void and counted (`voided=N` on the
verdict line); one that produced an output stays `MISMATCH`. Then output hashes
against the merged lines they produced, and the judgment rerun against the
against the merged lines they produced, and each merged line against the finding collect
parses at that id's index from the unchanged output file, label and raw text byte for byte
(`merged_line_differs` on the OUTPUT and JUDGE rows otherwise, GST-541), so an edited
merged.jsonl cannot lower a finding or swap its text. A recorded output file that is missing
(`output_file_missing`), a judgment run on a merged file collect did not write
(`judge_merged_not_collected`) and merged rows collect never recorded
(`merged_rows_not_collected`) are dirty too, and so is an output whose re-parsed findings
outnumber or undercount the ids collect recorded (`merged_count_differs`). The one exception
is a reply collect refused for its tree: verify re-checks the reply's `REVIEWED_TREE` line
against the render's tree, and such an output stays `NOT_REVIEWED` until a re-sent brief is
answered by a reply that passes the same check. Deleting a whole output event with its rows is
not caught yet, because the ledger has no anchor for it. The judgment is then rerun against the
recorded judged set, covering every collected id. A ledger from a run that kept no
prompts prints `VERIFY: no brief records` (exit 3), never clean.
6. `pass --base <b> --out <dir> --pass <id> -- <plan flags>` runs one pass from a
staged manifest (`<dir>/pass-manifest.json`): audit readers marked first, then
the specialists with the other audit readers, then the Red Team, then Step 11's
voices. A QA extra is refused before any reader is created: Step 9.2.1's probes
are the parent's. Each reviewer keeps its plan entry and is rendered by the same code as
are the parent's. `--diff-base` sets every reader's base, a scoped pass's Red
Team included; the targeted reader keeps the merge base. Each reviewer keeps its plan entry and is rendered by the same code as
`render`, once its inputs exist; the Red Team's findings file is the specialists'
`merged.jsonl` lines byte for byte. A brief leaves the bin in indexed chunks of at
most 65,536 characters (`chunk`), so no tool output line carries a whole brief and a
@@ -580,17 +611,26 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
fix-check plan renders its reader from `--fix-list`, `--hunks`, `--tests` and `--callers`,
`--audit-only` runs just the `--extras` readers (Step 14.5's docs audit), and the fork
point is found against `origin/<base>` first, as preflight does.
7. Every brief, audit readers' included, ends with a workspace paragraph naming the
checkout the bins run in (GST-482): a reader on a shared machine starts in the
machine's working directory, so it is told to `cd` there on every command, to give
every file tool an absolute path under it, and to start its reply with
`REVIEWED_TREE: <HEAD^{tree}> <toplevel>` measured there. The paragraph follows the
section's render, which stays byte for byte. `collect` and `mark --state collected`
7. Every brief, audit readers' included, opens with a fingerprint step and ends with a
workspace paragraph naming the checkout the bins run in (GST-482): a reader on a shared
machine starts in the machine's working directory, so it is told to `cd` there on every
command, to give every file tool an absolute path under it, and to start its reply with
the `REVIEWED_TREE: <HEAD^{tree}> <toplevel>` line the step's command outputs there. The
step comes first because a reader following a findings-only format skipped the request
when it only closed the brief (GST-897). The section's render sits between the two,
byte for byte. `collect` and `mark --state collected`
compare that line with the fingerprint recorded at render: a missing or foreign line is
`COLLECT_WRONG_TREE`, a later commit in the same checkout `COLLECT_TREE_MOVED`; nothing
is merged, the reader becomes `failed`, and `verify` reports `NOT_COLLECTED`. An
outside voice's tree failure is recorded `unavailable` only when its reply holds no finding. The line catches a reader that
worked elsewhere by accident; it is not proof of reading, since diff text can predict it.
worked elsewhere by accident; it is not proof of reading, since diff text can predict it. A `printed:` label
before the line (plain, bold or inside its backticks) is still read, and its tree and toplevel are still compared
exactly (GST-776). Capy cannot start a subagent in a chosen directory, so before a stage takes a start token or sends
its first reader, the Capy pass program compares the checkout with the one shared readers start in (the thread's
working directory) and stops when that is another checkout or cannot be read: run /ship in the thread's own checkout.
A start directory outside any checkout, such as a multi-repo workspace root, holds no tree to review by mistake and
dispatches. Step 11's voices take the `adversarial-review` start
token by default, and a `logStart` list without it stops before anything runs.
## Where this is enforced
@@ -601,7 +641,7 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
- `ship/sections/review-army.md` Step 9: the plan's `REVIEW_DEPTH`, `GATE_SCAN` and `--depth-floor` on every run, the depth fields of the review-log row, passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way.
- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the depth (`depthFor`, `DEPTH_RULES`, `depthOfCommitInput`), each dispatch's `exclude` pathspecs and `DIFF_CMD`, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`).
- `lib/review-collect.ts`: `collectCommit` (git objects only, from any checkout) and `collectLive` (the working tree), per-path lines and the base commit's registry (`test/review-collect.test.ts`).
- `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`); refusals are `REVIEW_SCOPE_INPUT` and `REVIEW_SCOPE_RANGE` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
- `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`, `unrecorded`, `ledgers_read`); refusals are `REVIEW_SCOPE_INPUT`, `REVIEW_SCOPE_RANGE` and `REVIEW_SCOPE_LEDGER` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
- `lib/ship-policy.ts`: the `review_scope` key and its `ship: review_scope …` consequence line.
- `review/checklist.md`: `prereg-with-results` under Gate Integrity and `test-keyboard-count-loop` under Pass 2; `bin/gstack-coauthor-trailers` for the contributor-credit trailers of a port.
- `lib/ship-receipt-prose.ts`, `gstack-ship-receipt read --check-prose`: the body's quoted counters against the receipt (`RECEIPT_PROSE_MISMATCH`, [ship-policy.md](ship-policy.md#ship-receipt)).
+7 -6
View File
@@ -1268,11 +1268,11 @@ construction:
the run and names the finding (path, severity, problem) for the owner instead
of a third round; the owner may grant one scoped fix-check.
`gstack-review-scope summary --since <REVIEW_SINCE>` reads the branch's review-log
rows for this invocation and writes the counters the ship receipt carries
`gstack-review-scope summary --since <REVIEW_SINCE> --run <run dir>` reads the branch's review-log
rows for this invocation and its dispatch ledgers, and writes the counters the ship receipt carries
(`gstack-ship-receipt write … --review-loop @<file>`): `review_loop: { passes,
blocking_found, deferred, noted, fixed, scope }`, plus `fix_checks`, `dispatches`
and `est_input_tokens` when the run recorded them, printed on the receipt summary
blocking_found, deferred, noted, fixed, scope }`, plus `fix_checks`, `dispatches`,
`est_input_tokens`, `unrecorded` and `ledgers_read` when the run recorded them, printed on the receipt summary
line as `passes=<n> blocking_found=<k> deferred=<m>`, so a scorecard can track
passes per PR.
@@ -1286,8 +1286,9 @@ instead of `defer`. `review_scope: all` is the opt-back to the earlier loop:
every finding is `scope: fix` on every pass, reviewers read the whole diff each
time, and only the three-cycle fix cap stops the loop. `show-effective` prints the choice as a `ship: review_scope …`
line, and the codes a refusal ends with are
[`REVIEW_SCOPE_INPUT`](troubleshooting.md#review-scope-input) and
[`REVIEW_SCOPE_RANGE`](troubleshooting.md#review-scope-range).
[`REVIEW_SCOPE_INPUT`](troubleshooting.md#review-scope-input),
[`REVIEW_SCOPE_RANGE`](troubleshooting.md#review-scope-range) and
[`REVIEW_SCOPE_LEDGER`](troubleshooting.md#review-scope-ledger).
<a id="base-red"></a>
## `base_red`: pausing /ship on a red base
+2 -2
View File
@@ -662,7 +662,7 @@ It leaves the order and tools to the model, keeps a few guards (stop and ask aft
## `/verify`
`/verify` is the loop you run while you build: change something, run the repo's checks, fix what broke, run again. `gstack-checks detect` finds the checks the repo already declares (a `gstack:verify:` line or a `## Health Stack` section in CLAUDE.md, else package.json scripts, tsconfig, eslint or biome, pyproject, Cargo.toml, go.mod) and never makes one up. `gstack-checks run` runs them plus the tests `/ship`'s pre-gate selects for your change, using pregate's own selector, so a green `/verify` predicts a green pre-gate.
`/verify` is the loop you run while you build: change something, run the repo's checks, fix what broke, run again. `gstack-checks detect` finds the checks the repo already declares (a `gstack:verify:` line or a `## Health Stack` section in CLAUDE.md, else package.json scripts, tsconfig, eslint or biome, pyproject, Cargo.toml, go.mod) and never makes one up. `gstack-checks run` runs them plus the tests `/ship`'s pre-gate selects for your change, using pregate's own selector, so a green `/verify` predicts a green pre-gate. `/health` finds its tools with the same detector (`gstack-checks detect --for health`), which adds its test, knip, shellcheck and gbrain dimensions.
It asks once before running any repo command (`gstack-checks trust`) and asks again when a command changes. A touched file no test covers is `UNMAPPED`, a selection that ran zero tests is `ZERO_RUN`, and neither counts as green. A missing tool or service is reported, not worked around. It stops after five runs or when the same failure comes back twice, and sends a stubborn red to `/investigate`. When everything passes, `gstack-checks status` prints `READY_FOR_SHIP tree=<tree>` without rerunning anything, and you run `/ship`. It never reviews, bumps versions, pushes or opens a PR.
@@ -1119,7 +1119,7 @@ Claude: Week of Mar 1: 47 commits (3 contributors), 3.2k LOC, 38% tests, 12 PRs,
[Top 3 team wins, 3 things to improve, 3 habits for next week]
```
It saves a JSON snapshot to `.context/retros/` so the next run can show trends.
It saves a JSON snapshot under `~/.gstack/projects/<slug>/retros/`, shared by every worktree of the repository, so the next run can show trends from any checkout. An older `.context/retros/` is moved there on the first run.
---
+1
View File
@@ -14,6 +14,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-memorable` | **Memorable recall bridge (opt-in, third party, Claude Code only)** — connects Claude Code to the external [Memorable](https://memorable.sh) CLI *through gstack* instead of the vendor's own installer, so the hook gets gstack's guarantees: an explicit consent key (`memorable_recall`, off by default, listed by `gstack-egress grants`), a fail-closed egress receipt for every prompt handed over (`gstack-egress list --sink memorable-recall`), a HIGH-tier secret pre-scan, a trust envelope and 8 KiB cap on whatever comes back, an allowlisted environment and process-group containment for the vendor process, and clean removal. `enable` registers the hook at the stable install with a 5 s timeout and never runs the vendor's own consent command; `disable` revokes the gate first and removes the entry by identity even after Claude Code strips the tag; `status` is read-only. gstack never installs Memorable, and what its binary sends is the vendor's claim, not gstack's. Not available on Windows yet. [Full guide](memorable-workflow-memory.md). |
| `gstack-decision-trail` | **Decision trail** — append one row (ts, phase, decision, why, evidence, result) to a run's TSV trail so a reviewer reads what was chosen and why without the transcript. Header once, append-only, cells flattened to one line, and a cell starting with `=`, `+`, `-` or `@` quoted so a spreadsheet cannot run it. `start <file> <run-id>` opens a run's stretch of a shared trail (idempotent; `start` is reserved as a phase name), and `--run <id>` refuses an append until that start row is the newest. Ported from pstack's `log.sh` (MIT, NOTICE.md). [Rule](unattended.md#the-decision-trail). |
| `gstack-worktree-audit` | **Worktree audit** — read-only table of every git worktree beside the main one, largest first: size, age, merged into the base or not, `clean`/`clean,ignored:<n>`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh` (fork PRs ignored), the newest Claude Code chat in it, and a bucket (`prunable` for an unlocked registered worktree whose directory is gone, `review` for a locked or `broken` one, `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe` only for a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed, else `review`). `-b origin/<branch>` sets the base, `--no-fetch` skips the best-effort fetch of the base ref (its one write). Never deletes. Ported from pstack's `worktree-audit.sh` (MIT, NOTICE.md). [Guide](CONTRIBUTOR_REFERENCE.md#auditing-conductor-worktrees). |
| `gstack-clean` | **Per-repo `.gstack/` cleanup** — run inside a repository to see what it would reclaim from that repository's `.gstack/` folder; `--apply` does it. It removes `tmp/codex-*` scratch files older than 7 days, cuts the browse logs (`browse-console.log`, `browse-network.log`, `browse-dialog.log`, `browse-daemon.log`, `browse-startup-error.log`) to their last 256 KB once they pass 1 MB, and trims `browse-audit.jsonl` only with `--audit-days N`. It never touches `browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/` or `browse-states/`, skips browse files while the daemon runs and `tmp/` while codex runs, and refuses (exit 3) outside git, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` names another folder, and when the folder is, holds or sits inside the gstack state root. `--json` prints one versioned object. Exit 0 done, 1 an action failed (rerunning is safe), 2 usage, 3 refused. [Error codes](troubleshooting.md#gstack-clean-usage). |
| `gstack-wtree` | **Working-tree fingerprint** — prints a content hash of what's actually on disk (temp index seeded from the stat cache, ~40x cheaper than a full re-hash; untracked source counts, gitignored scratch doesn't). Identical content fingerprints identically through commits, rebases, amends, and squashes — it's what binds reviews and test evidence to content instead of commit SHAs. |
| `gstack-review-log` | **Review-pass receipts** — `--start <skill>` captures the working-tree fingerprint before a diff review; `'<JSON>' --finish <token>` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs <token>` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. |
| `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). |
+161 -5
View File
@@ -2184,6 +2184,78 @@ command again.
**Fixed in.** The first release after v1.91.101.0 (#3167).
<a id="browse-session-invalid"></a>
### ``browse: the session name is not 1-64 letters, digits, dots, colons, underscores or dashes ("<name>"); fix: pick a name like agent-1 ... (BROWSE_SESSION_INVALID)``
**Meaning.** `BROWSE_SESSION` or `--session` holds a name browse will not use
as a tab owner.
**Fix.** Use a short name such as `agent-1`. An empty `BROWSE_SESSION=` turns
sessions off and uses the shared active tab.
<a id="browse-session-root-only"></a>
### ``browse: only the local root token may name a session ... (BROWSE_SESSION_ROOT_ONLY)``
**Meaning.** A paired or scoped agent sent a session name. Sessions are for
local agents that share the root token; scoped agents already get their own
tabs.
**Fix.** Run `newtab` and pass the returned `tabId` on every command.
<a id="browse-session-tab-reset"></a>
### ``browse: this session's tab was closed ... (session <name>'s tab <id> was closed; it was on <url>); fix: open the page again ... (BROWSE_SESSION_TAB_RESET)``
**Meaning.** The tab this session was working in is gone: someone ran
`closetab` on it, closed the window, or it sat idle past
`BROWSE_SESSION_IDLE_MINUTES` (default 120). browse refuses reads instead of
answering from a blank page.
**Fix.** `browse goto <url>` in the same session opens a new tab for it, then
`snapshot` again before using `@refs`.
<a id="browse-session-limit"></a>
### ``browse: every session slot is running a command ... (BROWSE_SESSION_LIMIT)``
**Meaning.** `BROWSE_MAX_SESSIONS` sessions (default 16) are all mid-command,
so browse could not close an idle one to open a tab for a new session.
**Fix.** Retry in a moment, or raise `BROWSE_MAX_SESSIONS`.
<a id="browse-device-unknown"></a>
### ``browse: no Playwright device has that name, so nothing on the tab changed ("<name>"; nearest: ...); fix: $B device --list (BROWSE_DEVICE_UNKNOWN)``
**Meaning.** `$B device <name>` matches names from Playwright's `devices`
table, case-insensitively. The name you gave is not in it, so the tab was not
touched. The line lists the three nearest names.
**Fix.** `$B device --list` prints every name; quote names with spaces
(`$B device "iPhone 15"`). Add `--landscape` for the rotated profile.
**Fixed in.** The first release after v1.92.61.0 (#3058).
<a id="browse-device-cdp-failed"></a>
### ``browse: Chromium refused a device emulation call on this tab (<method>: <error>; the tab is partly emulated); fix: $B device off (BROWSE_DEVICE_CDP_FAILED)``
**Meaning.** Phone emulation is three DevTools calls on the tab's own session
(size, touch, user agent). One failed; the line names it and says whether the
calls before it already changed the tab.
**Fix.** `$B device off` clears all three, then try the device again. If it
keeps failing, the tab or browser is gone: `$B restart`.
**Fixed in.** The first release after v1.92.61.0 (#3058).
<a id="browse-touch-no-device"></a>
### ``browse: touch input needs a phone profile on this tab first ($B tap on this tab); fix: $B device "iPhone 15" (BROWSE_TOUCH_NO_DEVICE)``
**Meaning.** `tap`, `swipe`, `pinch` and `touch` send real touch events,
which pages only see on a touch device. The current tab has no phone profile.
**Fix.** `$B device "iPhone 15"` (or any name from `$B device --list`) on
the same tab, then repeat the touch command.
**Fixed in.** The first release after v1.92.61.0 (#3058).
---
## Windows
@@ -3317,6 +3389,19 @@ work: they are how a budget-stopped CEO-only run recovers.)
full review in a new directory: `gstack-autoplan next --out <new dir> --plan
<file>` without `--ceo-only`.
<a id="shadow-before-answer"></a>
### `SHADOW_BEFORE_ANSWER`
**Meaning.** `gstack-autoplan shadow` skipped a gate revision whose plan
approval (`p1`) is unanswered or was answered `c` (interrogate). The export
pairs each hidden prediction with the owner's answer, so a revision he has not
answered has nothing to pair it with. Answered revisions of the same run still
export; the command exits 3 only when nothing was exported.
**Fix.** Answer the gate (`gstack-autoplan answer --out <dir> --gate-rev <n>
...`), then run `gstack-autoplan shadow` again. See
[Shadow mode](unattended.md#shadow-mode).
<a id="attempt-mismatch"></a>
### `ATTEMPT_MISMATCH`
@@ -3566,7 +3651,7 @@ 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). An unknown `sealed` key that a newer gstack defines (such as
`allow_paths` or `case_insensitive`) means this machine's gstack is older than
`allow_paths`, `case_insensitive`, `label_keys` or `identifier_keys`) means this machine's gstack is older than
the policy: upgrade gstack here, and raise `min_gstack_version` in the
repository's `.gstack/ship-policy.json` before landing such a key.
@@ -3763,6 +3848,15 @@ reads `gstack-pregate clear: receipt refused — REUSE: refused reason=<code>
branch's `run_profiles`. Each reason code and
what it checks is listed in [run evidence](pregate.md#run-evidence).
`gstack-pregate clear --remote <workflow.yml/job> --run-url <url>` on a lane
with no run profile refuses with this code too, reading `REUSE: refused
reason=<code> — …`: `run_ref_invalid` (not an Actions run URL or id on this
repository), `api_error` (gh could not read the run), `workflow_mismatch` (a
run of another workflow), `run_in_progress` (still queued or running) or
`run_not_success` (it failed, was cancelled or timed out). Each prints a fix
line; after the last three, clear again with a finished green run of the
lane's own workflow ([run evidence](pregate.md#run-evidence)).
Another tree is accepted in exactly one case: it differs from `pregate.json`'s
tree only in the repo's stamp rule (`.gstack/ship-policy.json` on the base
branch: `stamp_files`, `stamp_patterns`, the built-in version, manifest,
@@ -3864,7 +3958,7 @@ after `clear`), so it clears nothing here; or a `clear --run` receipt names
another tree than `pregate.json`'s with no substitution recorded (`run
receipt(s) verified for another tree`).
**Fix.** Dispatch that job, then
**Fix.** Dispatch that job, wait for it to finish green, then
`gstack-pregate clear --remote <workflow.yml/job> --run-url <url>` (or
`--bundle-lane <label>` from an evidence bundle). For `pregate.json/remote_gate`,
`clear --remote pregate.json/remote_gate --receipt <gate output>`: the gate's
@@ -4175,7 +4269,10 @@ migration is a code change, so the gate reruns.
sealed path or transcript (binary, empty and later-deleted files included), or
adds a line with a sealed-answer marker, a seed or a local home path, and the
destination is public or its visibility is unknown (a new or unreadable
repository counts as public). An unreadable or malformed sealed policy refuses
repository counts as public). With `sealed.label_keys` set, a new record that
puts a label field next to an identifier (`sealed.label_join`), or a supported
file the guard could not read (`sealed.label_join_unchecked`), refuses the
same way. An unreadable or malformed sealed policy refuses
too. The managed pre-push hook and `gstack-pregate sealed` print one line per
finding: rule, `file[:line]`, commit. They never print the matched text.
@@ -4184,7 +4281,9 @@ reaches the public repository (a later commit that deletes it still publishes
it). A publishable file kept next to sealed data (a `MANIFEST.json` of hashes
and counts) can be exempted from the path rules with a `sealed.allow_paths`
glob in the base branch's `.gstack/pregate.json`; the text rules still read it
([docs/pregate.md#sealed](pregate.md#sealed)). A reviewed false positive gets
([docs/pregate.md#sealed](pregate.md#sealed)). For `sealed.label_join`, drop
the identifier fields (or the labels) from the published file, or keep the file
out of the public repository; `allow_paths` never exempt it. A reviewed false positive gets
one line in the base branch's
`.gstack/sealed-allow.tsv`: `rule<TAB>path<TAB>sha<TAB>reason`, where `sha` is
the full commit sha (a shortened one is ignored and named on stderr). A private
@@ -5646,6 +5745,24 @@ pass).
the invocation record, and start a full pass (`--pass 1`) when the branch was
rebased; /ship's own rule is to merge the base in, never rebase a reviewed head.
<a id="review-scope-ledger"></a>
### `REVIEW_SCOPE_LEDGER`
**Meaning.** `gstack-review-scope summary` could not count the review's
dispatch ledgers, so it wrote no `REVIEW_LOOP:` line rather than a count that
might be low (GST-951). Either no ledger was named (`--run`, `--ledger` or
`--no-ledger`), `--run` holds no `review-ledger.jsonl`, or a ledger, the
`merged.jsonl` it names or its judged set is unreadable, or the judged set
changed after `judge` checked it. The ledgers hold every reader's findings
(specialists, Red Team, both adversarial voices, targeted, fix-check) before any
review-log row classifies them, which is why the count reads them.
**Fix.** Pass `--run <run dir>`, the directory the review passes wrote (on
Capy, `ship/DRIVER.md`'s run directory), or each `--ledger <f>`. Pass
`--no-ledger` only when no reader ran through `gstack-review-dispatch`. When a
file moved, restore it; when a judged set changed, rerun `gstack-review-dispatch
judge` on it.
<a id="receipt-carry-refused"></a>
### `RECEIPT_CARRY_REFUSED`
@@ -6210,7 +6327,7 @@ QM registration does not work yet. QM's skill-pack import clones the whole repos
## Module loadouts (gstack-modules)
These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modules` (guide: [loadouts.md](loadouts.md)). Each one exits 2 before anything is written, and none falls back to the `full` loadout: an eval arm that silently rendered `full` would measure the control.
These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modules` (guide: [loadouts.md](loadouts.md)). Each one exits 2 before anything is written, and none falls back to the `full` loadout: an eval arm that silently rendered `full` would measure the control. The exceptions are the install-time notices `LOADOUT_INVALID_MODEL`, `LOADOUT_MISMATCH` and `LOADOUT_FALLBACK` (from `bin/gstack-modules install`, the Capy installer and `gstack-skill-start`): an install never fails on them and serves `full` instead.
<a id="loadouts-invalid"></a>
### `LOADOUTS_INVALID: modules/loadouts.json …`
@@ -6222,6 +6339,21 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul
**Meaning.** `--loadout` or the first token of `--modules` names no loadout in `modules/loadouts.json`. **Fix.** Use a name from the `valid:` list (`bin/gstack-modules list` prints them); `full` is always present.
<a id="loadout-invalid-model"></a>
### `LOADOUT_INVALID_MODEL: "<id>" is not a plain model id; using full`
**Meaning.** The model id given to the installer (`--model`) or in `GSTACK_MODEL` is not a plain id after normalizing (lowercase letters, digits, `.`, `_` and `-`, at most 80 characters), so it never reaches a launch file or a start line. The full skills are served; nothing failed. **Fix.** Pass the model id as your model list names it, for example `--model claude-opus-5-5` or `--model anthropic/claude-opus-5-5` ([loadouts](loadouts.md#how-gstack-picks-a-loadout)).
<a id="loadout-mismatch"></a>
### `LOADOUT_MISMATCH: <baked id> vs <running id>: read <full root>/<skill>/SKILL.md now`
**Meaning.** The skill you opened is the lite render made for one model, and `GSTACK_MODEL` says this session runs another. Lite is measured only on the baked model. **Fix.** Read the named full SKILL.md and follow it instead of the lite one. To stop it recurring, run the installer with this session's model (`scripts/install.sh --model <id>`) and read skills from the `SKILL_ROOT` it prints.
<a id="loadout-fallback"></a>
### `LOADOUT_FALLBACK: <reason>; serving full`
**Meaning.** A lite tree could not be published (a render or dangling-reference failure, an unwritable state root, or a published folder whose files no longer match its `loadout.lock.json`), so the installer served the full skills and printed `SKILL_ROOT` for them. Nothing else changed. **Fix.** Fix the named reason; for a folder whose files changed, delete `<state root>/loadouts/<loadout>-<digest>` and rerun the installer, which publishes it again.
<a id="module-spec-invalid"></a>
### `MODULE_SPEC_INVALID: …`
@@ -6251,3 +6383,27 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul
### `RENDER_DEST_NOT_OWNED: <dir> exists and holds no loadout.lock.json`
**Meaning.** `--out-dir` is a non-empty directory that an earlier `gstack-modules render` did not write, so replacing it could delete someone's files. **Fix.** Pass a new or empty directory, or the directory of an earlier render.
## Per-repo cleanup (gstack-clean)
These codes come from `bin/gstack-clean`, which reclaims old codex scratch in `<repo>/.gstack/tmp/`, browse logs over 1 MB and, with `--audit-days N`, old browse audit entries. It changes nothing unless you pass `--apply`, and `gstack-clean --help` lists what it touches.
<a id="gstack-clean-usage"></a>
### `gstack-clean: an unknown flag or a bad --audit-days value … (GSTACK_CLEAN_USAGE)`
**Meaning.** A flag is not one of `--apply`, `--audit-days N`, `--json` or `--help`, or `--audit-days` got something other than a whole number of days from 1 to 99999. The line quotes the bad value and the last line is the usage line. Exit 2; nothing was read or changed. **Fix.** `gstack-clean --audit-days 30`, for example.
<a id="gstack-clean-target-refused"></a>
### `gstack-clean: the folder to clean is not a safe per-repo .gstack folder … (GSTACK_CLEAN_TARGET_REFUSED)`
**Meaning.** gstack-clean only cleans `<git toplevel>/.gstack` for the directory you run it in. It refuses outside a git checkout, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` points at a different folder's `browse.json`, and when the folder is, holds or sits inside the gstack state root (`~/.gstack` by default), where your learnings and settings live. Exit 3; nothing was touched. **Fix.** Run it from inside the repository, unset `BROWSE_STATE_FILE` (or point it at this repository's `.gstack/browse.json`), and replace a symlinked `.gstack` with a real folder.
<a id="gstack-clean-partial"></a>
### `FAILED <path> <errno>` … `(GSTACK_CLEAN_PARTIAL)`
**Meaning.** One or more removals or cuts failed, usually `EACCES` or `EPERM` (a read-only file or folder) or `EBUSY` (another program holds the file). Every other action finished. Exit 1. **Fix.** Rerunning is safe: fix the permissions on each FAILED path, or wait for the program holding it to finish, then run `gstack-clean --apply` again.
<a id="gstack-clean-internal"></a>
### `gstack-clean: unexpected gstack-clean failure … (GSTACK_CLEAN_INTERNAL)`
**Meaning.** gstack-clean hit an error it does not expect; the line names it. Exit 1. Changes made before the error stay made, and each was checked right before it was made. **Fix.** Rerun once; if it repeats, report the printed error to the gstack maintainers.
+100 -2
View File
@@ -172,7 +172,8 @@ unpushed head is `QUEUE_RECEIPT_STALE` to the queue.
The receipt's `review_loop` object, written from `gstack-review-scope summary
--since <REVIEW_SINCE>`, is the loop's machine-readable outcome: `passes`
(Step 9 full passes this ship ran, 1 or 2; up to 3 under `review_scope: all`),
`blocking_found` (distinct blocking findings across them), `deferred`
`blocking_found` (every blocker the readers found, rows and dispatch ledgers
both, [review-policy.md](review-policy.md#what-the-receipt-records)), `deferred`
(blocking findings in unchanged code filed as issues, Step 11's late defers
included), `noted`, `fixed` and `scope`. The
summary line a parent reads prints it as `passes=<n> blocking_found=<k>
@@ -230,6 +231,14 @@ credits outside coverage. A parent reads the rows with
`gstack-review-read` and treats `outside_status: unavailable` as missing
coverage, not as a clean outside review.
The pass program (`lib/capy-review-pass.js`) picks the outside voice's model
when `input.models['outside-voice']` is empty and the lane model is
`anthropic/`: `openai/gpt-6.1-sol` at medium reasoning, the pairing the no-loss
rounds ran. Any other lane must name a model. An outside voice from the same
provider as the lane model or the native voice stops the pass (`STOP:`) before
either voice is created, including on a rerun, and each outside voice prints an
`OUTSIDE_VOICE:` line saying whether it is the certified configuration.
### The carry across a base merge or a test-only fix
When the parent merges the base in or fixes a test after the receipt was
@@ -259,7 +268,8 @@ tree no review saw, so /ship runs again;
| 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`. |
| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`<run>-<label>` at gate revision 1, `<run>-r<rev>-<label>` after a reopen, since labels restart at each gate), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`, or `auto_kept` in a [CEO-only run](#ceo-only-runs)), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`. |
| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`<run>-<label>` at gate revision 1, `<run>-r<rev>-<label>` after a reopen, since labels restart at each gate), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`, or `auto_kept` in a [CEO-only run](#ceo-only-runs)), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`, plus how the owner answered: `owner_mode` (`rec`, `rec_except`, `tokens`, `as_is` or `unparsed`, parsed from the owner's own words), `owner_named` (his words named this item), `rec_hash` (the recommendations answered against) and `answered_role` (`human`, or `agent` relaying his words). |
| `shadow/` | private, not an artifact | [Shadow mode](#shadow-mode) only: `ceo-only.r<rev>.jsonl` (one hidden prediction per gate item, written before the answer), `r<rev>.prompt.md` and one `<call>.reply.txt` per model call, the audit `salt`, and `r<rev>.pending` while a call is in flight. Never in `export --public`, the gate render or the owner brief. |
| `findings.jsonl` | `findings` v1 | One row per reviewer finding: `id`, `phase`, `voice` (`native`/`outside`), `model`, `severity`, `title`, `disposition`, `plan_items`, `native_counterpart` (string or null), `source`. Rows share the review-log row shape, so `gstack-review-log --findings` derives review status from this file. |
| `tasks.jsonl` | `tasks` v2 | Implementation tasks with `id`, `title`, `status`, `depends_on`, `findings` (ids in `findings.jsonl`), `blocked_by`, `acceptance`, optional `pr`/`item`/`tier`. |
| `timing.json` | `timing` v1 | Per-phase `started_at`, `ended_at`, `wall_s`, `outside_s`, `native_s`; `started_at` and `total_wall_s` for the run; the Phase 0 `estimate`. |
@@ -651,6 +661,94 @@ approves, so `gstack-artifact validate` accepts the run as it stands.
does: `budget` then `resume`, or `close --phase ceo --deferred`, which ends
it `reviewed`.
### Shadow mode
Shadow mode measures how often a model decider would pick what the owner
picks, before the CEO-only mode is allowed to decide for him. In a driver run
that turned it on, every gate the driver publishes gets one hidden prediction
per question, written before anyone answers: `action` (`decide`,
`send_back` or `hold`), `pick` (one of that item's options), `confidence`
(0 to 1), `cites` (finding ids from that item) and `reason` (at most 400
characters). The owner still gets the gate and answers it as usual; nothing
routes differently. Taste-replay round 2 (R2, in gstack-evals) compares the
predictions with his answers.
- **Off by default.** A crew turns it on with `gstack-config set
autoplan_shadow on`; `GSTACK_AUTOPLAN_SHADOW=1` or `0` overrides it for one
run. It is read once, at the run's first `next`, and stored in `run.json`
(`shadow`), so a resumed run cannot flip it. The first `next` prints
`SHADOW: on model=<m> ...`, or `SHADOW: off (<reason>)` when it was asked for
but cannot run: no API key for the model's family, a model with no price in
`lib/pricing.ts`, `--no-outside`, or a CEO-only run. R2's collection window
starts at the first recorded row on a crew gate, not at this release: check
one `SHADOW: recorded` line on a real gate before counting days.
- **The model and the cost.** `autoplan_shadow_model` (default
`claude-opus-5-5`) gets one call per gate: about $0.07 on your own API key.
The call sends the reviewed plan's Implementation plan section (cut to 40,000
characters) and the gate questions to that provider, with an egress receipt
of sink `autoplan-shadow` and consent `autoplan_shadow on`. Each call is
reserved against `--spend-cap` under its own ledger id labeled `shadow` and
shows as `shadow=<usd>` on `GATE_SPEND`; it never changes the per-voice price
on `GATE_REC_COST`. When the cap cannot cover a call and its retry, the
shadow is skipped (`SHADOW: failed budget`).
- **The decider.** `autoplan/shadow-decider.md` is a frozen, hand-written
prediction prompt (it is not generated from the CEO-only section). It shows
the reviewers' recommendation as one labeled input and gives the six decision
principles as the owner's tendencies, not as rules, so the model can
disagree with the recommendation. Its sha256 and version are on every row;
an edit bumps both.
- **The reply.** Exactly one JSON object per question. A duplicate, missing
or invalid object marks that item malformed and the page is asked once more;
items still malformed are recorded malformed with no pick. A reply cut at the
4,000-token output limit is asked once more at 8,000 instead. One 90-second
deadline covers both calls.
- **When it runs.** Every driver command that prints a gate (`next`,
`submit`, `close`, `resume`, `revise`, `run` and `check`) runs the
shadow first, prints `SHADOW: recorded model=<m> usd=<x>` or `SHADOW: failed
<code>` (status only, never a pick), then the gate, then checkpoints, so a
checkpoint taken at a gate holds its record. A whole-gate carry asks nothing,
so it gets no shadow. Interactive `/autoplan` and CEO-only runs write none.
A shadow failure (`budget`, `no_key`, `timeout`, `http`, `network`,
`truncated`, `unsafe_dir`, `locked`, `internal`) never blocks the gate or the answer. A
failure while publishing the record (`locked` or `internal`) is journaled
(`shadow_failed`) under the run lock and never throws; if the lock stays
taken, the lease expires and the next command settles that revision.
- **The lease.** The run lock is not held during the call: the command takes
a lease (`shadow/r<rev>.pending`, with its pid, host and deadline) under the
lock, calls the model, then retakes the lock (waiting up to 30 seconds) to
publish. Another writer that finds a live lease prints `SHADOW: pending` and
pays nothing, and `answer` waits for the record. A lease whose process died
or whose deadline passed is a crash: its sent calls settle `unknown` at
their reservation (they may have been billed), and `answer` records that
revision first with every row `retrospective: true`, which R2 excludes from
prospective evidence.
- **The record.** `shadow/ceo-only.r<rev>.jsonl`, one row per gate item: the
frozen item, `item_class` (`challenge2`, `challenge1`, `disagree`, `agent`,
`cap_deferred`, `cap_blocker`, `cap_delta`, `finding`, `p1`), `ceo_eligible`
(false for a `finding` item, which a CEO-only run leaves to the owner), the
prediction, `rule_pick` (the driver's keep-direction pick, the free
baseline), `rec_hash`, the plan and prompt hashes, the model, the gstack
version, the page's tokens and cost, and `audit`. It is written once per
revision with a link, so a second writer never overwrites it, and its sha256
is journaled (`shadow_recorded`). `gstack-artifact validate <run.json>`
checks each record against that hash and its schema, and reports a
journaled revision whose record file is missing as `ARTIFACT_STALE`.
- **The 10% audit mark.** A `decide` row carries `audit: true` when
sha256(salt + decision id) mod 1000 < 100, with the salt drawn once per run
into `shadow/salt`. Nothing routes on it yet; the live switch that lets the
mode decide alone will send these items to the owner.
- **The reader.** `gstack-autoplan shadow --out <dir> [--json]`, or `--runs
<file>` with one run directory per line, exports one row per item of each
answered gate revision: the shadow status (`recorded`, `malformed`,
`failed` with its code, `missing`, `retrospective`, or `off` when the run
never turned it on), the prediction, and the owner's answer with its
provenance. It prints counts by status, item class, `owner_mode` and
`ceo_eligible` and computes no agreement number. A revision whose plan
approval is unanswered or answered `c` (interrogate) refuses
`SHADOW_BEFORE_ANSWER`, and a CEO-only run `CEO_ONLY_NO_GATE`. The refusal is
a courtesy for honest tools, not access control: the journaled hash is the
tamper evidence.
## Owner brief
`gstack-owner-brief render --out <dir>` is what the owner sees at the final
+5
View File
@@ -105,15 +105,20 @@ Run with `browse <command> [args]`. Full reference: `browse/SKILL.md`.
- `cookie <name>=<value>`: Set cookie on current page domain
- `cookie-import <json>`: Import cookies from JSON file
- `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc.
- `device [<name> [--landscape] | off | --list]`: Emulate a phone on the current tab only: size, pixel ratio, touch support (pointer: coarse) and user agent from Playwright's devices table (names match case-insensitively).
- `dialog-accept [text]`: Auto-accept next alert/confirm/prompt.
- `dialog-dismiss`: Auto-dismiss next dialog
- `fill <sel> <val>`: Fill input
- `header <name>:<value>`: Set custom request header (colon-separated, sensitive values auto-redacted)
- `hover <sel>`: Hover element
- `pinch <@ref|selector|x y> <scale>`: Two-finger pinch around an element's center or x y: scale above 1 spreads the fingers (zoom in), below 1 brings them together.
- `press <key>`: Press a Playwright keyboard key against the focused element.
- `scroll [sel|@ref]`: With a selector, smooth-scrolls the element into view.
- `select <sel> <val>`: Select dropdown option by value, label, or visible text
- `style <sel> <prop> <value> | style --undo [N]`: Modify CSS property on element (with undo support)
- `swipe <@ref|selector|x y> <up|down|left|right> [--distance <px>] [--ms <n>]`: One-finger swipe from an element's center or x y; the finger moves in the given direction (default 300px over 300ms, 10 moves).
- `tap <@ref|selector|x y> [--hold <ms>]`: Touch and release at an element's center (scrolled into view) or at x y.
- `touch [down|move <finger 0-9> <x> <y> | up <finger> | cancel]`: Hold, move and lift numbered fingers for multi-finger flows (hold a joystick with finger 0, tap fire with finger 1).
- `type [--selector <sel>] [--] <text>`: Send keystrokes to the focused element, or to <sel> with --selector (focuses it first).
- `upload <sel> <file> [file2...]`: Upload file(s)
- `useragent <string>`: Set user agent
+9 -32
View File
@@ -115,45 +115,22 @@ When the user types `/health`, run this skill.
## Step 1: Detect Health Stack
Read CLAUDE.md and look for a `## Health Stack` section. If found, parse the tools
listed there and skip auto-detection.
If no `## Health Stack` section exists, auto-detect available tools:
Detect the tools with gstack's detector:
```bash
# Type checker
[ -f tsconfig.json ] && echo "TYPECHECK: tsc --noEmit"
# Linter
[ -f biome.json ] || [ -f biome.jsonc ] && echo "LINT: biome check ."
setopt +o nomatch 2>/dev/null || true
ls eslint.config.* .eslintrc.* .eslintrc 2>/dev/null | head -1 | xargs -I{} echo "LINT: eslint ."
[ -f .pylintrc ] || [ -f pyproject.toml ] && grep -q "pylint\|ruff" pyproject.toml 2>/dev/null && echo "LINT: ruff check ."
# Test runner
[ -f package.json ] && grep -q '"test"' package.json 2>/dev/null && echo "TEST: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts.test)" 2>/dev/null)"
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml 2>/dev/null && echo "TEST: pytest"
[ -f Cargo.toml ] && echo "TEST: cargo test"
[ -f go.mod ] && echo "TEST: go test ./..."
# Dead code
command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip"
[ -f package.json ] && grep -q '"knip"' package.json 2>/dev/null && echo "DEADCODE: npx knip"
# Shell linting
command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck"
# GBrain presence — only report as a dimension if gbrain is actually
# set up; otherwise skip so machines without gbrain aren't penalized.
if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then
echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)"
fi
C="$HOME/.claude/skills/gstack/bin/gstack-checks"; "$C" detect --for health
```
It reads files and PATH only and runs nothing. A `## Health Stack` section in
CLAUDE.md wins: its rows come back as they are, and auto-detection is skipped.
Each `DIMENSION <name> <command> (<source>)` line is a tool to run in Step 2.
`DIMENSION <name> none (<reason>)` means that category is unavailable: record it
as `SKIPPED` with the reason. A `gbrain` command keeps its `timeout 5s`.
Use Glob to search for shell scripts:
- `**/*.sh` (shell scripts in the repo)
After auto-detection, present the detected tools via AskUserQuestion:
After auto-detection (no line's source is `CLAUDE.md Health Stack`), present the detected tools via AskUserQuestion:
"I detected these health check tools for this project:
+9 -32
View File
@@ -42,45 +42,22 @@ When the user types `/health`, run this skill.
## Step 1: Detect Health Stack
Read CLAUDE.md and look for a `## Health Stack` section. If found, parse the tools
listed there and skip auto-detection.
If no `## Health Stack` section exists, auto-detect available tools:
Detect the tools with gstack's detector:
```bash
# Type checker
[ -f tsconfig.json ] && echo "TYPECHECK: tsc --noEmit"
# Linter
[ -f biome.json ] || [ -f biome.jsonc ] && echo "LINT: biome check ."
setopt +o nomatch 2>/dev/null || true
ls eslint.config.* .eslintrc.* .eslintrc 2>/dev/null | head -1 | xargs -I{} echo "LINT: eslint ."
[ -f .pylintrc ] || [ -f pyproject.toml ] && grep -q "pylint\|ruff" pyproject.toml 2>/dev/null && echo "LINT: ruff check ."
# Test runner
[ -f package.json ] && grep -q '"test"' package.json 2>/dev/null && echo "TEST: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts.test)" 2>/dev/null)"
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml 2>/dev/null && echo "TEST: pytest"
[ -f Cargo.toml ] && echo "TEST: cargo test"
[ -f go.mod ] && echo "TEST: go test ./..."
# Dead code
command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip"
[ -f package.json ] && grep -q '"knip"' package.json 2>/dev/null && echo "DEADCODE: npx knip"
# Shell linting
command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck"
# GBrain presence — only report as a dimension if gbrain is actually
# set up; otherwise skip so machines without gbrain aren't penalized.
if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then
echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)"
fi
C="{{RUNTIME_ROOT}}/bin/gstack-checks"; "$C" detect --for health
```
It reads files and PATH only and runs nothing. A `## Health Stack` section in
CLAUDE.md wins: its rows come back as they are, and auto-detection is skipped.
Each `DIMENSION <name> <command> (<source>)` line is a tool to run in Step 2.
`DIMENSION <name> none (<reason>)` means that category is unavailable: record it
as `SKIPPED` with the reason. A `gbrain` command keeps its `timeout 5s`.
Use Glob to search for shell scripts:
- `**/*.sh` (shell scripts in the repo)
After auto-detection, present the detected tools via AskUserQuestion:
After auto-detection (no line's source is `CLAUDE.md Health Stack`), present the detected tools via AskUserQuestion:
"I detected these health check tools for this project:
+5 -4
View File
@@ -321,10 +321,10 @@ export function serveDir(root: string, nonce: string = randomBytes(16).toString(
// ─── Async spawn (keeps the loopback server's event loop free) ────────────────
async function runProc(cmd: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string; error?: string }> {
async function runProc(cmd: string, args: string[], timeoutMs: number, env?: Record<string, string | undefined>): Promise<{ code: number | null; stdout: string; stderr: string; error?: string }> {
let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
try {
child = Bun.spawn([cmd, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
child = Bun.spawn([cmd, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', ...(env ? { env } : {}) });
} catch (e) {
return { code: null, stdout: '', stderr: '', error: (e as Error).message };
}
@@ -529,8 +529,9 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
if (target.kind === 'error') return fail(target.error);
const deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const browseEnv = { ...process.env, BROWSE_SESSION: '' };
const run = async (args: string[]): Promise<string> => {
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())));
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())), browseEnv);
log.push(`$ browse ${args.join(' ').slice(0, 300)}\n${r.stdout}${r.stderr}`.trim());
if (r.error || r.code !== 0) {
const first = (r.stderr || r.stdout || r.error || '').trim().split('\n')[0];
@@ -647,7 +648,7 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
} catch (e) {
return fail((e as Error).message);
} finally {
if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000);
if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000, browseEnv);
srv?.stop();
if (work) fs.rmSync(work, { recursive: true, force: true });
}
+5 -2
View File
@@ -151,10 +151,13 @@ export function changeBudget(state: RunState, o: BudgetChange): string[] {
return lines;
}
/** Reservations with no dispatch journaled (a crash between reserve and journal) are proven never dispatched, so released. */
/**
* Reservations with no dispatch journaled (a crash between reserve and journal) are proven never dispatched, so released.
* A shadow call journals `shadow_dispatched` before its send: it counts as dispatched and is never released here.
*/
export function releaseUndispatched(state: RunState): string[] {
const file = path.join(state.out, FILES.spend);
const dispatched = new Set(readJournal(state.out).filter(r => r.event === 'dispatched').map(r => r.attempt));
const dispatched = new Set(readJournal(state.out).filter(r => r.event === 'dispatched' || r.event === 'shadow_dispatched').map(r => r.attempt));
return readLedger(file).reserved.filter(r => !dispatched.has(r.attempt)).map(r => {
release(file, r.attempt);
journal(state.out, { event: 'released_undispatched', attempt: r.attempt, estimate_usd: r.estimate_usd });
+2 -3
View File
@@ -21,7 +21,7 @@ import { buildGate, finishGate } from './autoplan-gate';
import { FINDING_ORIGIN } from './autoplan-confirm';
import { carryLines } from './autoplan-carry';
import { aggregateFindings, exportRun } from './autoplan-export';
import { decisionRows, keepDirection, type GateItem } from './gate-list';
import { decisionRows, ruleKept, type GateItem } from './gate-list';
import { MARKER_RE } from './autoplan-scope';
import { RunError, journal, voiceRows, writeDecisions, type RunState } from './autoplan-state';
import type { DecisionRow, FindingRow } from './headless-artifacts';
@@ -64,8 +64,7 @@ export function finishRun(state: RunState): string[] {
if (plan) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path ${report}`, 3);
const list = buildGate(state, 1);
const items = list.items.filter(i => i.id !== 'p1' && !i.origin?.endsWith(`|${FINDING_ORIGIN}`));
const kept = (item: GateItem) => (item.cap && item.kind === 'approval' ? null : keepDirection(item));
const rows: DecisionRow[] = decisionRows({ ...list, items }).map((row, i) => ({ ...row, kind: 'auto', status: 'auto_kept', chosen: kept(items[i]!), default_if_unanswered: kept(items[i]!), source: 'ceo-only' }));
const rows: DecisionRow[] = decisionRows({ ...list, items }).map((row, i) => ({ ...row, kind: 'auto', status: 'auto_kept', chosen: ruleKept(items[i]!), default_if_unanswered: ruleKept(items[i]!), source: 'ceo-only' }));
writeDecisions(state.out, rows);
state.status = 'reviewed';
exportRun(state);
+10 -5
View File
@@ -18,8 +18,10 @@ import { readLedger, summarize, type SpendSummary } from './spend-ledger';
export interface GateCost {
summary: SpendSummary;
/** Attempts the ledger has seen: settled plus still reserved. */
/** Reviewer attempts the ledger has seen: settled plus still reserved, shadow calls excluded. */
attempts: number;
/** What shadow calls (ledger label `shadow`, lib/autoplan-shadow.ts) spent or hold: in summary and the cap, never in the per-voice price. */
shadow_usd: number;
/** Phases a `rec` reply would reopen through its plan-changing recommendations, in run order; empty when none changes the plan. */
reopens: string[];
voices: number;
@@ -72,11 +74,14 @@ export function priorChoices(outDir: string, gateRev: number): Record<string, st
export function gateCost(outDir: string, list: GateList, run: GateCostRun, o: { defaultPerVoiceUsd?: number } = {}): GateCost {
const ledger = readLedger(path.join(outDir, 'spend.json'), run.spend_cap_usd);
const summary = summarize(ledger);
const seen = [...ledger.settled.map(s => s.reserved_usd), ...ledger.reserved.map(r => r.estimate_usd)].filter(n => n > 0);
const voice = (r: { label?: string }) => r.label !== 'shadow';
const settled = ledger.settled.filter(voice), reserved = ledger.reserved.filter(voice);
const seen = [...settled.map(s => s.reserved_usd), ...reserved.map(r => r.estimate_usd)].filter(n => n > 0);
const per_voice_usd = seen.length ? seen[seen.length - 1]! : o.defaultPerVoiceUsd ?? 0;
const reopens = recReopens(list, run, priorChoices(outDir, list.gate_rev));
const voices = reopens.length * (run.outside_enabled ? 2 : 1);
return { summary, attempts: ledger.settled.length + ledger.reserved.length, reopens, voices, per_voice_usd, rec_more_usd: Math.round(voices * per_voice_usd * 100) / 100 };
const shadow_usd = Math.round([...ledger.settled.filter(s => !voice(s)).map(s => (s.usd === 'unknown' ? s.reserved_usd : s.usd)), ...ledger.reserved.filter(r => !voice(r)).map(r => r.estimate_usd)].reduce((a, b) => a + b, 0) * 1e6) / 1e6;
return { summary, attempts: settled.length + reserved.length, shadow_usd, reopens, voices, per_voice_usd, rec_more_usd: Math.round(voices * per_voice_usd * 100) / 100 };
}
/** The two printed lines: the ledger at this gate, and the price of `rec`. */
@@ -87,7 +92,7 @@ export function renderGateCost(c: GateCost): string[] {
const unknown = s.unknown_count ? `; ${s.unknown_count} host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host's cost view)` : '';
const after = s.headroom_usd === null ? '' : ` → headroom ${usd(s.headroom_usd - c.rec_more_usd)}${s.headroom_usd - c.rec_more_usd < 0 ? ' (over the cap: raise it with gstack-autoplan budget before answering rec)' : ''}`;
return [
`GATE_SPEND: spent=${usd(s.spent_usd)} worst_case=${usd(s.worst_case_usd)} cap=${cap} headroom=${headroom} attempts=${c.attempts}${unknown}`,
`GATE_SPEND: spent=${usd(s.spent_usd)} worst_case=${usd(s.worst_case_usd)} cap=${cap} headroom=${headroom} attempts=${c.attempts}${c.shadow_usd ? ` shadow=${c.shadow_usd.toFixed(4)}` : ''}${unknown}`,
c.reopens.length
? `GATE_REC_COST: rec reopens up to ${c.reopens.join(',')} ≈ ${c.voices} voice(s) × ${usd(c.per_voice_usd)} = +${usd(c.rec_more_usd)} at most (the run's per-voice reservation)${after}`
: 'GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00',
@@ -97,7 +102,7 @@ export function renderGateCost(c: GateCost): string[] {
/** The JSON payload beside the printed lines. */
export function gateCostPayload(c: GateCost): Record<string, unknown> {
const s = c.summary;
return { spent_usd: s.spent_usd, worst_case_usd: s.worst_case_usd, cap_usd: s.cap_usd, headroom_usd: s.headroom_usd, attempts: c.attempts, unknown_count: s.unknown_count, rec_reopens: c.reopens, rec_voices: c.voices, per_voice_usd: c.per_voice_usd, rec_more_usd: c.rec_more_usd };
return { spent_usd: s.spent_usd, worst_case_usd: s.worst_case_usd, cap_usd: s.cap_usd, headroom_usd: s.headroom_usd, attempts: c.attempts, ...(c.shadow_usd ? { shadow_usd: c.shadow_usd } : {}), unknown_count: s.unknown_count, rec_reopens: c.reopens, rec_voices: c.voices, per_voice_usd: c.per_voice_usd, rec_more_usd: c.rec_more_usd };
}
/** One sentence for the owner brief. */
+22 -19
View File
@@ -28,8 +28,8 @@ import { evaluateCarry, gateEvidence, voidPendingCheck } from './autoplan-carry'
import { buildEli5, capyRelayLine } from './captain-brief';
import { FINDING_ORIGIN, previousReview, reviewRows, settledIds } from './autoplan-confirm';
import { phaseClose } from './autoplan-timing';
import { ANSWERED_BY_RE, applyReply, autoDefault, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
import type { FindingRow } from './headless-artifacts';
import { ANSWERED_BY_RE, applyReply, autoDefault, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, replyShape, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
import type { DecisionRow, FindingRow } from './headless-artifacts';
import { readJsonl, sha256File } from './headless-artifacts';
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
import { vaguePlanTerms } from './plan-reality';
@@ -133,21 +133,21 @@ function capItems(state: RunState, next: { d: () => string; uc: () => string }):
const rows: OpenRow[] = [...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside'),
...(def.carried_file ? readJsonl(path.join(state.out, def.carried_file)).rows as unknown as OpenRow[] : [])];
push({ id: next.d(), title: `${phase} closed accepted-with-findings at round ${def.round} (${def.reason}): ${def.open} open finding(s), ${severityText(def.counts)}, carried into tasks.jsonl`,
kind: 'auto', recommended: 'a', phase, cap: { phase, kind: 'deferred' }, why: `closed deferred by ${def.by}`, cost: `b grants ${phase} one more round`,
kind: 'auto', recommended: 'a', phase, cap: { phase, kind: 'deferred' }, item_class: 'cap_deferred', why: `closed deferred by ${def.by}`, cost: `b grants ${phase} one more round`,
options: [{ key: 'a', text: 'accept with findings, deferred to the build', changes_plan: false }, reopen(phase)] }, `deferred-${phase}`);
for (const id of def.blockers) {
const f = rows.find(r => r.id === id);
if (!f) continue;
const risk = f.severity === 'Critical' || f.urgent;
push({ id: next.uc(), title: `${f.severity}${f.urgent ? ' urgent' : ''}${f.user_challenge ? ' user challenge' : ''} left open when ${phase} closed deferred: ${f.title} [${id}]`,
kind: 'approval', recommended: risk ? 'b' : 'a', recommended_basis: risk ? 'an open Critical or urgent finding' : 'plan direction; a user challenge', phase, cap: { phase, kind: 'blocker', ref: id },
kind: 'approval', recommended: risk ? 'b' : 'a', recommended_basis: risk ? 'an open Critical or urgent finding' : 'plan direction; a user challenge', phase, cap: { phase, kind: 'blocker', ref: id }, item_class: 'cap_blocker',
why: `${phase} closed deferred (${def.reason}) with this finding open; only you can accept it`,
options: [{ key: 'a', text: 'carry it into the build as a blocking task (you accept the risk)', changes_plan: false }, reopen(phase)] }, id);
}
}
for (const delta of pendingDeltas(state)) {
push({ id: next.uc(), title: `plan changed after ${delta.phase} cap: unreviewed delta (+${delta.added} -${delta.removed} lines, ${delta.old_plan_sha256.slice(0, 12)} -> ${delta.new_plan_sha256.slice(0, 12)})`,
kind: 'approval', recommended: 'a', recommended_basis: 'the run budget; the delta is reviewed during the build', phase: delta.phase, cap: { phase: delta.phase, kind: 'delta', ref: String(delta.k) },
kind: 'approval', recommended: 'a', recommended_basis: 'the run budget; the delta is reviewed during the build', phase: delta.phase, cap: { phase: delta.phase, kind: 'delta', ref: String(delta.k) }, item_class: 'cap_delta',
why: `revise v${delta.k} changed the plan after ${delta.phase} used round ${delta.round} of ${delta.cap}, so ${delta.phase} never reviewed it`,
options: [{ key: 'a', text: 'accept the unreviewed delta into the approved plan', changes_plan: false }, reopen(delta.phase)] }, `delta-${delta.k}`);
}
@@ -189,7 +189,7 @@ export function challengeDefault(row: ReconciliationRow, n: FindingRow | undefin
/** A rerun challenge naming no scope, cost or direction: the agent's own decision, recorded, reopening nothing. */
function agentDecision(id: string, title: string, phase: string, why: string): GateItem {
return { id, title, kind: 'auto', recommended: 'a', phase, reopens: false, why: `${why}; it names no scope, cost or direction change, so on a rerun it is the agent's decision`,
return { id, title, kind: 'auto', recommended: 'a', phase, reopens: false, item_class: 'agent', why: `${why}; it names no scope, cost or direction change, so on a rerun it is the agent's decision`,
cost: 'agent decision; b is recorded, no phase reruns', options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change (agent decision; no phase reruns)', changes_plan: false }] };
}
@@ -218,14 +218,14 @@ export function buildGate(state: RunState, gateRev: number): GateList {
const title = `${n.title} [${row.native_id} + ${row.outside_id}]`;
if (reran && !n.changes && !o.changes) { push(agentDecision(`d${++d}`, title, phase, 'both voices recommend changing your stated direction'), ids); continue; }
const def = challengeDefault(row, n, o);
push({ id: `uc${++uc}`, title, kind: 'approval', recommended: def.recommended, recommended_basis: def.basis, phase, ...(n.changes ?? o.changes ? { changes: n.changes ?? o.changes } : {}),
push({ id: `uc${++uc}`, title, kind: 'approval', recommended: def.recommended, recommended_basis: def.basis, phase, item_class: 'challenge2', ...(n.changes ?? o.changes ? { changes: n.changes ?? o.changes } : {}),
why: `both voices recommend changing your stated direction; blind spot: ${o.title}; cost if wrong: ${o.resolution ?? n.resolution ?? 'unstated'}`,
options: [KEEP('keep your direction (the plan as reviewed)'), { key: 'b', text: def.text, changes_plan: true }] }, ids);
continue;
}
if (row.disposition === 'disagree') {
challenged.add(o.id); challenged.add(n!.id);
push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase,
push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase, item_class: 'disagree',
why: `voices disagree: ${row.resolution}`, cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`,
options: [{ key: 'a', text: `native position (${row.severity.native})`, changes_plan: false }, { key: 'b', text: `outside position (${row.severity.outside})`, changes_plan: true }] }, ids);
}
@@ -234,7 +234,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
if (!f.user_challenge || challenged.has(f.id) || f.settled_by) continue;
const why = `one voice (${f.voice}) recommends changing your stated direction`;
if (reran && !f.changes) { push(agentDecision(`d${++d}`, `${f.title} [${f.id}]`, phase, why), f.id); continue; }
push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, why, ...(f.changes ? { changes: f.changes } : {}),
push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, item_class: 'challenge1', why, ...(f.changes ? { changes: f.changes } : {}),
cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id);
}
// An open Critical or High finding the items above do not cover: rec applies its fix to the plan.
@@ -247,7 +247,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
if (!severity) continue;
const ids = group.map(g => g.id);
const fixes = [...new Set(group.map(g => g.resolution).filter((r): r is string => !!r))];
push({ id: `d${++d}`, title: `${severity}: ${f.title} [${ids.join(' + ')}]`, kind: 'auto', recommended: 'a', phase, finding: ids,
push({ id: `d${++d}`, title: `${severity}: ${f.title} [${ids.join(' + ')}]`, kind: 'auto', recommended: 'a', phase, item_class: 'finding', finding: ids,
why: `an open ${severity} finding (${group.map(g => g.voice).join(' + ')}); rec applies its fix to the plan`, cost: `a reopens ${phase}${phase === 'eng' ? '' : ' and eng'} once the plan is revised`,
options: [{ key: 'a', text: `apply the fix to the plan: ${fixes.length ? fixes.join('; ') : `resolve "${f.title}"`}`, changes_plan: true }, { key: 'b', text: 'carry it into the build as a blocking task (no plan change)', changes_plan: false }] }, ids.join('+'), FINDING_ORIGIN);
}
@@ -266,7 +266,7 @@ function planApproval(state: RunState, items: GateItem[]): GateItem {
const changes = items.filter(i => i.id !== 'p1' && i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`);
const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan;
const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`);
return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}),
return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', item_class: 'p1', ...(flags.length ? { flags } : {}),
options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' },
{ key: 'c', text: 'interrogate (ask questions; stays pending)' },
{ key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] };
@@ -304,7 +304,7 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string,
}
/** The gate list the owner last answered: gate.json, or its archive (`gate.r<rev>[.v<k>].json`) once a reopen moved it. */
function previousGate(out: string, rev: number): GateList | undefined {
export function previousGate(out: string, rev: number): GateList | undefined {
if (rev < 1) return undefined;
const re = new RegExp(`^gate\\.r${rev}(?:\\.v(\\d+))?\\.json$`);
const archived = fs.readdirSync(out).map(f => [f, re.exec(f)] as const).filter(([, m]) => m).sort((a, b) => Number(b[1]![1] ?? 1) - Number(a[1]![1] ?? 1)).map(([f]) => f);
@@ -380,15 +380,15 @@ export function amendmentSteps(state: RunState, out: string): string {
edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : ''].filter(Boolean).join('; then ');
}
/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. */
function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): boolean {
/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. Returns the owner's parse. */
function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): ParsedReply {
const owner = parseReply(ownerReply, list, gateRev, prior);
if (!owner.valid) return false;
if (!owner.valid) return owner;
const choices = (p: ParsedReply) => JSON.stringify(p.choices.map(c => [c.id, c.chosen]).sort());
if (choices(owner) !== choices(relayed) || owner.pending.join() !== relayed.pending.join()) {
throw new RunError('GATE_OWNER_REPLY_MISMATCH', `the owner's reply ${JSON.stringify(ownerReply)} resolves to ${owner.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${owner.pending.length ? ` (pending ${owner.pending.join(',')})` : ''}; the relayed reply resolves to ${relayed.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${relayed.pending.length ? ` (pending ${relayed.pending.join(',')})` : ''}`, 3);
}
return true;
return owner;
}
/**
@@ -418,16 +418,19 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
if (takesRecs && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); \`rec\` and \`p1b\` take its recommendations, so pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
const ownerParsed = o.ownerReply !== undefined && !o.carry ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
const provenance = o.carry ? { owner_reply: o.carry.words, carried_gate_from: o.carry.from, carried_gate_rev: o.carry.gate_rev, carry_reason: o.carry.reason, ...(o.carry.by ? { carried_answered_by: o.carry.by } : {}) }
: o.ownerReply !== undefined && o.wordsBy ? { answer_words: o.ownerReply, answer_words_parsed: ownerParsed, words_by: o.wordsBy }
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
: o.ownerReply !== undefined && o.wordsBy ? { answer_words: o.ownerReply, answer_words_parsed: ownerParsed!.valid, words_by: o.wordsBy }
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed!.valid } : {};
const shape = o.carry || o.wordsBy ? undefined : replyShape(o.ownerReply ?? reply, list, ownerParsed ?? parsed);
const itemOf = (id: string) => list.items.find(i => i.id === id)!;
const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!;
const plan = parsed.choices.find(c => c.id === 'p1');
const lines: string[] = o.carry ? [] : voidPendingCheck(state, `the owner answered gate_rev ${gateRev}`);
const answeredAt = now();
const owner = (r: DecisionRow): Partial<DecisionRow> => (shape && r.gate_rev === list.gate_rev && r.answered_at === answeredAt
? { owner_mode: shape.mode, owner_named: shape.named.has((r.label ?? '').toLowerCase()), rec_hash: currentHash, answered_role: answeredBy?.startsWith('agent:') ? 'agent' : 'human' } : {});
const record = (answered = parsed) => writeDecisions(state.out, applyReply(rows, answered, answeredAt, answeredBy, provenance).map(r => {
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
return c ? { ...r, chosen_text: c.text } : r;
return { ...(c ? { ...r, chosen_text: c.text } : r), ...owner(r) };
}));
if (!plan || parsed.pending.length) {
// Approvals unanswered: record what was answered, but not an auto default that p1b would change (the final answer decides it).
+2 -4
View File
@@ -23,7 +23,7 @@ import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate';
import { carryLines, voidPendingCheck } from './autoplan-carry';
import { atCap, escalateLine, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { atCap, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { diffStat, lineDiff } from './autoplan-confirm';
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text';
@@ -112,9 +112,7 @@ function approveOrRegate(state: RunState, at: string): string[] {
if (pendingDeltas(state).length) {
finishGate(state);
const carried = carryLines(state);
if (state.status !== 'gate_pending') return carried;
const escalate = escalateLine(state);
return [...carried, `GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])];
return carried;
}
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
}
+13 -7
View File
@@ -21,6 +21,7 @@ import * as path from 'node:path';
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
import { amendmentSteps, applyGuidance, closeDeferred, closePhase } from './autoplan-gate';
import { finishRun } from './autoplan-ceo-only';
import type { ShadowSwitch } from './autoplan-shadow';
import { GSTACK_VERSION } from './autoplan-migrate';
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
@@ -38,14 +39,18 @@ import { annotateConfirm, confirmBlock } from './autoplan-confirm';
/** An unreadable run.lock younger than this is a lock some other gstack is still writing, never a stale one. */
const LOCK_WRITE_GRACE_MS = 30_000;
/** Exclusive ownership for one command: a lock whose pid is alive is never stolen; a dead pid's lock is reclaimed. */
export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean } = {}): T {
/**
* Exclusive ownership for one command: a lock whose pid is alive is never stolen; a dead pid's lock is reclaimed.
* `waitMs`: retry a live owner's lock for that long before RUN_LOCKED (the shadow's publishing step, lib/autoplan-shadow.ts).
*/
export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean; waitMs?: number } = {}): T {
const waitUntil = Date.now() + (opts.waitMs ?? 0);
fs.mkdirSync(out, { recursive: true });
const lock = path.join(out, FILES.lock);
const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: now() });
const staged = `${lock}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
fs.writeFileSync(staged, mine);
for (let i = 0; ; i++) {
for (let reclaims = 0; ;) {
try {
fs.linkSync(staged, lock);
fs.unlinkSync(staged);
@@ -61,9 +66,10 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
} else if (owner.host === os.hostname() && typeof owner.pid === 'number') {
try { process.kill(owner.pid, 0); alive = true; } catch (k: any) { alive = k.code === 'EPERM'; }
} else if (owner.host && owner.host !== os.hostname()) alive = !opts.steal;
if (alive && Date.now() < waitUntil) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); continue; }
if (alive) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', unreadable ? `${lock} is being written by another command` : `pid ${owner.pid} on ${owner.host} since ${owner.at}`, 3); }
try { fs.unlinkSync(lock); } catch { /* raced with its owner's exit */ }
if (i > 50) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3); }
if (++reclaims > 50) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3); }
}
}
try { return fn(); } finally { try { if (fs.readFileSync(lock, 'utf8') === mine) fs.unlinkSync(lock); } catch { /* already gone */ } }
@@ -72,7 +78,7 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
// ---------------------------------------------------------------------------
// init
// ---------------------------------------------------------------------------
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; ceoOnly?: boolean; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; ceoOnly?: boolean; shadow?: ShadowSwitch; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
/** `lines` receives what init itself reports (SCAFFOLDED:). A plan with one section heading missing refuses, or with `scaffold` is reviewed from a scaffolded copy. */
export function initRun(o: InitOptions, lines: string[] = []): RunState {
@@ -120,12 +126,12 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
deviations: ['autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED)'],
...(o.deadlineMin ? { deadline_at: new Date(Date.now() + o.deadlineMin * 60_000).toISOString() } : {}),
...(o.light ? { light: true } : {}),
...(o.ceoOnly ? { ceo_only: true } : {}), gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
...(o.ceoOnly ? { ceo_only: true } : {}), ...(o.shadow ? { shadow: o.shadow } : {}), gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
...(o.planHeading ? { plan_heading: o.planHeading } : {}), ...(scaffolded ? { scaffolded: true } : {}),
...(o.carryForward === false ? { carry_forward: false } : {}), ...(o.carryCheckCapUsd !== undefined ? { carry_check_cap_usd: o.carryCheckCapUsd } : {}),
};
fs.writeFileSync(path.join(out, 'timing.json'), JSON.stringify({ schema_version: 1, run, phases: [], started_at: now(), session_kind: state.session_kind }, null, 2) + '\n');
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(o.ceoOnly ? { ceo_only: true } : {}), ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(o.ceoOnly ? { ceo_only: true } : {}), ...(o.shadow ? { shadow: o.shadow } : {}), ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
writeState(state);
return state;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* autoplan-shadow-reader — the read side of shadow mode (lib/autoplan-shadow.ts,
* docs/unattended.md#shadow-mode). `exportShadow` is `gstack-autoplan shadow
* --out <dir> | --runs <file>`, the export gstack-evals reads for taste-replay
* R2: one row per item of each answered gate revision, with the shadow's
* status (recorded, malformed, failed with its code, missing, retrospective, or
* off when the run never turned shadow on), the shadow fields when present, and
* the owner's answer with its provenance (owner_mode, owner_named, carried,
* answered_role, rec_hash at record and at answer). It computes no agreement
* number, only counts. A revision whose plan approval is unanswered or answered
* c (interrogate) refuses SHADOW_BEFORE_ANSWER (a courtesy for honest tools,
* not access control: the journaled sha256 is the tamper evidence), and a
* CEO-only run CEO_ONLY_NO_GATE. Read-only. `validateShadow` is the private
* check `gstack-artifact validate <run.json>` adds: each record's schema and
* its journaled sha256.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { previousGate } from './autoplan-gate';
import { readDecisions, readJournal, readState } from './autoplan-state';
import type { ResultCodeName } from './result-codes';
import type { ValidationError } from './headless-artifacts';
import { RECORD_RE, SHADOW_ACTIONS, recordFile, shadowDir } from './autoplan-shadow';
export type ShadowStatus = 'recorded' | 'malformed' | 'failed' | 'missing' | 'retrospective' | 'off';
export interface ShadowExportRow {
run: string; gate_rev: number; item: string; decision: string; item_class: string | null; ceo_eligible: boolean; status: ShadowStatus; code?: string;
shadow: Record<string, unknown> | null;
owner: { chosen: string | null; status: string; owner_mode: string | null; owner_named: boolean | null; carried: boolean; carried_from: string | null; answered_role: string | null; answered_by: string | null; rec_hash_at_answer: string | null; rec_hash_at_record: string | null };
}
export interface ShadowExport { rows: ShadowExportRow[]; counts: Record<string, Record<string, number>>; refused: Array<{ run: string; gate_rev?: number; code: ResultCodeName; detail: string }> }
const readRows = (file: string) => fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l) as Record<string, any>);
/** Every answered revision of every run, deduped by run folder, gate_rev and item (two runs can share a run id). */
export function exportShadow(dirs: string[]): ShadowExport {
const rows = new Map<string, ShadowExportRow>();
const refused: ShadowExport['refused'] = [];
for (const dir of dirs) {
let state;
try { state = readState(dir); } catch (e: any) { refused.push({ run: dir, code: e.code ?? 'RUN_NOT_INITIALIZED', detail: `${dir}: ${e.detail ?? e.message}` }); continue; }
if (state.ceo_only) { refused.push({ run: state.run, code: 'CEO_ONLY_NO_GATE', detail: `${dir} is CEO-only run ${state.run}: no gate, nothing answered` }); continue; }
const key = fs.realpathSync(dir);
const decisions = readDecisions(dir).filter(r => r.gate_rev >= 1);
const journal = readJournal(dir);
for (const rev of [...new Set(decisions.map(r => r.gate_rev))].sort((a, b) => a - b)) {
const revRows = decisions.filter(r => r.gate_rev === rev);
const p1 = revRows.find(r => (r.label ?? '').toLowerCase() === 'p1');
if (!p1?.chosen || p1.status === 'pending' || p1.chosen === 'c') {
refused.push({ run: state.run, gate_rev: rev, code: 'SHADOW_BEFORE_ANSWER', detail: `${dir} gate_rev ${rev}: the plan approval is ${p1?.chosen === 'c' ? 'answered c (interrogate)' : 'unanswered'}; the export waits for the owner` });
continue;
}
const record = fs.existsSync(recordFile(dir, rev)) ? new Map(readRows(recordFile(dir, rev)).map(r => [String(r.item), r])) : undefined;
const failed = journal.find(r => r.event === 'shadow_failed' && r.gate_rev === rev);
const gate = previousGate(dir, rev);
for (const d of revRows) {
const item = (d.label ?? d.id).toLowerCase();
const s = record?.get(item);
const status: ShadowStatus = s ? (s.retrospective ? 'retrospective' : s.status) : failed ? 'failed' : state.shadow?.enabled ? 'missing' : 'off';
const gateItem = gate?.items.find(i => i.id === item);
const row: ShadowExportRow = {
run: state.run, gate_rev: rev, item, decision: d.id, item_class: s?.item_class ?? gateItem?.item_class ?? null, ceo_eligible: s?.ceo_eligible ?? ((s?.item_class ?? gateItem?.item_class) !== 'finding' && (item === 'p1' || d.phase === 'ceo')), status,
...(status === 'failed' ? { code: String(failed!.code) } : {}),
shadow: s ? Object.fromEntries(['action', 'pick', 'confidence', 'cites', 'reason', 'malformed', 'rule_pick', 'audit', 'model', 'prompt_version', 'prompt_sha256', 'rec_hash', 'rec_rev', 'recorded_at', 'retrospective'].filter(k => k in s).map(k => [k, s[k]])) : null,
owner: { chosen: d.chosen ?? null, status: d.status, owner_mode: d.owner_mode ?? null, owner_named: d.owner_named ?? null, carried: !!(d.carried_from || d.carried_gate_from), carried_from: d.carried_from ?? d.carried_gate_from ?? null,
answered_role: d.answered_role ?? null, answered_by: d.answered_by ?? null, rec_hash_at_answer: d.rec_hash ?? null, rec_hash_at_record: s?.rec_hash ?? null },
};
rows.set(`${key}|${rev}|${item}`, row);
}
}
}
const all = [...rows.values()];
const count = (key: (r: ShadowExportRow) => string) => all.reduce<Record<string, number>>((c, r) => { const k = key(r); c[k] = (c[k] ?? 0) + 1; return c; }, {});
return { rows: all, refused, counts: { status: count(r => r.status), item_class: count(r => r.item_class ?? 'unknown'), owner_mode: count(r => r.owner.owner_mode ?? 'none'), ceo_eligible: count(r => String(r.ceo_eligible)) } };
}
export function renderShadowExport(r: ShadowExport): string[] {
const counts = Object.entries(r.counts).map(([k, c]) => `SHADOW_COUNTS: ${k} ${Object.entries(c).map(([v, n]) => `${v}=${n}`).join(' ') || 'none'}`);
return [...r.rows.map(row => `SHADOW_ROW: ${JSON.stringify(row)}`), ...counts, `SHADOW_EXPORT: rows=${r.rows.length} refused=${r.refused.length} (no agreement number: gstack-evals computes it)`];
}
/** The private check behind gstack-artifact validate: each shadow record matches its journaled sha256 and its rows the schema. */
export function validateShadow(runDir: string): ValidationError[] {
const dir = shadowDir(runDir);
const journal = readJournal(runDir);
const missing = (present: Set<number>): ValidationError[] => [...new Set(journal.filter(r => r.event === 'shadow_recorded').map(r => Number(r.gate_rev)))]
.filter(rev => !present.has(rev))
.map(rev => ({ code: 'ARTIFACT_STALE', path: `shadow/${path.basename(recordFile(runDir, rev))}`, message: `the journal records gate_rev ${rev}, but its shadow record is missing` }));
let st: fs.Stats;
try { st = fs.lstatSync(dir); } catch { return missing(new Set()); }
if (!st.isDirectory()) return [{ code: 'ARTIFACT_PATH_ESCAPE', path: 'shadow', message: 'the shadow folder is a symlink or not a directory' }];
const errors: ValidationError[] = [];
const present = new Set<number>();
for (const name of fs.readdirSync(dir)) {
const m = RECORD_RE.exec(name);
if (!m) continue;
const rev = Number(m[1]);
present.add(rev);
const rel = `shadow/${name}`;
const bytes = fs.readFileSync(path.join(dir, name));
const recorded = journal.filter(r => r.event === 'shadow_recorded' && r.gate_rev === rev).at(-1);
const sha = createHash('sha256').update(bytes).digest('hex');
if (!recorded) errors.push({ code: 'ARTIFACT_STALE', path: rel, message: `no shadow_recorded journal row names gate_rev ${rev}` });
else if (recorded.sha256 !== sha) errors.push({ code: 'ARTIFACT_STALE', path: rel, message: `sha256 ${sha.slice(0, 12)} is not the journaled ${String(recorded.sha256).slice(0, 12)}` });
bytes.toString('utf8').split('\n').filter(Boolean).forEach((line, i) => {
let row: Record<string, any>;
try { row = JSON.parse(line); } catch { errors.push({ code: 'ARTIFACT_SCHEMA', path: `${rel}:${i + 1}`, message: 'not JSON' }); return; }
const problem = rowProblem(row, rev);
if (problem) errors.push({ code: 'ARTIFACT_SCHEMA', path: `${rel}:${i + 1}`, message: problem });
});
}
return [...errors, ...missing(present)];
}
function rowProblem(r: Record<string, any>, rev: number): string | undefined {
if (r.schema_version !== 1 || r.gate_rev !== rev) return `schema_version 1 and gate_rev ${rev} expected`;
for (const k of ['run', 'decision', 'item', 'model', 'prompt_sha256', 'recorded_at']) if (typeof r[k] !== 'string' || !r[k]) return `${k} must be a non-empty string`;
if (typeof r.retrospective !== 'boolean' || typeof r.audit !== 'boolean' || typeof r.ceo_eligible !== 'boolean') return 'retrospective, audit and ceo_eligible must be booleans';
if (r.status === 'malformed') return r.pick === null && !r.audit ? undefined : 'a malformed row carries no pick and no audit mark';
if (r.status !== 'recorded') return `status ${JSON.stringify(r.status)} is not recorded or malformed`;
if (!SHADOW_ACTIONS.includes(r.action)) return 'action must be decide, send_back or hold';
if (typeof r.pick !== 'string' || !Array.isArray(r.item_frozen?.options) || !r.item_frozen.options.some((o: { key?: string }) => o.key === r.pick)) return 'pick must be one of the frozen item\'s options';
if (typeof r.confidence !== 'number' || !(r.confidence >= 0 && r.confidence <= 1)) return 'confidence must be a number from 0 to 1';
if (!Array.isArray(r.cites) || typeof r.reason !== 'string' || r.reason.length > 400) return 'cites must be a list and reason a string of at most 400 characters';
if (r.audit && r.action !== 'decide') return 'only a decide row carries the audit mark';
return undefined;
}
+448
View File
@@ -0,0 +1,448 @@
/**
* autoplan-shadow — shadow mode for the CEO-only decider (GST-537 PR 3,
* docs/unattended.md#shadow-mode). In a driver run that turned it on at init
* (`autoplan_shadow on` or GSTACK_AUTOPLAN_SHADOW=1; off by default), every
* gate the driver publishes gets one hidden model prediction per question,
* written before anyone answers: action (decide, send_back, hold), pick,
* confidence, cites and reason, beside the free keep-direction rule's pick.
* Nothing routes differently and a shadow failure never blocks a gate or an
* answer. The decider prompt is the frozen, hand-written
* autoplan/shadow-decider.md (its sha256 is pinned below and on every row).
*
* The publication is two-phase so the run lock is never held during the call:
* `claimShadow` runs under the run lock of the command that wrote the gate and
* takes a lease (`<run>/shadow/r<rev>.pending`: pid, host, deadline; journaled
* `shadow_pending`); `runShadow` calls the model with no lock held (each call
* reserved under its own ledger id labeled `shadow` and journaled
* `shadow_dispatched` before the send), then retakes the lock with a bounded
* wait and publishes `<run>/shadow/ceo-only.r<rev>.jsonl` with link (a second
* writer never overwrites) plus `shadow_recorded` or `shadow_failed`. A live
* lease makes every other writer pay nothing, and `answer` waits for it
* (`shadowBeforeAnswer`). Only a dead lease (its pid gone, or past its
* deadline) is a crash: its dispatched calls settle `unknown`, never released,
* and `answer` then records the revision with every row `retrospective`.
* This module is the only one that names the shadow folder's paths; the reader
* for gstack-evals and the private validate check are in
* lib/autoplan-shadow-reader.ts.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { extractHeading } from './autoplan-prompts';
import { GSTACK_VERSION } from './autoplan-migrate';
import { withRunLock } from './autoplan-run';
import { FILES, RunError, journal, now, readJournal, readState, voiceRows, type RunState } from './autoplan-state';
import { decisionId, recHash, ruleKept, type GateItem, type GateList } from './gate-list';
import type { FindingRow } from './headless-artifacts';
import { readJsonl } from './headless-artifacts';
import { apiKeyFor, callModel, type ApiCallResult } from './outside-voice-api';
import { modelFamily } from './outside-voice-runner';
import { estimateCostUsd, hasPricing } from './pricing';
import { readLedger, release, reserve, settle, summarize } from './spend-ledger';
import { resolveSourceRoot } from './source-root';
import { readConfigKey } from './state-root';
export const SHADOW_PROMPT_VERSION = 1;
/** sha256 of autoplan/shadow-decider.md at SHADOW_PROMPT_VERSION; a prompt edit bumps both (test/autoplan-shadow.test.ts). */
export const SHADOW_PROMPT_SHA256 = 'e4857932ae831b7a6d933c42c182ccd7711c68338fec6901799264f645589344';
export const DEFAULT_SHADOW_MODEL = 'claude-opus-5-5';
/** One deadline for the call and its one retry. */
export const SHADOW_DEADLINE_MS = 90_000;
export const SHADOW_MAX_TOKENS = 4_000;
/** A reply cut at the output limit is asked once more at this limit, inside the same deadline. */
export const SHADOW_RAISED_MAX_TOKENS = 8_000;
export const SHADOW_SINK = 'autoplan-shadow';
export const SHADOW_CONSENT = 'autoplan_shadow on';
const PLAN_CHARS = 40_000;
const REASON_CHARS = 400;
const RELOCK_MS = 30_000;
const LEASE_GRACE_MS = 30_000;
export const SHADOW_ACTIONS = ['decide', 'send_back', 'hold'] as const;
const ROOT = path.resolve(import.meta.dir, '..');
export const shadowPromptFile = () => path.join(resolveSourceRoot(ROOT), 'autoplan', 'shadow-decider.md');
export const shadowDir = (out: string) => path.join(out, 'shadow');
export const recordFile = (out: string, rev: number) => path.join(shadowDir(out), `ceo-only.r${rev}.jsonl`);
export const RECORD_RE = /^ceo-only\.r(\d+)\.jsonl$/;
const leaseFile = (out: string, rev: number) => path.join(shadowDir(out), `r${rev}.pending`);
const saltFile = (out: string) => path.join(shadowDir(out), 'salt');
const sha256 = (data: string | Buffer) => crypto.createHash('sha256').update(data).digest('hex');
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// the switch, fixed at init
// ---------------------------------------------------------------------------
export interface ShadowSwitch { enabled: boolean; model?: string; reason?: string; source: 'env' | 'config' | 'default' }
/**
* GSTACK_AUTOPLAN_SHADOW (1|on|0|off), else gstack-config autoplan_shadow (default off). On also needs a priced model
* (autoplan_shadow_model, default claude-opus-5-5), its family's API key, and a run that is neither --no-outside nor
* CEO-only. Stored in run.json at init and never re-read. The line is printed only when shadow was asked for.
*/
export function shadowSwitch(o: { outsideEnabled: boolean; ceoOnly: boolean }, env: NodeJS.ProcessEnv = process.env): { shadow: ShadowSwitch; line?: string } {
const fromEnv = !!env.GSTACK_AUTOPLAN_SHADOW?.trim();
const value = (fromEnv ? env.GSTACK_AUTOPLAN_SHADOW! : readConfigKey('autoplan_shadow', env) ?? '').trim().toLowerCase();
const source = fromEnv ? 'env' as const : value ? 'config' as const : 'default' as const;
const label = fromEnv ? `GSTACK_AUTOPLAN_SHADOW=${value}` : `autoplan_shadow ${value}`;
const off = (reason: string) => ({ shadow: { enabled: false, reason, source }, line: `SHADOW: off (${reason})` });
if (!value || value === 'off' || value === '0') return { shadow: { enabled: false, reason: source === 'default' ? 'off by default' : label, source } };
if (value !== 'on' && value !== '1') return off(`${label} is not on or off`);
if (o.ceoOnly) return off('a CEO-only run writes no gate');
if (!o.outsideEnabled) return off('--no-outside');
const model = (readConfigKey('autoplan_shadow_model', env) ?? '').trim() || DEFAULT_SHADOW_MODEL;
const family = modelFamily(model);
const keyName = family === 'openai' ? 'OPENAI_API_KEY' : family === 'anthropic' ? 'ANTHROPIC_API_KEY' : `no API transport for family ${family}`;
if (!apiKeyFor(family, env)) return off(`no API key for ${model} (${keyName})`);
if (!hasPricing(model)) return off(`${model} has no price in lib/pricing.ts, so --spend-cap cannot bound it`);
return { shadow: { enabled: true, model, source }, line: `SHADOW: on model=${model} (${label}); one hidden model call per gate page, counted against --spend-cap` };
}
// ---------------------------------------------------------------------------
// the lease
// ---------------------------------------------------------------------------
interface Lease { gate_rev: number; pid: number; host: string; started_at: string; deadline: string; retrospective: boolean }
export interface ShadowClaim { gate_rev: number; retrospective: boolean }
function readLease(out: string, rev: number): Lease | undefined {
try { return JSON.parse(fs.readFileSync(leaseFile(out, rev), 'utf8')) as Lease; } catch { return undefined; }
}
/** Live: before its deadline plus a grace, and (on this host) its pid still running. */
function leaseLive(l: Lease, at = Date.now()): boolean {
if (!(at <= Date.parse(l.deadline) + LEASE_GRACE_MS)) return false;
if (l.host !== os.hostname()) return true;
try { process.kill(l.pid, 0); return true; } catch (e: any) { return e.code === 'EPERM'; }
}
function dropLease(out: string, rev: number): void {
const l = readLease(out, rev);
if (l && l.pid === process.pid && l.host === os.hostname()) fs.rmSync(leaseFile(out, rev), { force: true });
}
const resultRow = (rows: Array<Record<string, any>>, rev: number) => rows.find(r => (r.event === 'shadow_recorded' || r.event === 'shadow_failed') && r.gate_rev === rev);
const callIds = (rows: Array<Record<string, any>>, rev: number) => rows.filter(r => r.event === 'shadow_dispatched' && r.gate_rev === rev).map(r => String(r.attempt));
/** The shadow folder: created on first use; a symlink or a non-directory is refused, never followed. */
function safeDir(out: string): string | undefined {
const dir = shadowDir(out);
try {
const st = fs.lstatSync(dir);
if (st.isSymbolicLink()) return `${dir} is a symlink`;
if (!st.isDirectory()) return `${dir} is not a directory`;
} catch (e: any) {
if (e.code !== 'ENOENT') return `${dir}: ${e.code ?? e.message}`;
fs.mkdirSync(dir, { mode: 0o700 });
}
return undefined;
}
/** A dead lease: its dispatched calls settle unknown at their reservation (they may have been billed), the rest are released. */
function expireLease(out: string, rev: number, rows: Array<Record<string, any>>): void {
const file = path.join(out, FILES.spend);
const sent = new Set(callIds(rows, rev));
const open = readLedger(file).reserved.filter(r => r.attempt.startsWith(`shadow-r${rev}-`));
for (const r of open) if (sent.has(r.attempt)) settle(file, r.attempt, 'unknown'); else release(file, r.attempt);
fs.rmSync(leaseFile(out, rev), { force: true });
journal(out, { event: 'shadow_expired', gate_rev: rev, settled_unknown: open.filter(r => sent.has(r.attempt)).map(r => r.attempt), released: open.filter(r => !sent.has(r.attempt)).map(r => r.attempt) });
}
/**
* Under the run lock: claim the current gate revision's shadow, or say why not (nothing printed when shadow is off, the
* run is not at a gate, or the revision already has its result). A live lease means another writer is paying: this one
* pays nothing. `retrospective` (answer's crash path) claims only a revision whose shadow_pending has no result.
*/
export function claimShadow(out: string, o: { retrospective?: boolean } = {}): { claim?: ShadowClaim; line?: string } {
const state = readState(out);
const rev = state.gate_rev;
if (!state.shadow?.enabled || state.ceo_only || state.status !== 'gate_pending' || !rev) return {};
const rows = readJournal(out);
if (resultRow(rows, rev) || fs.existsSync(recordFile(out, rev))) return {};
const pending = rows.some(r => r.event === 'shadow_pending' && r.gate_rev === rev);
if (o.retrospective && !pending) return {};
const unsafe = safeDir(out);
if (unsafe) {
journal(out, { event: 'shadow_failed', gate_rev: rev, code: 'unsafe_dir', detail: unsafe });
return { line: `SHADOW: failed unsafe_dir (${unsafe})` };
}
const held = readLease(out, rev);
if (held && leaseLive(held)) return { line: `SHADOW: pending (gate_rev ${rev} is being recorded by pid ${held.pid} on ${held.host} until ${held.deadline}; this command pays nothing)` };
if (held || pending) expireLease(out, rev, rows);
const started = Date.now();
const lease: Lease = { gate_rev: rev, pid: process.pid, host: os.hostname(), started_at: new Date(started).toISOString(), deadline: new Date(started + SHADOW_DEADLINE_MS + RELOCK_MS).toISOString(), retrospective: !!o.retrospective };
const staged = `${leaseFile(out, rev)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(staged, JSON.stringify(lease));
try { fs.linkSync(staged, leaseFile(out, rev)); } catch (e: any) { if (e.code !== 'EEXIST') throw e; return { line: `SHADOW: pending (gate_rev ${rev} was claimed by another writer)` }; } finally { fs.rmSync(staged, { force: true }); }
journal(out, { event: 'shadow_pending', ...lease });
return { claim: { gate_rev: rev, retrospective: lease.retrospective } };
}
// ---------------------------------------------------------------------------
// the page: one prompt per gate
// ---------------------------------------------------------------------------
interface PageFinding { id: string; severity: string; title: string }
export interface PageItem { item: GateItem; findings: PageFinding[]; frozen: Record<string, unknown> }
export interface Page { prompt: string; items: PageItem[]; plan_sha256: string; prompt_sha256: string; request_sha256: string }
/** Every finding the gate items can name: both voices of every phase, plus findings a deferred close carried. */
function findingIndex(state: RunState): Map<string, FindingRow> {
const rows: FindingRow[] = [];
for (const phase of state.phase_order) {
rows.push(...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside'));
const carried = state.phases[phase]?.deferred?.carried_file;
if (carried && fs.existsSync(path.join(state.out, carried))) rows.push(...readJsonl(path.join(state.out, carried)).rows as unknown as FindingRow[]);
}
return new Map(rows.map(r => [r.id, r]));
}
/** The question as frozen in the record and sent in the prompt; p1 lists every finding the other items name. */
export function buildPage(state: RunState, list: GateList, deciderText: string): Page {
const index = findingIndex(state);
const own = (item: GateItem) => (item.origin?.split('|')[1] ?? '').split('+').filter(id => index.has(id));
const named = [...new Set(list.items.filter(i => i.id !== 'p1').flatMap(own))];
const items = list.items.map(item => {
const findings = (item.id === 'p1' ? named : own(item)).map(id => { const f = index.get(id)!; return { id, severity: f.severity, title: f.title.split('\n')[0]!.slice(0, 200) }; });
const frozen = { id: item.id, item_class: item.item_class ?? null, kind: item.kind, title: item.title, why: item.why ?? null, phase: item.phase ?? null,
options: item.options.map(o => ({ key: o.key, text: o.text, changes_plan: o.changes_plan ?? null })), recommended: item.recommended, recommended_basis: item.recommended_basis ?? null, findings };
return { item, findings, frozen };
});
const planText = fs.existsSync(state.active_plan) ? fs.readFileSync(state.active_plan, 'utf8') : '';
const plan = (extractHeading(planText, 'Implementation plan') ?? planText).slice(0, PLAN_CHARS);
const slots: Record<string, string> = { PLAN: plan, QUESTIONS: '```json\n' + JSON.stringify(items.map(i => i.frozen), null, 2) + '\n```', IDS: items.map(i => i.item.id).join(', ') };
const prompt = deciderText.replace(/\{\{(PLAN|QUESTIONS|IDS)\}\}/g, (_, k: string) => slots[k]!);
return { prompt, items, plan_sha256: sha256(planText), prompt_sha256: sha256(deciderText), request_sha256: sha256(prompt) };
}
// ---------------------------------------------------------------------------
// the reply contract
// ---------------------------------------------------------------------------
export type Verdict = { ok: true; action: typeof SHADOW_ACTIONS[number]; pick: string; confidence: number; cites: string[]; reason: string } | { ok: false; why: string };
export interface ParsedPage { byId: Map<string, Verdict>; foreign: string[]; clean: boolean }
function jsonArray(text: string): unknown {
const fenced = /```(?:json)?\s*\n([\s\S]*?)```/.exec(text)?.[1];
for (const candidate of [fenced, text.trim(), text.slice(text.indexOf('['), text.lastIndexOf(']') + 1)]) {
if (!candidate) continue;
try { return JSON.parse(candidate); } catch { /* next candidate */ }
}
return undefined;
}
/** Exactly one valid object per requested id; a duplicate, omission or invalid field marks that item malformed, a foreign id the page. */
export function parsePage(text: string, items: PageItem[]): ParsedPage {
const byId = new Map<string, Verdict>();
const parsed = jsonArray(text);
if (!Array.isArray(parsed)) {
for (const i of items) byId.set(i.item.id, { ok: false, why: 'no JSON array in the reply' });
return { byId, foreign: [], clean: false };
}
const want = new Map(items.map(i => [i.item.id, i]));
const seen = new Map<string, unknown[]>();
const foreign: string[] = [];
for (const obj of parsed) {
const id = (obj as { id?: unknown })?.id;
if (typeof id === 'string' && want.has(id)) (seen.get(id) ?? seen.set(id, []).get(id)!).push(obj);
else foreign.push(typeof id === 'string' ? id : JSON.stringify(id ?? null));
}
for (const [id, pi] of want) {
const objs = seen.get(id) ?? [];
byId.set(id, objs.length === 0 ? { ok: false, why: 'missing' } : objs.length > 1 ? { ok: false, why: 'duplicate' } : verdict(objs[0] as Record<string, unknown>, pi));
}
return { byId, foreign, clean: foreign.length === 0 && [...byId.values()].every(v => v.ok) };
}
function verdict(o: Record<string, unknown>, pi: PageItem): Verdict {
const cites = Array.isArray(o.cites) ? o.cites : null;
const allowed = new Set(pi.findings.map(f => f.id));
if (!SHADOW_ACTIONS.includes(o.action as typeof SHADOW_ACTIONS[number])) return { ok: false, why: `action ${JSON.stringify(o.action ?? null)} is not decide, send_back or hold` };
if (typeof o.pick !== 'string' || !pi.item.options.some(op => op.key === o.pick)) return { ok: false, why: `pick ${JSON.stringify(o.pick ?? null)} is not an option of ${pi.item.id}` };
if (typeof o.confidence !== 'number' || !Number.isFinite(o.confidence) || o.confidence < 0 || o.confidence > 1) return { ok: false, why: `confidence ${JSON.stringify(o.confidence ?? null)} is not a number from 0 to 1` };
if (!cites || cites.some(c => typeof c !== 'string' || !allowed.has(c))) return { ok: false, why: `cites ${JSON.stringify(o.cites ?? null)} name a finding outside ${pi.item.id}'s origin` };
if (typeof o.reason !== 'string' || o.reason.length > REASON_CHARS) return { ok: false, why: `reason is not a string of at most ${REASON_CHARS} characters` };
return { ok: true, action: o.action as typeof SHADOW_ACTIONS[number], pick: o.pick, confidence: o.confidence, cites: cites as string[], reason: o.reason };
}
/** The 10% audit mark: sha256(salt + decision id) mod 1000 < 100. */
export function auditMark(salt: string, decision: string): boolean {
return BigInt('0x' + sha256(salt + decision)) % 1000n < 100n;
}
// ---------------------------------------------------------------------------
// the call and the record
// ---------------------------------------------------------------------------
export interface ShadowOutcome { status: 'recorded' | 'failed' | 'dropped'; code?: string; model?: string; usd?: number; malformed?: number; line: string }
export interface RunShadowOptions { env?: NodeJS.ProcessEnv; deadlineMs?: number }
class ShadowFailure extends Error { constructor(public code: string, detail: string) { super(detail); } }
interface Reply { result: ApiCallResult; usd: number | 'unknown'; attempt: string }
/** Classify a callModel error: no key, an HTTP status (not billed, settles 0), or a timeout or network loss (may be billed, settles unknown). */
function failureOf(e: any): { code: string; usd: number | 'unknown' } {
const msg = String(e?.message ?? e);
if (/^no API key/.test(msg)) return { code: 'no_key', usd: 0 };
if (/^(anthropic|openai) \d{3}:/.test(msg)) return { code: 'http', usd: 0 };
return { code: /timeout|abort/i.test(msg) || e?.name === 'AbortError' || e?.name === 'TimeoutError' ? 'timeout' : 'network', usd: 'unknown' };
}
/**
* Outside any lock: the page is asked once; a reply cut at the output limit is asked once more at 8,000 tokens, and a
* malformed one once more as a fresh request; one 90 s deadline covers both. Each call is reserved under its own
* ledger id (label shadow), journaled shadow_dispatched before the send and settled at its actual usage. Then the
* lock is retaken with a bounded wait and the record published. Never throws: every failure is an outcome.
*/
export async function runShadow(out: string, claim: ShadowClaim, o: RunShadowOptions = {}): Promise<ShadowOutcome> {
const env = o.env ?? process.env;
const deadline = Date.now() + (o.deadlineMs ?? SHADOW_DEADLINE_MS);
const rev = claim.gate_rev;
const replies: Reply[] = [];
try {
const state = readState(out);
const list = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
if (list.gate_rev !== rev) throw new ShadowFailure('stale_gate', `gate.json is gate_rev ${list.gate_rev}, the claim is ${rev}`);
const model = state.shadow!.model!;
const page = buildPage(state, list, fs.readFileSync(shadowPromptFile(), 'utf8'));
writeAtomic(path.join(shadowDir(out), `r${rev}.prompt.md`), page.prompt);
const inputTokens = Math.ceil(Buffer.byteLength(page.prompt) / 3);
const estimate = (maxTokens: number) => estimateCostUsd({ input: inputTokens, output: maxTokens }, model);
const ledgerFile = path.join(out, FILES.spend);
const headroom = summarize(readLedger(ledgerFile, state.spend_cap_usd)).headroom_usd;
const worst = estimate(SHADOW_MAX_TOKENS) + estimate(SHADOW_RAISED_MAX_TOKENS);
if (headroom !== null && headroom < worst) throw new ShadowFailure('budget', `headroom ${headroom.toFixed(4)} < ${worst.toFixed(4)} (one call plus its retry)`);
const ask = async (maxTokens: number): Promise<Reply> => {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new ShadowFailure('timeout', `the ${(o.deadlineMs ?? SHADOW_DEADLINE_MS) / 1000} s deadline passed before the call`);
const attempt = `shadow-r${rev}-${callIds(readJournal(out), rev).length + 1}`;
try { reserve(ledgerFile, attempt, estimate(maxTokens), { cap: readState(out).spend_cap_usd, label: 'shadow' }); }
catch (e: any) { throw new ShadowFailure('budget', e.message); }
journal(out, { event: 'shadow_dispatched', gate_rev: rev, attempt, model, max_tokens: maxTokens, estimate_usd: estimate(maxTokens), retrospective: claim.retrospective });
let result: ApiCallResult;
try {
result = await callModel({ model, prompt: page.prompt, timeoutMs: remaining, env, maxTokens, receipt: { sink: SHADOW_SINK, consent: SHADOW_CONSENT, payloadClass: 'plan-and-gate' } });
} catch (e: any) {
const f = failureOf(e);
settle(ledgerFile, attempt, f.usd);
throw new ShadowFailure(f.code, String(e?.message ?? e));
}
const { input_tokens: inTok, output_tokens: outTok } = result.usage;
const usd: number | 'unknown' = typeof inTok === 'number' && typeof outTok === 'number' ? estimateCostUsd({ input: inTok, output: outTok }, model) : 'unknown';
settle(ledgerFile, attempt, usd);
writeAtomic(path.join(shadowDir(out), `${attempt}.reply.txt`), result.text);
const reply = { result, usd, attempt };
replies.push(reply);
return reply;
};
const truncated = (r: Reply) => r.result.stop_reason === 'max_tokens' || r.result.stop_reason === 'length';
const first = await ask(SHADOW_MAX_TOKENS);
let parsed: ParsedPage[];
if (truncated(first)) {
const raised = await ask(SHADOW_RAISED_MAX_TOKENS);
if (truncated(raised)) throw new ShadowFailure('truncated', `the reply was cut at ${SHADOW_RAISED_MAX_TOKENS} output tokens`);
parsed = [parsePage(raised.result.text, page.items)];
} else {
parsed = [parsePage(first.result.text, page.items)];
if (!parsed[0]!.clean) {
try {
const retry = await ask(SHADOW_MAX_TOKENS);
if (!truncated(retry)) parsed.push(parsePage(retry.result.text, page.items));
} catch (e) { if (!(e instanceof ShadowFailure)) throw e; }
}
}
return publish(out, claim, { rows: shadowRows(out, state, list, page, parsed, replies), model, replies });
} catch (e: any) {
const failure = e instanceof ShadowFailure ? e : new ShadowFailure('internal', String(e?.message ?? e));
return publish(out, claim, { failure, replies });
}
}
const usdOf = (replies: Reply[]) => (replies.some(r => r.usd === 'unknown') ? 'unknown' as const : Math.round(replies.reduce((s, r) => s + (r.usd as number), 0) * 1e6) / 1e6);
/** One row per gate item: the frozen question, what the model predicted (the retry's verdict when valid, else the first's), the free rule's pick and the audit mark. */
function shadowRows(out: string, state: RunState, list: GateList, page: Page, parsed: ParsedPage[], replies: Reply[]): Array<Record<string, unknown>> {
const at = now();
const usage = replies.reduce((u, r) => ({ input_tokens: u.input_tokens + (r.result.usage.input_tokens ?? 0), output_tokens: u.output_tokens + (r.result.usage.output_tokens ?? 0) }), { input_tokens: 0, output_tokens: 0 });
return page.items.map(({ item, frozen }) => {
const verdicts = parsed.map(p => p.byId.get(item.id)!);
const k = verdicts.map((v, i) => [v, i] as const).reverse().find(([v]) => v.ok);
const decision = decisionId(list.run, list.gate_rev, item.id);
const base = {
schema_version: 1, run: list.run, gate_rev: list.gate_rev, decision, item: item.id, item_class: item.item_class ?? null, phase: item.phase ?? null,
ceo_eligible: item.item_class !== 'finding' && (item.id === 'p1' || item.phase === 'ceo'), item_frozen: frozen, rec_hash: recHash(list), rec_rev: list.rec_rev ?? 0,
plan_sha256: page.plan_sha256, prompt_version: SHADOW_PROMPT_VERSION, prompt_sha256: page.prompt_sha256, request_sha256: page.request_sha256,
model: replies[replies.length - 1]?.result.model ?? state.shadow?.model, gstack_version: GSTACK_VERSION, rule_pick: ruleKept(item),
retrospective: false, page_usage: usage, page_usd: usdOf(replies), recorded_at: at,
};
if (!k) return { ...base, status: 'malformed', malformed: (verdicts[verdicts.length - 1] as { why: string }).why, action: null, pick: null, confidence: null, cites: [], reason: null, audit: false };
const v = k[0] as Extract<Verdict, { ok: true }>;
return { ...base, status: 'recorded', action: v.action, pick: v.pick, confidence: v.confidence, cites: v.cites, reason: v.reason, reply: k[1] + 1,
audit: v.action === 'decide' && auditMark(saltOf(out), decision) };
});
}
/** The per-run audit salt, drawn once into the shadow folder (never run.json). */
function saltOf(out: string): string {
try { return fs.readFileSync(saltFile(out), 'utf8').trim(); } catch { /* first use */ }
try { fs.writeFileSync(saltFile(out), crypto.randomBytes(16).toString('hex') + '\n', { flag: 'wx', mode: 0o600 }); } catch (e: any) { if (e.code !== 'EEXIST') throw e; }
return fs.readFileSync(saltFile(out), 'utf8').trim();
}
function writeAtomic(file: string, text: string): void {
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, text);
fs.renameSync(tmp, file);
}
/** Under the run lock (a bounded wait, never RUN_LOCKED at once): link the record, journal the result, drop the lease. */
function publish(out: string, claim: ShadowClaim, r: { rows?: Array<Record<string, unknown>>; model?: string; failure?: ShadowFailure; replies: Reply[] }): ShadowOutcome {
const rev = claim.gate_rev;
const usd = usdOf(r.replies);
const attempts = r.replies.map(x => x.attempt);
try {
return withRunLock(out, () => {
const rows = readJournal(out);
if (fs.existsSync(recordFile(out, rev)) || resultRow(rows, rev)) { dropLease(out, rev); return { status: 'dropped' as const, line: `SHADOW: dropped (gate_rev ${rev} already has its result; this call's cost stays in the ledger)` }; }
if (r.failure) {
journal(out, { event: 'shadow_failed', gate_rev: rev, code: r.failure.code, detail: r.failure.message.slice(0, 500), attempts, usd, retrospective: claim.retrospective });
dropLease(out, rev);
return { status: 'failed' as const, code: r.failure.code, line: `SHADOW: failed ${r.failure.code}` };
}
const salted: Array<Record<string, unknown>> = r.rows!.map(row => ({ ...row, retrospective: claim.retrospective }));
const text = salted.map(row => JSON.stringify(row)).join('\n') + '\n';
const final = recordFile(out, rev);
for (const e of fs.readdirSync(shadowDir(out))) if (e.startsWith(`ceo-only.r${rev}.jsonl.`) && e.endsWith('.tmp')) fs.rmSync(path.join(shadowDir(out), e), { force: true });
const tmp = `${final}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, text, { flag: 'wx' });
try { fs.linkSync(tmp, final); } catch (e: any) { if (e.code !== 'EEXIST') throw e; dropLease(out, rev); return { status: 'dropped' as const, line: `SHADOW: dropped (gate_rev ${rev} was recorded by another writer)` }; } finally { fs.rmSync(tmp, { force: true }); }
const malformed = salted.filter(row => row.status === 'malformed').length;
journal(out, { event: 'shadow_recorded', gate_rev: rev, sha256: sha256(text), model: r.model, usd, items: salted.length, malformed, attempts, retrospective: claim.retrospective });
dropLease(out, rev);
const usdText = usd === 'unknown' ? 'unknown' : usd.toFixed(4);
return { status: 'recorded' as const, model: r.model, usd: usd === 'unknown' ? undefined : usd, malformed,
line: `SHADOW: recorded model=${r.model} usd=${usdText}${malformed ? ` malformed=${malformed}/${salted.length}` : ''}${claim.retrospective ? ' retrospective' : ''}` };
}, { waitMs: RELOCK_MS });
} catch (e: any) {
const code = e instanceof RunError && e.code === 'RUN_LOCKED' ? 'locked' : 'internal';
try {
withRunLock(out, () => {
if (!resultRow(readJournal(out), rev) && !fs.existsSync(recordFile(out, rev))) journal(out, { event: 'shadow_failed', gate_rev: rev, code, detail: String(e?.message ?? e).slice(0, 500), attempts, usd, retrospective: claim.retrospective });
dropLease(out, rev);
}, { waitMs: 2_000 });
} catch { /* still locked: the lease expires and the next command settles the revision */ }
return { status: 'failed', code, line: `SHADOW: failed ${code} (${e?.message ?? e}); the gate stands` };
}
}
/**
* Before `answer` takes the lock: wait for a live lease to finish (up to its deadline), then, when the revision has a
* shadow_pending and still no result (a crash), record it first with every row retrospective. Never throws.
*/
export async function shadowBeforeAnswer(out: string, o: RunShadowOptions = {}): Promise<string[]> {
try {
const state = readState(out);
const rev = state.gate_rev;
if (!state.shadow?.enabled || state.status !== 'gate_pending' || !rev) return [];
for (let l = readLease(out, rev); l && leaseLive(l); l = readLease(out, rev)) await sleep(200);
const rows = readJournal(out);
if (resultRow(rows, rev) || fs.existsSync(recordFile(out, rev)) || !rows.some(r => r.event === 'shadow_pending' && r.gate_rev === rev)) return [];
const { claim, line } = withRunLock(out, () => claimShadow(out, { retrospective: true }), { waitMs: RELOCK_MS });
if (!claim) return line ? [line] : [];
return [(await runShadow(out, claim, o)).line];
} catch (e: any) {
return [`SHADOW: failed internal (${e?.message ?? e}); the answer proceeds`];
}
}
+2
View File
@@ -74,6 +74,8 @@ export interface RunState extends RunManifest {
carry_forward?: boolean; carry_check_cap_usd?: number; carry?: import('./autoplan-carry').CarryRecord;
/** `next --ceo-only` (lib/autoplan-ceo-only.ts): the CEO phase alone, no gate, ends `reviewed`; never set by a scope marker. */
ceo_only?: boolean;
/** Shadow mode (lib/autoplan-shadow.ts): fixed at init from GSTACK_AUTOPLAN_SHADOW or autoplan_shadow and never re-read; absent reads off. */
shadow?: import('./autoplan-shadow').ShadowSwitch;
}
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
+58 -5
View File
@@ -30,6 +30,9 @@ const finish = (tail) => {
return all.slice(0, -1).join('\n').slice(0, Math.max(0, RETURN_BYTES - last.length)) + last;
};
const stop = (why) => finish(['STOP: ' + why]);
const logStart = input.stage === 'voices' ? (input.logStart ?? ['adversarial-review']) : (input.logStart || []);
if (!Array.isArray(logStart) || !logStart.every((s) => typeof s === 'string' && s.trim())) return stop('input.logStart must be a list of review-log start skills (strings), got ' + JSON.stringify(input.logStart) + '; nothing ran');
if (input.stage === 'voices' && !(logStart.length && logStart.every((s) => s.split(' ')[0] === 'adversarial-review'))) return stop('Step 11 (stage "voices") takes the adversarial-review start token, which Step 11.5 binds, so its logStart is ["adversarial-review"] (with any --reservation in input.reservation) or omitted, never ' + JSON.stringify(input.logStart) + '; nothing ran');
const runDir = input.out.replace(/\/+$/, '').replace(/\/[^/]+$/, '');
const nextPhase = input.stage === 'voices' ? 5 : input.step === '14.5' ? 7 : 4;
const withParts = async (tail) => {
@@ -91,8 +94,35 @@ const briefText = async (r) => {
};
const roleArgs = (r) => ' --role ' + q(r.role) + (r.name ? ' --name ' + q(r.name) : '');
const REASONING = { medium: 'medium', light: 'low' };
const modelOf = (r) => r.model || (input.models || {})[r.role === 'specialist-batched' ? 'specialist' : r.role]
|| (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined);
const family = (id) => (typeof id === 'string' && id.includes('/') ? id.slice(0, id.indexOf('/')).toLowerCase() : '');
const CERTIFIED_OUTSIDE = { model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'medium' };
const voiceFile = input.out.replace(/\/+$/, '') + '/voice-models.json';
let voices = { laneModel: null, readers: {} };
let laneFamily = family(input.laneModel);
const modelOf = (r) => r.model || (r.reader in voices.readers ? voices.readers[r.reader] || undefined
: (input.models || {})[r.role === 'specialist-batched' ? 'specialist' : r.role]
|| (r.role === 'outside-voice' && laneFamily === 'anthropic' ? CERTIFIED_OUTSIDE : undefined)
|| (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined));
const outsideNote = (r, source) => {
const ov = modelOf(r);
const certified = laneFamily === 'anthropic' && ov.model_id === CERTIFIED_OUTSIDE.model_id && ov.reasoning_mode === CERTIFIED_OUTSIDE.reasoning_mode;
return 'OUTSIDE_VOICE: ' + ov.model_id + ' reasoning=' + (ov.reasoning_mode || 'default') + ' ' + source
+ (certified ? ' (certified configuration)' : '; outside the certified configuration (' + CERTIFIED_OUTSIDE.model_id + ' at ' + CERTIFIED_OUTSIDE.reasoning_mode + ' beside an anthropic lane)');
};
const familyStop = (readers) => {
const native = readers.find((x) => x.role === 'adversarial-native');
const nativeFamily = family(native && modelOf(native) ? modelOf(native).model_id : undefined);
for (const ov of readers.filter((x) => x.role === 'outside-voice' && !['created', 'collected', 'failed'].includes(x.state))) {
const om = modelOf(ov);
if (!om) return "the outside voice needs input.models['outside-voice'] (a model of another family); none was given";
const lane = voices.laneModel || input.laneModel;
if (!laneFamily && !nativeFamily) return 'the outside voice ' + om.model_id + ' cannot be checked against another family: ' + (lane ? 'the lane model ' + lane + ' has no provider prefix and no native voice model was given' : 'neither input.laneModel nor a native voice model was given');
const f = family(om.model_id);
if (!f) return 'the outside voice ' + om.model_id + ' has no provider prefix, so its family cannot be checked';
if (f === laneFamily || f === nativeFamily) return 'the outside voice ' + om.model_id + ' is the ' + f + ' family, the same as the ' + (f === nativeFamily ? 'native voice' : 'lane model') + "; Step 11 needs input.models['outside-voice'] from another family";
}
return null;
};
const safe = (s) => s.replace(/[^a-zA-Z0-9._-]/g, '_');
const active = input.stage === 'voices' ? ['voices'] : ['first', 'specialists', 'red-team'];
const beforeKey = input.stage === 'voices' ? 'before_voices' : 'before';
@@ -100,9 +130,23 @@ const beforeKey = input.stage === 'voices' ? 'before_voices' : 'before';
try {
let m = await load();
if (input.stage !== 'voices' && m.next === 'fix-check') active.push('voices');
if (active.includes('voices')) {
const v = await sh('cat ' + q(voiceFile) + ' 2>/dev/null || true');
if (v.out.trim()) voices = JSON.parse(v.out);
if (voices.laneModel) laneFamily = family(voices.laneModel);
}
if (input.stage === 'voices' && !m.readers.some((r) => r.stage === 'voices')) return finish(['VOICES: none (the pass plan lists no Step 11 voice)', 'NEXT: judge']);
if (input.cwd && !m.readers.some((r) => active.includes(r.stage) && !['planned', 'rendered'].includes(r.state))) {
const top = 'if d=$(git rev-parse --show-toplevel 2>&1); then cd "$d" && pwd -P; else case "$d" in *"not a git repository"*) echo NOT_A_CHECKOUT ;; *) echo "$d"; exit 1 ;; esac; fi';
const ship = await sh(top);
const start = await tools.capy.bash({ command: top, description: 'Ship review pass ' + input.pass + ' reader start checkout', timeout: 300 });
const want = ship.out.trim(), got = (start.output || '').trim();
if (ship.code !== 0 || want === 'NOT_A_CHECKOUT') return stop('cannot read the checkout input.cwd names (' + input.cwd + '): ' + want.slice(-300));
if (start.exit_code !== 0) return stop('cannot read the directory readers on the shared machine start in: ' + got.slice(-300) + '; no start token was taken and no reader was dispatched');
if (got !== 'NOT_A_CHECKOUT' && got !== want) return stop('readers on the shared machine start in ' + got + ', not in ' + want + ', the checkout this pass ships, and Capy cannot start a subagent in another directory; no start token was taken and no reader was dispatched. Run /ship in ' + got + " (the thread's working directory) on this branch, with a fresh --out");
}
const seen = {};
for (const s of input.logStart || []) {
for (const s of logStart) {
const nth = (seen[s] = (seen[s] || 0) + 1);
const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s + (nth > 1 ? '#' + nth : '');
if ((m.log_starts || {})[key]) continue;
@@ -125,12 +169,17 @@ try {
await mark(r.reader, found[0].status === 'draft' ? 'queued' : 'created', { path: found[0].path });
}
m = await load();
const sameFamily = active.includes('voices') && familyStop(m.readers);
if (sameFamily) return stop(sameFamily);
for (const r of m.readers.filter((x) => active.includes(x.stage) && x.state === 'queued')) {
const t = byPath.get(r.path);
if (t && t.status !== 'draft') { await mark(r.reader, 'created', {}); continue; }
const model = modelOf(r);
const s = await tools.subagent.start({ indices: [Number(r.path)], machine: 'shared', ...(model ? { model } : {}) });
if ((s.started || []).length) await mark(r.reader, 'created', {});
if ((s.started || []).length) {
if (r.role === 'outside-voice') lines.push(outsideNote(r, 'as dispatched, started from the queue'));
await mark(r.reader, 'created', {});
}
else if (r.state_at && (await now()) - Date.parse(r.state_at) > stuckMs) return stop(r.reader + ' (subagent ' + r.path + ') still queued after ' + Math.round(stuckMs / 60000) + ' minutes; free a subagent slot, then rerun');
}
for (const r of m.readers.filter((x) => active.includes(x.stage) && x.state === 'created')) {
@@ -167,7 +216,6 @@ try {
lines.push(...s.out.trim().split('\n'));
m = await load();
}
if (m.readers.some((x) => x.stage === stage && x.role === 'outside-voice' && x.state === 'rendered' && !modelOf(x))) return stop("the outside voice needs input.models['outside-voice'] (a model of another family); none was given");
const unmapped = m.readers.find((x) => x.stage === stage && x.state === 'rendered' && REASONING[x.effort] && !modelOf(x));
if (unmapped) return stop(unmapped.reader + ' runs at effort=' + unmapped.effort + ', which needs input.laneModel (your model id) or input.models; none was given');
for (const r of m.readers.filter((x) => x.stage === stage && x.state === 'rendered')) {
@@ -184,6 +232,11 @@ try {
const b = await reading();
if (b) { await sh(bin + ' mark --manifest ' + q(mf) + ' --before ' + b.tokens + ',' + b.credits + (beforeKey === 'before_voices' ? ' --voices' : '')); m[beforeKey] = b; }
}
if (r.role === 'outside-voice') lines.push(outsideNote(r, r.model || (input.models || {})['outside-voice'] ? 'given' : 'default for an anthropic lane'));
if (r.stage === 'voices') {
voices = { laneModel: voices.laneModel || input.laneModel || null, readers: { ...voices.readers, [r.reader]: model || null } };
await tools.capy.write({ path: voiceFile, content: JSON.stringify(voices) });
}
await mark(r.reader, 'intent', {});
const res = await tools.subagent.create({ tasks: [{ title: r.title, prompt: text }], start: { machine: 'shared', ...(model ? { model } : {}) } });
const idx = String(res.tasks[0].index);
+53 -3
View File
@@ -53,8 +53,8 @@ export function declaredVerify(claudeMd: string): string | null {
return null;
}
/** The '## Health Stack' section (/health's own format): '- key: command' rows. 'test' and 'tests' are left to pregate; a repeated key gets -2, -3 so every check has its own name. */
export function healthStack(claudeMd: string): Check[] {
/** The '## Health Stack' section (/health's own format): '- key: command' rows. 'test' and 'tests' are left to pregate unless keepTests (/health scores them); a repeated key gets -2, -3 so every check has its own name. */
export function healthStack(claudeMd: string, keepTests = false): Check[] {
const lines = claudeMd.split('\n');
const start = lines.findIndex(l => /^##\s+Health Stack\s*$/.test(l));
if (start < 0) return [];
@@ -64,7 +64,7 @@ export function healthStack(claudeMd: string): Check[] {
for (const line of lines.slice(start + 1)) {
if (/^#{1,6}\s/.test(line)) break;
const m = /^\s*[-*]\s*([A-Za-z0-9_-]+):\s*(.+?)\s*$/.exec(line);
if (!m || ['test', TESTS].includes(m[1].toLowerCase())) continue;
if (!m || (!keepTests && ['test', TESTS].includes(m[1].toLowerCase()))) continue;
const key = m[1].toLowerCase();
let n = seen.get(key) ?? 0;
let name = key;
@@ -127,6 +127,56 @@ export function detectChecks(root: string): Check[] {
});
}
export const HEALTH_DIMENSIONS = ['typecheck', 'lint', 'test', 'deadcode', 'shell', 'gbrain'] as const;
export interface HealthDimensions { checks: Array<{ dimension: string; command: string; source: string }>; missing: Array<{ dimension: string; reason: string }> }
/**
* /health Step 1, in the directory /health runs from: a Health Stack section row for row (test rows kept; a section with no
* rows still turns auto-detection off), else /health's own presence rules,
* which may use a tool found on PATH (unlike /verify's declared-only manifestChecks). Reads files and PATH; runs nothing.
* test/health-detect-no-loss.test.ts holds these rules to the shell block they replaced.
*/
export function healthDimensions(root: string, env: NodeJS.ProcessEnv = process.env): HealthDimensions {
const claude = read(path.join(root, 'CLAUDE.md')) ?? '';
const stack = healthStack(claude, true);
if (/^##\s+Health Stack\s*$/m.test(claude)) {
const keys = new Set(stack.map(c => c.name.replace(/-\d+$/, '').replace(/^tests$/, 'test')));
return { checks: stack.map(c => ({ dimension: c.name, command: c.command, source: c.source })), missing: HEALTH_DIMENSIONS.filter(d => !keys.has(d)).map(dimension => ({ dimension, reason: stack.length ? 'not in the CLAUDE.md Health Stack section' : 'the CLAUDE.md Health Stack section has no "- key: command" rows' })) };
}
const has = (file: string) => fs.existsSync(path.join(root, file));
const which = (bin: string) => Bun.which(bin, { PATH: env.PATH ?? '' }) !== null;
const pkgText = read(path.join(root, 'package.json'));
const pyproject = read(path.join(root, 'pyproject.toml'));
let testScript: unknown;
try { testScript = pkgText?.includes('"test"') ? JSON.parse(pkgText).scripts?.test : undefined; } catch { /* an unreadable package.json declares nothing */ }
const listDir = (dir: string) => { try { return fs.readdirSync(dir); } catch { return []; } };
const shellFiles = ['.', 'scripts', 'bin'].some(dir => listDir(path.join(root, dir)).some(f => f.endsWith('.sh')));
const found: Array<[string, string, string] | false> = [
has('tsconfig.json') && ['typecheck', 'tsc --noEmit', 'tsconfig.json'],
(has('biome.json') || has('biome.jsonc')) && ['lint', 'biome check .', 'biome.json'],
listDir(root).some(f => /^(eslint\.config\..+|\.eslintrc(\..+)?)$/.test(f)) && ['lint', 'eslint .', 'eslint config'],
!!pyproject && /pylint|ruff/.test(pyproject) && ['lint', 'ruff check .', 'pyproject.toml'],
typeof testScript === 'string' && testScript !== '' && !/[\r\n]/.test(testScript) && ['test', testScript, 'package.json'],
!!pyproject && pyproject.includes('pytest') && ['test', 'pytest', 'pyproject.toml'],
has('Cargo.toml') && ['test', 'cargo test', 'Cargo.toml'],
has('go.mod') && ['test', 'go test ./...', 'go.mod'],
which('knip') && ['deadcode', 'knip', 'PATH'],
!!pkgText?.includes('"knip"') && ['deadcode', 'npx knip', 'package.json'],
which('shellcheck') && shellFiles && ['shell', 'shellcheck', 'PATH, *.sh in the root, scripts/ or bin/'],
which('gbrain') && !!env.HOME && fs.existsSync(path.join(env.HOME, '.gbrain', 'config.json')) && ['gbrain', 'timeout 5s gbrain doctor --json', 'PATH, ~/.gbrain/config.json'],
];
const checks = found.filter((c): c is [string, string, string] => !!c).map(([dimension, command, source]) => ({ dimension, command, source }));
const reasons: Record<string, string> = {
typecheck: 'no tsconfig.json',
lint: 'no biome.json, eslint config, or pyproject.toml naming ruff or pylint',
test: typeof testScript === 'string' && /[\r\n]/.test(testScript) ? 'the package.json test script spans lines; list it in a CLAUDE.md Health Stack section' : 'no package.json test script, pytest in pyproject.toml, Cargo.toml or go.mod',
deadcode: 'knip is not on PATH or in package.json',
shell: which('shellcheck') ? 'no *.sh file in the root, scripts/ or bin/' : 'shellcheck is not on PATH',
gbrain: 'gbrain is not on PATH or ~/.gbrain/config.json is absent',
};
return { checks, missing: HEALTH_DIMENSIONS.filter(d => !checks.some(c => c.dimension === d)).map(dimension => ({ dimension, reason: reasons[dimension] })) };
}
/** Every command a run could execute, read without executing any: the checks, the pregate runner and each selection manifest command. */
export function commandSet(root: string, checks: readonly Check[], config: Pick<PregateConfig, 'runner' | 'selection'>): string {
return sha256(JSON.stringify({ checks: checks.map(c => [c.name, c.command, c.body ?? '']), scripts: packageScripts(root), runner: config.runner ?? detectedRunnerInputs(root), selection: Object.entries(config.selection ?? {}).sort(([a], [b]) => a.localeCompare(b)) }));
+43 -15
View File
@@ -85,24 +85,52 @@ export function isShallow(cwd: string): boolean {
/**
* The base as a ref that resolves here and, when `head` is given, shares
* history with it: the ref itself, else its remote-tracking branch
* (`origin/main` for `main`). A clone that checked out only the feature
* branch has no local `main`, and the merge-base against `origin/main` is the
* same commit; a stale local `main` that shares no history with HEAD (a
* fetched-over clone) yields to the twin too, so the fix line's fetch into the
* tracking ref is a fix and not a loop.
* history with it. A bare branch name resolves to origin's tracking branch
* (`refs/remotes/origin/main` for `main`), the base /ship merges against and
* the one review-collect's collectLive and review-dispatch read: a stale local
* `main` behind it would scan upstream commits the branch only inherited
* (#3650, GST-883), so a local branch that differs is reported as `differs`.
* Without a related origin twin the related local branch wins over any other
* remote, so a fork's branch cannot replace it; it is `fallback` when origin
* lacks the branch or shares no history with HEAD (named as `unrelatedRemote`),
* and plain with no origin. Another remote's tracking branch is used only when
* no related local branch exists. A clone that checked out only the feature
* branch has no local `main` (`missing`); a local `main` that shares no
* history with HEAD is `unrelated`, so the fix line's fetch into the tracking
* ref is a fix and not a loop. Branches are read by their full names
* (`refs/heads/`, `refs/remotes/`), so a tag named `main` or `origin/main`
* cannot stand in for them, and a remote-qualified name (`upstream/main`) is
* taken as given.
*/
export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' } | null {
export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' | 'differs' | 'fallback'; unrelatedRemote?: string } | null {
const related = (sha: string) => !head || mergeBase(sha, head, cwd) !== null;
const direct = resolvesToCommit(ref, cwd);
if (direct && related(direct)) return { ref, sha: direct };
if (!isBranchName(ref)) return direct ? { ref, sha: direct } : null;
for (const remote of remoteNames(cwd)) {
const tracking = `${remote}/${ref}`;
const sha = resolvesToCommit(tracking, cwd);
if (sha && related(sha)) return { ref: tracking, sha, local: direct ? 'unrelated' : 'missing' };
const direct = () => resolvesToCommit(ref, cwd);
if (!isBranchName(ref)) { const sha = direct(); return sha ? { ref, sha } : null; }
const qualified = resolvesToCommit(`refs/remotes/` + ref, cwd);
if (qualified) return { ref, sha: qualified };
const local = resolvesToCommit(`refs/heads/` + ref, cwd);
const remotes = remoteNames(cwd);
const localRelated = !!local && related(local);
const twin = (remote: string) => {
const sha = resolvesToCommit(`refs/remotes/` + remote + '/' + ref, cwd);
if (!sha || !related(sha)) return null;
const tracking = remote + '/' + ref;
return sha === local ? { ref: tracking, sha } : { ref: tracking, sha, local: !local ? 'missing' as const : localRelated ? 'differs' as const : 'unrelated' as const };
};
const hasOrigin = remotes.includes('origin');
const origin = hasOrigin ? twin('origin') : null;
if (origin) return origin;
const originRef = resolvesToCommit(`refs/remotes/origin/` + ref, cwd);
const fallback = hasOrigin ? { local: 'fallback' as const, ...(originRef ? { unrelatedRemote: 'origin/' + ref } : {}) } : {};
if (localRelated) return { ref, sha: local!, ...fallback };
for (const remote of remotes) {
if (remote === 'origin') continue;
const other = twin(remote);
if (other) return other;
}
return direct ? { ref, sha: direct } : null;
if (local) return { ref, sha: local, ...(remotes.length ? { local: 'fallback' as const, ...(originRef ? { unrelatedRemote: 'origin/' + ref } : {}) } : {}) };
const sha = direct();
return sha ? { ref, sha } : null;
}
/** A word for a printed shell line: as is when it is plain, single-quoted otherwise (ref names may hold `;`, `$`, `(`, `{1,2}`). */
+17
View File
@@ -41,9 +41,12 @@ export interface GateItem {
carried?: { from: string; chosen: string; gate_rev: number };
/** A round-cap item (lib/autoplan-budget.ts): `b` grants the phase one more round and reopens it. */
cap?: { phase: string; kind: 'deferred' | 'blocker' | 'delta'; ref?: string };
/** The buildGate branch that wrote it (lib/autoplan-gate.ts); `kind` alone mislabels disagreements and single-voice challenges. */
item_class?: ItemClass;
/** An open Critical or High finding (its ids, a paired twin included): `a` applies the reviewer's fix to the plan. */
finding?: string[];
}
export type ItemClass = 'challenge2' | 'challenge1' | 'disagree' | 'agent' | 'cap_deferred' | 'cap_blocker' | 'cap_delta' | 'finding' | 'p1';
export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string;
/** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */
rec_rev?: number;
@@ -219,6 +222,20 @@ export function keepDirection(item: GateItem): string {
return item.options.find(o => o.changes_plan === false)?.key ?? (item.id === 'p1' && item.options.some(o => o.key === 'a') ? 'a' : item.recommended);
}
/** The driver's own pick when nobody answers (a CEO-only run, and the shadow's free baseline): the direction kept; a cap approval stays undecided. */
export function ruleKept(item: GateItem): string | null {
return (item.cap && item.kind === 'approval') || item.finding ? null : keepDirection(item);
}
/** How the owner's own words answered: their reply mode, and the item ids they named with an `<id><option>` token. */
export function replyShape(words: string, list: GateList, parsed: ParsedReply): { mode: 'rec' | 'rec_except' | 'tokens' | 'as_is' | 'unparsed'; named: Set<string> } {
if (!parsed.valid) return { mode: 'unparsed', named: new Set() };
const tokens = tokenize(words, list);
const named = new Set(tokens.flatMap(t => { const m = /^([a-z]*[0-9]+)([a-z])$/.exec(t); return m && list.items.some(i => i.id === m[1]) ? [m[1]!] : []; }));
const mode = parsed.mode === 'rec' ? (tokens.includes('except') ? 'rec_except' : 'rec') : parsed.mode === 'as-is' ? 'as_is' : 'tokens';
return { mode, named };
}
/** What an unanswered `auto` item resolves to: its recommendation, unless that changes the plan (then the plan's direction). */
export function autoDefault(item: GateItem): string {
return item.options.find(o => o.key === item.recommended)?.changes_plan ? keepDirection(item) : item.recommended;
+5 -2
View File
@@ -41,6 +41,8 @@ export interface DecisionRow {
recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string;
/** An agent-relayed answer: the owner's verbatim words, and whether they parsed under the gate grammar. */
owner_reply?: string; owner_reply_parsed?: boolean; recommended_by?: string;
/** How the owner's own words answered (lib/gate-list.ts replyShape): their mode, whether they named this item, the rec_hash answered against, and who typed the reply. */
owner_mode?: 'rec' | 'rec_except' | 'tokens' | 'as_is' | 'unparsed'; owner_named?: boolean; rec_hash?: string; answered_role?: 'human' | 'agent';
/** An agent's own words (`--words-by agent:<id>`): never the owner's, so never carried or imported as the owner's. */
answer_words?: string; answer_words_parsed?: boolean; words_by?: string;
/** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision whose owner words this answer reuses, its gate_rev, why, who relayed them, items taken under "do it", and the delta check attempt. */
@@ -106,7 +108,7 @@ export interface ShipReceipt {
// Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation.
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> };
/** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; rounds?: number; dispatches?: Record<string, number>; est_input_tokens?: number };
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; rounds?: number; dispatches?: Record<string, number>; est_input_tokens?: number; unrecorded?: number; ledgers_read?: number };
carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' };
/** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */
head_tree?: string; product_patch_id?: string;
@@ -179,6 +181,7 @@ export const ARTIFACT_SCHEMAS = {
kind: { enum: ['auto', 'approval', 'user_challenge'] }, status: { enum: ['pending', 'approved', 'overridden', 'rejected', 'auto_kept'] },
default_if_unanswered: nullableStr, gate_rev: { type: 'integer', minimum: 1 }, phase: str, chosen: nullableStr,
answered_at: str, reply: str, source: str, cost: str,
owner_mode: { enum: ['rec', 'rec_except', 'tokens', 'as_is', 'unparsed'] }, owner_named: { type: 'boolean' }, rec_hash: str, answered_role: { enum: ['human', 'agent'] },
recommended_basis: str, changes: str, origin: str, carried_from: str, chosen_text: str,
carried_gate_from: str, carried_gate_rev: { type: 'integer', minimum: 1 }, carry_reason: str, carried_answered_by: str, carried_under: str, carry_check: str,
},
@@ -238,7 +241,7 @@ export const ARTIFACT_SCHEMAS = {
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' },
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, rounds: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } },
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, rounds: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 }, unrecorded: { type: 'integer', minimum: 0 }, ledgers_read: { type: 'integer', minimum: 0 } } },
carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } },
head_tree: str, product_patch_id: str, gstack_version: str,
base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } },
+56
View File
@@ -0,0 +1,56 @@
/**
* Model detection for loadouts (GSTA-161, PR 1b): which loadout an install
* serves, from explicit inputs only.
*
* Order: (1) an explicit loadout name (`--loadout`), served as asked and bound
* to no model; (2) an explicit model id (`--model`), else `GSTACK_MODEL`;
* (3) nothing, which is full. A model id is normalized (trim, lowercase, a
* trailing `[…]` and a `provider/` prefix dropped) and must match MODEL_ID_RE
* before it can reach generated shell or a launch file. An invalid, unmapped
* or unreadable input always resolves to full and never errors; only an
* unknown explicit loadout name is refused. No Claude settings, overlay
* preference or inherited loadout variable is read.
* Guide: docs/loadouts.md.
*/
import { FULL, MODEL_ID_RE, ModuleError, loadLoadouts, loadoutForModel, normalizeModelId, rungFor, type LoadoutsManifest } from '../scripts/modules';
export interface LoadoutResolution {
loadout: string;
source: 'loadout-flag' | 'model-flag' | 'GSTACK_MODEL' | 'default';
/** The model an automatic selection is bound to (the matched manifest.models key, so a dated snapshot binds to its base id); null for an explicit or default selection. */
model: string | null;
/** Why a given model resolved to full. */
reason?: 'unmapped' | 'invalid' | 'manifest-unreadable' | 'render-failed';
/** A LOADOUT_INVALID_MODEL line to print, when the model id was refused. */
notice?: string;
}
export function resolveLoadout(input: { loadout?: string; model?: string; env?: Record<string, string | undefined>; manifest?: LoadoutsManifest }): LoadoutResolution {
const env = input.env ?? process.env;
if (input.loadout !== undefined) {
const manifest = input.manifest ?? loadLoadouts();
if (!Object.hasOwn(manifest.loadouts, input.loadout)) {
throw new ModuleError('LOADOUT_UNKNOWN', `${JSON.stringify(input.loadout.slice(0, 40))} is not a loadout (valid: ${Object.keys(manifest.loadouts).sort().join(', ')})`, 'gstack-modules list');
}
return { loadout: input.loadout, source: 'loadout-flag', model: null };
}
const raw = input.model ?? env.GSTACK_MODEL;
if (raw === undefined || raw.trim() === '') return { loadout: FULL, source: 'default', model: null };
const source = input.model !== undefined ? 'model-flag' : 'GSTACK_MODEL';
const id = normalizeModelId(raw);
if (!MODEL_ID_RE.test(id)) {
const shown = JSON.stringify(raw.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 40));
return { loadout: FULL, source, model: null, reason: 'invalid', notice: `LOADOUT_INVALID_MODEL: ${shown} is not a plain model id; using full (docs/troubleshooting.md#loadout-invalid-model)` };
}
let manifest: LoadoutsManifest;
try { manifest = input.manifest ?? loadLoadouts(); } catch { return { loadout: FULL, source, model: null, reason: 'manifest-unreadable' }; }
const mapped = loadoutForModel(manifest, id);
const loadout = mapped?.loadout ?? manifest.default?.[rungFor(manifest, id)] ?? FULL;
if (loadout === FULL || !Object.hasOwn(manifest.loadouts, loadout)) return { loadout: FULL, source, model: null, reason: 'unmapped' };
return { loadout, source, model: mapped?.key ?? id };
}
/** One printable line: `LOADOUT: <name> model=<id|none> source=<s>[ reason=<r>]`. */
export function describeResolution(r: LoadoutResolution): string {
return `LOADOUT: ${r.loadout} model=${r.model ?? 'none'} source=${r.source}${r.reason ? ` reason=${r.reason}` : ''}`;
}
+15 -9
View File
@@ -11,10 +11,12 @@
import { sha256Hex, writeReceipt } from './egress-receipt';
import { modelFamily, type ModelFamily } from './outside-voice-runner';
export interface ApiCallRequest { model: string; prompt: string; timeoutMs: number; env?: Record<string, string | undefined>; signal?: AbortSignal; maxTokens?: number }
/** Who sends and on whose consent: replaces the outside-voice receipt for a caller with its own sink (lib/autoplan-shadow.ts). */
export interface ApiReceipt { sink: string; consent: string; payloadClass?: string }
export interface ApiCallRequest { model: string; prompt: string; timeoutMs: number; env?: Record<string, string | undefined>; signal?: AbortSignal; maxTokens?: number; receipt?: ApiReceipt }
export interface ApiUsage { input_tokens?: number; output_tokens?: number }
/** `truncated`: the provider stopped at the output token limit (Anthropic `max_tokens`, OpenAI `length`), so the text is cut off. */
export interface ApiCallResult { text: string; model: string; usage: ApiUsage; provider: 'openai' | 'anthropic'; status: number; truncated: boolean }
/** `truncated`: the provider stopped at the output token limit (Anthropic `max_tokens`, OpenAI `length`), so the text is cut off; `stop_reason`: the provider's stop or finish reason when it sent one. */
export interface ApiCallResult { text: string; model: string; usage: ApiUsage; provider: 'openai' | 'anthropic'; status: number; truncated: boolean; stop_reason?: string }
/** Anthropic requires max_tokens; a full review needs more than 8192. `run` sends this to OpenAI too, where reasoning tokens count toward it. */
export const DEFAULT_MAX_TOKENS = 16_384;
@@ -29,11 +31,13 @@ export function apiKeyFor(family: ModelFamily, env: Record<string, string | unde
return undefined;
}
function receipted(url: string, body: string, provider: string): void {
function receipted(url: string, body: string, provider: string, req: ApiCallRequest): void {
const receipt = req.receipt;
try {
writeReceipt({
sink: 'outside-voice-api', host: new URL(url).host, payloadClass: 'review-prompt', bytes: Buffer.byteLength(body), sha256: sha256Hex(body),
consent: `user ran gstack-outside-voice --runner api (${provider.toUpperCase()}_API_KEY configured)`,
env: req.env,
sink: receipt?.sink ?? 'outside-voice-api', host: new URL(url).host, payloadClass: receipt?.payloadClass ?? 'review-prompt', bytes: Buffer.byteLength(body), sha256: sha256Hex(body),
consent: receipt?.consent ?? `user ran gstack-outside-voice --runner api (${provider.toUpperCase()}_API_KEY configured)`,
});
} catch (err) {
process.stderr.write(`[outside-voice] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`);
@@ -41,7 +45,7 @@ function receipted(url: string, body: string, provider: string): void {
}
async function post(url: string, headers: Record<string, string>, body: string, req: ApiCallRequest, provider: string): Promise<{ status: number; json: any }> {
receipted(url, body, provider);
receipted(url, body, provider, req);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error(`timeout after ${req.timeoutMs} ms`)), req.timeoutMs);
req.signal?.addEventListener('abort', () => controller.abort(req.signal!.reason), { once: true });
@@ -67,7 +71,8 @@ export async function callModel(req: ApiCallRequest): Promise<ApiCallResult> {
const { status, json } = await post(`${base}/v1/messages`, { 'x-api-key': auth.key, 'anthropic-version': '2023-06-01' }, body, req, 'anthropic');
if (status < 200 || status >= 300) throw new Error(`anthropic ${status}: ${json?.error?.message ?? json?.raw ?? JSON.stringify(json)}`);
const text = Array.isArray(json?.content) ? json.content.filter((c: any) => c?.type === 'text').map((c: any) => c.text).join('\n') : '';
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.input_tokens, output_tokens: json?.usage?.output_tokens }, provider: 'anthropic', status, truncated: json?.stop_reason === 'max_tokens' };
const stop = typeof json?.stop_reason === 'string' ? json.stop_reason : undefined;
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.input_tokens, output_tokens: json?.usage?.output_tokens }, provider: 'anthropic', status, truncated: stop === 'max_tokens', ...(stop ? { stop_reason: stop } : {}) };
}
const base = (env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1').replace(/\/$/, '');
const body = JSON.stringify({ model, messages: [{ role: 'user', content: req.prompt }], ...(req.maxTokens ? { max_completion_tokens: req.maxTokens } : {}) });
@@ -75,5 +80,6 @@ export async function callModel(req: ApiCallRequest): Promise<ApiCallResult> {
if (status < 200 || status >= 300) throw new Error(`openai ${status}: ${json?.error?.message ?? json?.raw ?? JSON.stringify(json)}`);
const content = json?.choices?.[0]?.message?.content;
const text = typeof content === 'string' ? content : Array.isArray(content) ? content.map((c: any) => c?.text ?? '').join('\n') : '';
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.prompt_tokens, output_tokens: json?.usage?.completion_tokens }, provider: 'openai', status, truncated: json?.choices?.[0]?.finish_reason === 'length' };
const finish = json?.choices?.[0]?.finish_reason;
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.prompt_tokens, output_tokens: json?.usage?.completion_tokens }, provider: 'openai', status, truncated: finish === 'length', ...(typeof finish === 'string' ? { stop_reason: finish } : {}) };
}
+33 -13
View File
@@ -49,6 +49,26 @@ export function parseAddedLineNumbers(diff: string): Map<string, number[]> {
return files;
}
/** Each file's whole postimage from a diff with full context: one hunk starting at line 1, its context and added lines; a file with any other shape is left out. */
export function parsePostimages(diff: string): Map<string, string[]> {
const files = new Map<string, string[]>();
let file = '';
let lines: string[] | null = null;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --')) { file = ''; lines = null; continue; }
if (line.startsWith('@@')) {
const first: boolean = lines === null && file !== '' && /^@@ -\d+(?:,\d+)? \+1(?:,\d+)? @@/.test(line);
files.delete(file);
lines = first ? [] : null;
if (lines) files.set(file, lines); else file = '';
continue;
}
if (lines === null) { if (line.startsWith('+++ ')) file = line.startsWith('+++ b/') ? line.slice(6) : ''; continue; }
if (line.startsWith('+') || line.startsWith(' ')) lines.push(line.slice(1));
}
return files;
}
/** Files whose source-syntax exemption needs the whole file: a line added inside an existing `'''`/`"""` block is a literal (#3630). */
const LEXICAL_CONTEXT_SOURCE = /\.(?:dart|pyi?)$/i;
const WHOLE_FILE_MAX_BYTES = 4 * 1024 * 1024;
@@ -58,10 +78,11 @@ const WHOLE_FILE_MAX_BYTES = 4 * 1024 * 1024;
* untracked files, grouped by file so gstack-redact can use each file's type
* (#3211). A tracked Dart or Python file is passed whole with `keep`, its
* added line numbers, so the scanner sees the strings an added line sits in
* and only findings on those lines count (#3630); an untracked one keeps every
* line. One too large, unreadable, not a regular file, or whose working copy
* no longer holds the diff's added lines at their numbers
* to read whole is scanned under a neutral name, without that exemption. External diff and textconv
* and only findings on those lines count (#3630). The whole text is the
* postimage git diffed (a full-context diff), never the working copy, which
* assume-unchanged can hide from git (GST-738); an untracked file keeps every
* line. One too large, or whose full diff does not parse as one hunk holding
* its added lines, is scanned under a neutral name, without that exemption. External diff and textconv
* drivers are off: either can empty or rewrite the diff, and an empty diff
* reads as clean.
*/
@@ -70,16 +91,15 @@ export function addedLines(ctx: PregateContext): Array<{ file: string; text: str
const files = parseAddedLines(diff.stdout ?? '');
const numbers = parseAddedLineNumbers(diff.stdout ?? '');
const whole = new Map<string, { text: string; keep: number[] }>();
for (const [file, keep] of numbers) {
if (!LEXICAL_CONTEXT_SOURCE.test(file)) continue;
try {
const st = fs.lstatSync(path.join(ctx.repoRoot, file));
if (!st.isFile() || st.size > WHOLE_FILE_MAX_BYTES) continue;
const text = fs.readFileSync(path.join(ctx.repoRoot, file), 'utf8');
const lines = text.split('\n');
const sources = [...numbers.keys()].filter(file => LEXICAL_CONTEXT_SOURCE.test(file));
if (sources.length) {
const full = spawnSync('git', ['-c', 'diff.suppressBlankEmpty=false', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--src-prefix=a/', '--dst-prefix=b/', '--unified=100000000', '--diff-filter=ACMR', ctx.mergeBase, '--', ...sources.map(file => `:(literal)${file}`)], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024, env: ctx.env });
for (const [file, post] of parsePostimages(full.status === 0 ? full.stdout ?? '' : '')) {
const keep = numbers.get(file) ?? [];
const added = files.get(file) ?? [];
if (keep.length === added.length && keep.every((n, k) => lines[n - 1] === added[k])) whole.set(file, { text, keep });
} catch { /* unreadable: its added lines are scanned alone */ }
const text = post.join('\n');
if (text.length <= WHOLE_FILE_MAX_BYTES && keep.length === added.length && keep.every((n, k) => post[n - 1] === added[k])) whole.set(file, { text, keep });
}
}
for (const t of ctx.touched) {
if (!t.uncommitted || t.status === 'D') continue;
+1 -1
View File
@@ -348,7 +348,7 @@ export function explainConfig(): string {
'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, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)',
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, label_keys, identifier_keys, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs; label_keys turns on sealed.label_join, which refuses a published record that puts one of those fields next to an identifier (identifier_keys globs, or a hex value) (docs/pregate.md#sealed)',
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'timeout_s: seconds each runner process may take (the unit-file group, each serial file, each guard without its own timeout_s, and the remote gate\'s deadline) when --timeout is not passed; 1 to 3600, default 600',
+22 -2
View File
@@ -27,7 +27,7 @@ import { NON_DOWNGRADABLE, PREGATE_CONFIG_PATH, userPolicyAbsent } from './confi
import { sha256, withFingerprint } from './context';
import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnlyTree } from './remote-receipt';
import { resolveBaseBranch } from '../ship-policy';
import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence';
import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence';
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
@@ -217,7 +217,12 @@ export type ClearDecision = { receipt: string; attested: boolean; substitution?:
* tree and the substitution recorded; when any differing path is not a stamp,
* the tree mismatch refuses as before. A `test_backend/<backend>` obligation
* has no machine-checkable receipt, so its run URL is recorded as attested.
* Platform lanes take a run URL or an evidence-bundle lane label.
* Platform lanes take a run URL or an evidence-bundle lane label. When the run
* evidence is readable, a run URL must name a run of this repository whose
* workflow file is the lane's own and that completed with success; a run of
* another workflow, a red or unfinished run, an unreadable run or a URL that
* is not an Actions run refuses (GSTA-203), and a green run is recorded as the
* attempt URL it judged.
*
* `--run` (and `--run-url` on a lane the base branch profiles) goes to
* clearByRuns, which accepts only verified Actions runs; a profiled lane
@@ -253,6 +258,21 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput
if (!input.runUrl) return { refused: 'usage', message: `${lane} clears with --run-url <url>` };
return { receipt: `attested:${input.runUrl}`, attested: true };
}
if (input.runUrl && input.evidence) {
const repo = input.evidence.repo;
const invalid = (why: string, fix: string): ClearDecision => ({ refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=${why}; ${lane} stays uncleared`, fix });
const id = parseRunRef(input.runUrl.replace(/[?#].*$/, ''), repo);
if (id === null) return invalid(`run_ref_invalid — ${JSON.stringify(input.runUrl)} is not a run id or a run URL on ${repo}`, `pass the run's numeric id or https://github.com/${repo}/actions/runs/<id>`);
let run: ApiRun | null;
try { run = input.evidence.deps.run(id); }
catch (e: any) { return invalid(`api_error — reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`, `check gh auth status and that gh can read ${repo}'s Actions runs, then rerun the same clear`); }
const want = `.github/workflows/${target.workflow}`;
if (!run || run.path !== want) return invalid(`workflow_mismatch — run ${id} attempt ${run?.run_attempt ?? '?'} ran ${run?.path ?? 'an unknown workflow'}, not ${want}`, `clear ${lane} with a run of its own workflow: gh workflow run ${target.workflow} --ref <branch>, then gstack-pregate clear --remote ${lane} --run-url <that run's URL>`);
const green = `then gstack-pregate clear --remote ${lane} --run-url <the green run's URL>`;
if (run.status !== 'completed') return invalid(`run_in_progress — run ${id} attempt ${run.run_attempt} is ${run.status}`, `wait for run ${id} to finish green (or rerun ${target.workflow} if it fails), ${green}`);
if (run.conclusion !== 'success') return invalid(`run_not_success — run ${id} attempt ${run.run_attempt} concluded ${run.conclusion}`, `rerun ${target.workflow} (gh run rerun ${id}, or gh workflow run ${target.workflow} --ref <branch>), ${green}`);
return { receipt: `https://github.com/${repo}/actions/runs/${id}/attempts/${run.run_attempt}`, attested: false };
}
const receipt = input.runUrl ?? (input.bundleLane ? `bundle-lane:${input.bundleLane}` : undefined);
if (!receipt) return { refused: 'usage', message: `${lane} clears with --run-url <url> or --bundle-lane <label>` };
return { receipt, attested: false };
+118 -18
View File
@@ -694,7 +694,8 @@ const CODE_NAME_CHAIN = /^[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)*(?:<[\w$.,\s
* text) records every string's content span: a `'''`/`"""` block, or a
* `'`/`"` string that ends at its line unless a backslash continues it.
* Comments are skipped (`#` in Python; `//` and nested `/* *\/` in Dart), so a
* delimiter inside one opens nothing; a backslash escapes the next character, except in a Dart raw
* delimiter inside one opens nothing, and a Dart block comment's own span is
* recorded too: a `password:` line in it is text, not code (GST-738); a backslash escapes the next character, except in a Dart raw
* string (`r'''…'''`). The exemption needs the
* whole file to see a block's opener: pregate passes Dart and Python files
* whole, and the pre-push hook, which scans added lines alone, passes them
@@ -708,34 +709,133 @@ export function clearStringSpanCache(): void {
stringSpansCache = null;
}
function stringSpans(text: string, lang: "py" | "dart"): Array<[number, number]> {
/** The index after the comment at `i`: a line comment ends at its newline, a Dart block comment at its matching `*\/` (they nest). */
function commentEnd(text: string, i: number, lang: "py" | "dart"): number {
if (lang === "py" || !text.startsWith("/*", i)) {
const eol = text.indexOf("\n", i);
return eol < 0 ? text.length : eol;
}
let depth = 1;
let j = i + 2;
while (j < text.length && depth > 0) {
if (text.startsWith("/*", j)) { depth += 1; j += 2; }
else if (text.startsWith("*/", j)) { depth -= 1; j += 2; }
else j += 1;
}
return j;
}
const MAX_INTERPOLATION_DEPTH = 256;
type Spans = Array<[number, number]>;
/**
* The index after a Dart `${…}` expression starting at `j`. Its code is not
* text; its strings and block comments add their own spans.
*/
function dartExpressionEnd(text: string, j: number, depth: number, spans: Spans): number {
if (depth > MAX_INTERPOLATION_DEPTH) return text.length;
for (let open = 1; j < text.length && open > 0; ) {
const d = text[j]!;
if (text.startsWith("//", j)) { j = commentEnd(text, j, "dart"); continue; }
if (text.startsWith("/*", j)) { const end = commentEnd(text, j, "dart"); spans.push([j, end]); j = end; continue; }
if (d === '"' || d === "'") { j = stringAt(text, j, "dart", depth, spans); continue; }
if (d === "{") open += 1;
else if (d === "}") open -= 1;
j += 1;
}
return Math.min(j, text.length);
}
/**
* The index after a Python replacement field whose expression starts at `j`.
* The expression is code: its `#` comments are skipped and its strings add
* their own spans. A `:` or `!` outside its brackets starts the conversion and
* format spec, which is text apart from nested `{…}` fields (`{n:#x}`, `{n:'>10}`).
*/
function pythonFieldEnd(text: string, j: number, depth: number, spans: Spans): number {
const n = text.length;
if (depth > MAX_INTERPOLATION_DEPTH) return n;
let nest = 0;
while (j < n) {
const d = text[j]!;
if (d === "#") { j = commentEnd(text, j, "py"); continue; }
if (d === '"' || d === "'") { j = stringAt(text, j, "py", depth, spans); continue; }
if (d === "}" && nest === 0) return j + 1;
if (nest === 0 && (d === ":" || (d === "!" && text[j + 1] !== "="))) {
let from = j + 1;
for (j += 1; j < n && text[j] !== "}"; ) {
if (text[j] !== "{") { j += 1; continue; }
if (from < j) spans.push([from, j]);
j = pythonFieldEnd(text, j + 1, depth + 1, spans);
from = j;
}
if (from < Math.min(j, n)) spans.push([from, Math.min(j, n)]);
return Math.min(j + 1, n);
}
if ("([{".includes(d)) nest += 1;
else if (")]}".includes(d)) nest = Math.max(0, nest - 1);
j += 1;
}
return n;
}
/**
* Adds the text spans of the string whose quote is at `i` and returns the index
* after its closing delimiter. An interpolated expression (`{…}` in a Python
* f- or t-string, `${…}` in a non-raw Dart string) is code: it is skipped
* whole, with its nested strings as their own spans, so a quote inside it never
* ends the outer string (GST-738). An expression left open, or nested past
* MAX_INTERPOLATION_DEPTH, is text to the end of the file. In a Python f-string
* `\{` still opens a field.
*/
function stringAt(text: string, i: number, lang: "py" | "dart", depth: number, spans: Spans): number {
const n = text.length;
const c = text[i]!;
const close = text.startsWith(c.repeat(3), i) ? c.repeat(3) : c;
const multiline = close.length === 3;
let k = i;
while (k > 0 && /[A-Za-z]/.test(text[k - 1]!)) k -= 1;
const prefix = k === 0 || !/[\w$]/.test(text[k - 1]!) ? text.slice(k, i) : "";
const raw = lang === "dart" && prefix === "r";
const interpolated = lang === "dart" ? !raw : /^(?:[rR]?[fFtT]|[fFtT][rR])$/.test(prefix);
let j = i + close.length;
let from = j;
while (j < n && !text.startsWith(close, j) && (multiline || text[j] !== "\n")) {
if (!raw && text[j] === "\\") { j += lang === "py" && interpolated && text[j + 1] === "{" ? 1 : 2; continue; }
if (!interpolated) { j += 1; continue; }
if (lang === "py" && text.startsWith("{{", j)) { j += 2; continue; }
if (lang === "py" ? text[j] !== "{" : !text.startsWith("${", j)) { j += 1; continue; }
if (from < j) spans.push([from, j]);
const field = j;
const mark = spans.length;
j = lang === "py" ? pythonFieldEnd(text, j + 1, depth + 1, spans) : dartExpressionEnd(text, j + 2, depth + 1, spans);
if (j >= n) { spans.length = mark; from = field; break; }
from = j;
}
const end = Math.min(j, n);
if (from < end) spans.push([from, end]);
return end + close.length;
}
function stringSpans(text: string, lang: "py" | "dart"): Spans {
if (stringSpansCache?.text === text && stringSpansCache.lang === lang) return stringSpansCache.spans;
const spans: Array<[number, number]> = [];
const spans: Spans = [];
const n = text.length;
for (let i = 0; i < n; ) {
const c = text[i]!;
if (lang === "py" ? c === "#" : text.startsWith("//", i)) {
const eol = text.indexOf("\n", i);
i = eol < 0 ? n : eol;
i = commentEnd(text, i, lang);
continue;
}
if (lang === "dart" && text.startsWith("/*", i)) {
let depth = 1;
for (i += 2; i < n && depth > 0; ) {
if (text.startsWith("/*", i)) { depth += 1; i += 2; }
else if (text.startsWith("*/", i)) { depth -= 1; i += 2; }
else i += 1;
}
const end = commentEnd(text, i, lang);
spans.push([i, end]);
i = end;
continue;
}
if (c !== '"' && c !== "'") { i += 1; continue; }
const close = text.startsWith(c.repeat(3), i) ? c.repeat(3) : c;
const multiline = close.length === 3;
const raw = lang === "dart" && text[i - 1] === "r" && !/[\w$]/.test(text[i - 2] ?? "");
let j = i + close.length;
while (j < n && !text.startsWith(close, j) && (multiline || text[j] !== "\n")) j += !raw && text[j] === "\\" ? 2 : 1;
spans.push([i + close.length, Math.min(j, n)]);
i = j + close.length;
i = stringAt(text, i, lang, 0, spans);
}
stringSpansCache = { text, lang, spans };
return spans;
+429
View File
@@ -0,0 +1,429 @@
/**
* repo-state-clean — gstack-clean's plan and apply (#2627; plan GSTA-226 D8,
* D9). Reclaims a fixed allowlist of byproducts in one repository's
* `<git toplevel>/.gstack`, and nothing else:
*
* tmp/codex-* top-level regular files older than 7 days
* browse-*.log over 1 MB, cut in place to the last 256 KB from a
* line start (same inode, so O_APPEND writers go on)
* browse-audit.jsonl only with --audit-days N: entries older than N days
*
* browse.json, session-state.json, chromium-profile/, browser-skills/,
* browse-states/ and every other tmp/ entry (restamp journal, gate-ahead,
* pregate.json, ship receipt, autoplan/, ship-queue/, eval-bisect/) are never
* touched; test/gstack-clean-producers.test.ts classifies every tmp producer.
* Dry run unless --apply. Each change re-checks the file and its group's
* liveness first (live browse daemon, running codex, a write in the last
* hour); an unknown answer skips the group with its reason.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveStateRoot } from './state-root';
import { resultLine } from './result-codes';
export const CLEAN_SCHEMA_VERSION = 1;
const DAY_MS = 24 * 60 * 60 * 1000;
export const SCRATCH_PREFIX = 'codex-';
export const SCRATCH_MIN_AGE_MS = 7 * DAY_MS;
export const RECENT_MS = 60 * 60 * 1000;
const LIVENESS_RECHECK_MS = 2000;
export const LOG_CUT_OVER_BYTES = 1024 * 1024;
export const LOG_KEEP_BYTES = 256 * 1024;
export const BROWSE_LOGS = ['browse-console.log', 'browse-network.log', 'browse-dialog.log', 'browse-daemon.log', 'browse-startup-error.log'] as const;
export const AUDIT_LOG = 'browse-audit.jsonl';
export const USAGE = 'usage: gstack-clean [--apply] [--audit-days N] [--json]';
export const HELP = `gstack-clean — reclaim throwaway files in this repository's .gstack folder
${USAGE}
Reports what it would do and changes nothing unless you pass --apply.
Target: <git toplevel>/.gstack of the current directory. Refuses (exit 3)
outside git, when .gstack is a symlink, when BROWSE_STATE_FILE names another
folder, and when the folder is, holds or sits inside the gstack state root.
What it cleans (fixed thresholds):
tmp/codex-* codex scratch files older than 7 days: removed
browse-console.log, browse-network.log, browse-dialog.log,
browse-daemon.log, browse-startup-error.log
over 1 MB: cut in place to the last 256 KB,
starting after a line break
browse-audit.jsonl only with --audit-days N: entries older than N days
removed; lines without a readable ts are kept
Everything else (browse.json, session-state.json, chromium-profile/,
browser-skills/, browse-states/, and every other tmp/ entry) is never touched.
Files changed in the last hour are skipped, browse files are skipped while the
browse daemon runs, tmp/ is skipped while codex runs, and a group is skipped
when that cannot be told.
Options:
--apply make the changes
--audit-days N also trim browse-audit.jsonl to the last N days (N >= 1)
--json print one versioned JSON object instead of text
Exit codes: 0 done or nothing to do, 1 an action failed (rerunning is safe),
2 usage, 3 target refused.
`;
type Group = 'tmp' | 'browse';
type Kind = 'remove' | 'cut' | 'rotate';
export interface CleanOptions { apply: boolean; json: boolean; auditDays: number | null }
interface Candidate { file: string; group: Group; kind: Kind }
interface Seen { dev: number; ino: number; size: number; mtimeMs: number }
interface Action extends Candidate { bytes: number; reason: string; keep?: Buffer; seen: Seen }
interface Skip { file: string; group: Group; reason: string }
interface Done { action: Action; errno?: string }
export interface CleanTarget { target: string; chosenBy: string }
interface Io { out: (line: string) => void; err: (line: string) => void }
class Refused extends Error {}
class UsageError extends Error {}
export function parseCleanArgs(argv: string[]): CleanOptions | 'help' {
const opts: CleanOptions = { apply: false, json: false, auditDays: null };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === '--help' || arg === '-h') return 'help';
if (arg === '--apply') { opts.apply = true; continue; }
if (arg === '--json') { opts.json = true; continue; }
if (arg === '--audit-days' || arg.startsWith('--audit-days=')) {
const value = arg === '--audit-days' ? argv[++i] : arg.slice('--audit-days='.length);
if (value === undefined || !/^[1-9]\d{0,4}$/.test(value)) throw new UsageError(`--audit-days needs a whole number of days from 1 to 99999; got ${JSON.stringify(value ?? '')}`);
opts.auditDays = Number(value);
continue;
}
throw new UsageError(`unknown flag ${JSON.stringify(arg)}`);
}
return opts;
}
/** realpath when the path exists, else its nearest existing parent's realpath plus the rest. */
function canonical(p: string): string {
const abs = path.resolve(p);
try { return fs.realpathSync(abs); } catch { /* missing: resolve the parent */ }
const parent = path.dirname(abs);
return parent === abs ? abs : path.join(canonical(parent), path.basename(abs));
}
const within = (child: string, parent: string) => child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
/** The verified target, or the missing path when .gstack does not exist. Throws Refused. */
export function resolveCleanTarget(env: NodeJS.ProcessEnv, cwd: string): CleanTarget | { missing: string } {
const git = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000, windowsHide: true });
const top = git.status === 0 ? git.stdout.trim() : '';
if (!top) throw new Refused(`${cwd} is not inside a git work tree`);
const toplevel = fs.realpathSync(top);
const candidate = path.join(toplevel, '.gstack');
if (env.BROWSE_STATE_FILE && canonical(path.resolve(cwd, env.BROWSE_STATE_FILE)) !== canonical(path.join(candidate, 'browse.json'))) {
throw new Refused(`BROWSE_STATE_FILE is ${env.BROWSE_STATE_FILE}, not ${path.join(candidate, 'browse.json')}`);
}
let st: fs.Stats;
try { st = fs.lstatSync(candidate); } catch (e: any) {
if (e?.code === 'ENOENT') return { missing: candidate };
throw e;
}
if (st.isSymbolicLink()) throw new Refused(`${candidate} is a symlink`);
if (!st.isDirectory()) throw new Refused(`${candidate} is not a directory`);
const target = fs.realpathSync(candidate);
const stateRoot = canonical(path.resolve(cwd, resolveStateRoot(env)));
if (within(target, stateRoot) || within(stateRoot, target)) {
throw new Refused(`${target} is, holds or sits inside the gstack state root ${stateRoot}`);
}
const from = canonical(cwd) === toplevel ? '' : `, found from ${cwd}`;
return { target, chosenBy: `the .gstack folder at the git toplevel ${toplevel}${from}` };
}
function pidAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch (e: any) { return e?.code !== 'ESRCH'; }
}
/** Why browse files must wait, or null when no browse daemon can be using them. */
function browseBusy(target: string): string | null {
if (process.platform === 'win32') return 'cannot tell whether the browse daemon is running on Windows';
for (const name of ['browse.json', 'browse.json.lock']) {
let text: string;
try { text = fs.readFileSync(path.join(target, name), 'utf8'); } catch (e: any) {
if (e?.code === 'ENOENT') continue;
return `cannot read ${name} (${e?.code ?? 'error'}) to tell whether the browse daemon is running`;
}
let pid: unknown;
try { pid = name === 'browse.json' ? JSON.parse(text)?.pid : Number(text.trim()); } catch { pid = undefined; }
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return `${name} names no pid, so the browse daemon may be running`;
if (pidAlive(pid)) return `the browse daemon (pid ${pid}, ${name}) is running`;
}
return null;
}
/** Why tmp/ must wait, or null when no codex process runs. */
function codexBusy(): string | null {
if (process.platform === 'win32') return 'cannot tell whether codex is running on Windows';
const ps = spawnSync('ps', ['-A', '-o', 'args='], { encoding: 'utf8', timeout: 10_000, windowsHide: true });
if (ps.error || ps.status !== 0) return 'cannot list processes to tell whether codex is running';
for (const line of ps.stdout.split('\n')) {
const words = line.trim().split(/\s+/).slice(0, 2);
if (words.some(w => /^codex(?:\.js|-[^.]*)?$/.test(path.basename(w)))) return `codex is running (${line.trim().slice(0, 80)})`;
}
return null;
}
/** Audit lines to keep (newer than the cutoff, or without a readable ts), or why the file is malformed. */
function auditKeep(buf: Buffer, cutoffMs: number): Buffer | string {
const kept: string[] = [];
for (const [i, line] of buf.toString('utf8').split(/(?<=\n)/).entries()) {
const body = line.replace(/\r?\n$/, '');
if (body.trim() === '') { kept.push(line); continue; }
let entry: unknown;
try { entry = JSON.parse(body); } catch { return `line ${i + 1} is not JSON, so the file was left as it is`; }
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return `line ${i + 1} is not a JSON object, so the file was left as it is`;
const ts = Date.parse(String((entry as { ts?: unknown }).ts ?? ''));
if (Number.isNaN(ts) || ts >= cutoffMs) kept.push(line);
}
return Buffer.from(kept.join(''), 'utf8');
}
function readTail(file: string, size: number): Buffer {
const fd = fs.openSync(file, 'r');
try {
const length = Math.min(size, LOG_KEEP_BYTES);
const buf = Buffer.alloc(length);
return buf.subarray(0, fs.readSync(fd, buf, 0, length, size - length));
} finally { fs.closeSync(fd); }
}
/**
* One file's action, a skip, or null when the file is not a target. `busy` is
* the group's liveness answer; applyClean asks it again right before a change.
*/
function evaluate(c: Candidate, dir: string, busy: string | null, opts: CleanOptions, now: number): Action | Skip | null {
let st: fs.Stats;
try { st = fs.lstatSync(c.file); } catch (e: any) {
return e?.code === 'ENOENT' ? null : { ...c, reason: `cannot stat (${e?.code ?? 'error'})` };
}
if (st.isSymbolicLink()) return { ...c, reason: 'is a symlink; left alone' };
if (!st.isFile()) return c.kind === 'remove' ? { ...c, reason: 'is not a regular file; left alone' } : null;
let real: string;
try { real = fs.realpathSync(c.file); } catch (e: any) {
return e?.code === 'ENOENT' ? null : { ...c, reason: `cannot resolve (${e?.code ?? 'error'})` };
}
if (path.dirname(real) !== dir) return { ...c, reason: 'resolves outside the folder being cleaned; left alone' };
if (c.kind === 'remove' && now - st.mtimeMs < SCRATCH_MIN_AGE_MS) return null;
if (c.kind === 'cut' && st.size <= LOG_CUT_OVER_BYTES) return null;
if (now - st.mtimeMs < RECENT_MS) return { ...c, reason: 'modified within the last hour' };
if (busy) return { ...c, reason: busy };
const seen: Seen = { dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs };
if (c.kind === 'remove') return { ...c, seen, bytes: st.size, reason: 'codex scratch older than 7 days' };
if (st.nlink !== 1) return { ...c, reason: `has ${st.nlink} hard links, so cutting it would change a file outside this folder; left alone` };
let content: Buffer;
try { content = c.kind === 'cut' ? readTail(c.file, st.size) : fs.readFileSync(c.file); } catch (e: any) {
return { ...c, reason: `cannot read (${e?.code ?? 'error'}); left alone` };
}
if (c.kind === 'cut') {
const tail = content;
const at = tail.indexOf(0x0a);
if (at < 0) return { ...c, reason: 'no line break in its last 256 KB; left alone' };
const keep = tail.subarray(at + 1);
return { ...c, seen, keep, bytes: st.size - keep.length, reason: 'over 1 MB; keeps the last 256 KB from a line start' };
}
let keep: Buffer | string;
try { keep = auditKeep(content, now - opts.auditDays! * DAY_MS); } catch (e: any) {
return { ...c, reason: `cannot read (${e?.code ?? e?.name ?? 'error'}); left alone` };
}
if (typeof keep === 'string') return { ...c, reason: keep };
if (keep.length === st.size) return null;
return { ...c, seen, keep, bytes: st.size - keep.length, reason: `audit entries older than ${opts.auditDays} days` };
}
function candidates(target: string, opts: CleanOptions): { list: Candidate[]; skips: Skip[] } {
const list: Candidate[] = [];
const skips: Skip[] = [];
const tmp = path.join(target, 'tmp');
let tmpStat: fs.Stats | null = null;
try { tmpStat = fs.lstatSync(tmp); } catch (e: any) {
if (e?.code !== 'ENOENT') skips.push({ file: tmp, group: 'tmp', reason: `cannot stat (${e?.code ?? 'error'}); the tmp group is skipped` });
}
if (tmpStat?.isSymbolicLink()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp/ is a symlink; the tmp group is refused' });
else if (tmpStat && !tmpStat.isDirectory()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp is not a directory; the tmp group is skipped' });
else if (tmpStat) {
let names: string[] = [];
try { names = fs.readdirSync(tmp).sort(); } catch (e: any) {
skips.push({ file: tmp, group: 'tmp', reason: `cannot list (${e?.code ?? 'error'}); the tmp group is skipped` });
}
for (const name of names) if (name.startsWith(SCRATCH_PREFIX)) list.push({ file: path.join(tmp, name), group: 'tmp', kind: 'remove' });
}
for (const name of BROWSE_LOGS) list.push({ file: path.join(target, name), group: 'browse', kind: 'cut' });
if (opts.auditDays !== null) list.push({ file: path.join(target, AUDIT_LOG), group: 'browse', kind: 'rotate' });
return { list, skips };
}
const dirOf = (c: Candidate, target: string) => (c.group === 'tmp' ? path.join(target, 'tmp') : target);
const busyFor = (group: Group, target: string) => (group === 'tmp' ? codexBusy() : browseBusy(target));
export function planClean(target: string, opts: CleanOptions, now = Date.now()): { actions: Action[]; skips: Skip[] } {
const { list, skips } = candidates(target, opts);
const busy: Record<Group, string | null> = {
tmp: list.some(c => c.group === 'tmp') ? busyFor('tmp', target) : null,
browse: busyFor('browse', target),
};
const actions: Action[] = [];
for (const c of list) {
const result = evaluate(c, dirOf(c, target), busy[c.group], opts, now);
if (result && 'bytes' in result) actions.push(result);
else if (result) skips.push(result);
}
return { actions, skips };
}
const changed = () => Object.assign(new Error('the file changed after it was checked; rerunning is safe'), { code: 'ECHANGED' });
const same = (st: fs.Stats, seen: Seen) => st.dev === seen.dev && st.ino === seen.ino && st.size === seen.size && st.mtimeMs === seen.mtimeMs;
function writeFully(fd: number, buf: Buffer, position: number): void {
for (let off = 0; off < buf.length;) {
const n = fs.writeSync(fd, buf, off, buf.length - off, position + off);
if (n <= 0) throw Object.assign(new Error('short write'), { code: 'EIO' });
off += n;
}
}
/** The parent folder is still the verified real folder, so a swapped link cannot redirect the change. */
function parentIntact(file: string, dir: string): boolean {
const parent = path.dirname(file);
try { return !fs.lstatSync(parent).isSymbolicLink() && fs.realpathSync(parent) === dir; } catch { return false; }
}
/** A cut or trim keeps the inode, so O_APPEND writers go on; refused unless the file is exactly what evaluate saw. */
function cutInPlace(file: string, keep: Buffer, seen: Seen): void {
const fd = fs.openSync(file, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0));
try {
const st = fs.fstatSync(fd);
if (st.nlink !== 1 || !same(st, seen)) throw changed();
writeFully(fd, keep, 0);
fs.ftruncateSync(fd, keep.length);
} finally { fs.closeSync(fd); }
}
/** Re-checks each planned action against its file and its group's liveness right before the change. */
export function applyClean(target: string, planned: Action[], opts: CleanOptions): { done: Done[]; skips: Skip[] } {
const done: Done[] = [];
const skips: Skip[] = [];
const asked: Partial<Record<Group, { at: number; busy: string | null }>> = {};
const busyNow = (group: Group): string | null => {
const last = asked[group];
if (last && Date.now() - last.at < LIVENESS_RECHECK_MS) return last.busy;
const busy = busyFor(group, target);
asked[group] = { at: Date.now(), busy };
return busy;
};
for (const p of planned) {
const fresh = evaluate({ file: p.file, group: p.group, kind: p.kind }, dirOf(p, target), busyNow(p.group), opts, Date.now());
if (!fresh) continue;
if (!('bytes' in fresh)) { skips.push(fresh); continue; }
try {
if (!parentIntact(fresh.file, dirOf(fresh, target))) throw changed();
if (fresh.kind === 'remove') {
if (!same(fs.lstatSync(fresh.file), fresh.seen)) throw changed();
fs.unlinkSync(fresh.file);
} else cutInPlace(fresh.file, fresh.keep!, fresh.seen);
done.push({ action: fresh });
} catch (e: any) {
done.push({ action: fresh, errno: e?.code ?? 'UNKNOWN' });
}
}
return { done, skips };
}
const VERB: Record<Kind, [string, string]> = { remove: ['would remove', 'removed'], cut: ['would cut', 'cut'], rotate: ['would trim', 'trimmed'] };
function report(io: Io, opts: CleanOptions, t: CleanTarget, rows: Done[], skips: Skip[]): number {
const rel = (file: string) => path.relative(t.target, file) || '.';
const failed = rows.filter(r => r.errno);
const ok = rows.filter(r => !r.errno);
const bytes = ok.reduce((n, r) => n + r.action.bytes, 0);
const exit = failed.length ? 1 : 0;
const partial = resultLine('gstack-clean', 'GSTACK_CLEAN_PARTIAL', `${failed.length} of ${rows.length} actions failed`);
if (opts.json) {
io.out(JSON.stringify({
schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run',
status: failed.length ? 'partial' : rows.length === 0 ? 'nothing_to_do' : opts.apply ? 'done' : 'planned',
target: t.target, chosen_by: t.chosenBy, audit_days: opts.auditDays,
actions: rows.map(({ action: a, errno }) => ({
path: a.file, rel: rel(a.file), group: a.group, action: a.kind, bytes: a.bytes, reason: a.reason,
status: errno ? 'failed' : opts.apply ? 'done' : 'planned', ...(errno ? { errno } : {}),
})),
skips: skips.map(s => ({ path: s.file, rel: rel(s.file), group: s.group, reason: s.reason })),
total: { actions: ok.length, bytes, failed: failed.length, skipped: skips.length },
error: failed.length ? { code: 'GSTACK_CLEAN_PARTIAL', message: partial } : null,
exit_code: exit,
}, null, 2));
if (failed.length) io.err(partial);
return exit;
}
io.out(`gstack-clean: target ${t.target} (${t.chosenBy})`);
for (const { action: a, errno } of rows) {
if (errno) { io.out(`FAILED ${a.file} ${errno}`); continue; }
const size = a.kind === 'remove' ? `${a.bytes} bytes` : `${opts.apply ? 'freed' : 'freeing'} ${a.bytes} bytes`;
io.out(`${VERB[a.kind][opts.apply ? 1 : 0].padEnd(12)} ${rel(a.file)} (${size}): ${a.reason}`);
}
for (const s of skips) io.out(`${'skip'.padEnd(12)} ${rel(s.file)}: ${s.reason}`);
const failNote = failed.length ? `; ${failed.length} failed` : '';
io.out(`total: ${ok.length} action${ok.length === 1 ? '' : 's'} ${opts.apply ? 'freed' : 'would free'} ${bytes} bytes${failNote}; ${skips.length} skipped`);
if (failed.length) io.err(partial);
else if (!opts.apply) io.out(rows.length ? `nothing changed; to do this: gstack-clean --apply${opts.auditDays === null ? '' : ` --audit-days ${opts.auditDays}`}` : 'nothing changed; nothing to clean');
return exit;
}
function run(argv: string[], env: NodeJS.ProcessEnv, cwd: string, io: Io): number {
let parsed: CleanOptions | 'help';
try { parsed = parseCleanArgs(argv); } catch (e) {
if (!(e instanceof UsageError)) throw e;
io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_USAGE', e.message));
io.err(USAGE);
return 2;
}
if (parsed === 'help') { io.out(HELP.trimEnd()); return 0; }
const opts = parsed;
let resolved: CleanTarget | { missing: string };
try { resolved = resolveCleanTarget(env, cwd); } catch (e) {
if (!(e instanceof Refused)) throw e;
const line = resultLine('gstack-clean', 'GSTACK_CLEAN_TARGET_REFUSED', e.message);
io.err(line);
if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', status: 'refused', error: { code: 'GSTACK_CLEAN_TARGET_REFUSED', message: line }, exit_code: 3 }, null, 2));
return 3;
}
if ('missing' in resolved) {
if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run', status: 'nothing_to_do', target: resolved.missing, chosen_by: 'the .gstack folder at the git toplevel (missing)', audit_days: opts.auditDays, actions: [], skips: [], total: { actions: 0, bytes: 0, failed: 0, skipped: 0 }, error: null, exit_code: 0 }, null, 2));
else io.out(`nothing to clean: ${resolved.missing} does not exist`);
return 0;
}
const plan = planClean(resolved.target, opts);
if (!opts.apply) return report(io, opts, resolved, plan.actions.map(action => ({ action })), plan.skips);
const applied = applyClean(resolved.target, plan.actions, opts);
return report(io, opts, resolved, applied.done, [...plan.skips, ...applied.skips]);
}
/** Synchronous, so a pipe gets every byte before the bin calls process.exit. */
/** A reader that went away (EPIPE) ends the output quietly; the exit code still reports what was done. */
function writeAll(fd: number, text: string): void {
const buf = Buffer.from(text, 'utf8');
for (let off = 0; off < buf.length;) {
try { off += fs.writeSync(fd, buf, off); } catch (e: any) {
if (e?.code === 'EPIPE') return;
if (e?.code !== 'EAGAIN') throw e;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
}
}
}
/** The bin's entry. Never throws: an unexpected error prints one GSTACK_CLEAN_INTERNAL line and exits 1. */
export function main(argv: string[], env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): number {
const io: Io = { out: line => writeAll(1, `${line}\n`), err: line => writeAll(2, `${line}\n`) };
try {
return run(argv, env, cwd, io);
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
try { io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_INTERNAL', `${err.name}: ${err.message}`.slice(0, 300))); } catch { /* stderr is gone too */ }
return 1;
}
}
+7 -1
View File
@@ -1,6 +1,7 @@
/**
* Result codes for the CEO-only driver run (`gstack-autoplan next --ceo-only`,
* lib/autoplan-ceo-only.ts). Kept beside lib/result-codes.ts, which spreads
* lib/autoplan-ceo-only.ts) and its shadow-mode reader (`gstack-autoplan shadow`,
* lib/autoplan-shadow-reader.ts). Kept beside lib/result-codes.ts, which spreads
* this table into RESULT_CODES; the same rules apply: one stable
* docs/troubleshooting.md anchor, a summary and a fix clause each. The run is
* documented in docs/unattended.md#ceo-only-runs.
@@ -23,4 +24,9 @@ export const AUTOPLAN_RESULT_CODES = {
summary: 'answer, recommend, revise and approvals import need a gate or an approvable run, and a CEO-only run has neither: it ends status=reviewed and is never approved',
fix: 'read <run dir>/ceo-review.md; to get a gate and an approval, start a full review with gstack-autoplan next --out <new dir> --plan <file> (no --ceo-only)',
},
SHADOW_BEFORE_ANSWER: {
anchor: 'shadow-before-answer',
summary: 'gstack-autoplan shadow skipped a gate revision whose plan approval is unanswered or answered c (interrogate): the export pairs predictions with answers, so it waits for the owner',
fix: 'answer the gate (gstack-autoplan answer --gate-rev <n> ...), then run gstack-autoplan shadow again; answered revisions of the same run still export',
},
} as const satisfies Record<string, ResultCode>;
+54
View File
@@ -0,0 +1,54 @@
/**
* Result codes for the browse daemon's tabs: tab recovery, per-session tabs
* (#1961) and phone emulation with touch input (#3058). Kept beside
* lib/result-codes.ts (which spreads this table into RESULT_CODES) so the
* owner module stays within the module-size ratchet; the same rules apply:
* one stable docs/troubleshooting.md anchor, a summary and a fix clause each.
*/
import type { ResultCode } from './result-codes';
export const BROWSE_RESULT_CODES = {
// Fix wave 2026-10-15, PR 1a (items 7 and 11).
BROWSE_TAB_UNRECOVERABLE: {
anchor: 'browse-tab-unrecoverable',
summary: 'every tab was closed outside browse and a new one could not be opened, because the browser or its context is gone',
fix: 'run `browse restart` (`$B restart` in a gstack skill); `browse status` and `browse stop` keep working',
},
// Per-session tabs (#1961).
BROWSE_SESSION_INVALID: {
anchor: 'browse-session-invalid',
summary: 'the session name is not 1-64 letters, digits, dots, colons, underscores or dashes',
fix: 'pick a name like agent-1 (BROWSE_SESSION=agent-1 or --session agent-1)',
},
BROWSE_SESSION_ROOT_ONLY: {
anchor: 'browse-session-root-only',
summary: 'only the local root token may name a session; paired and scoped agents get their own tab with newtab',
fix: 'drop the session field and run newtab, then pass its tabId on every command',
},
BROWSE_SESSION_TAB_RESET: {
anchor: 'browse-session-tab-reset',
summary: 'this session\'s tab was closed (by closetab, the window, or the idle sweep), so the page it was reading is gone',
fix: 'open the page again with `browse goto <url>` in the same session, then snapshot again before using @refs',
},
BROWSE_SESSION_LIMIT: {
anchor: 'browse-session-limit',
summary: 'every session slot is running a command, so no idle session could be closed to make room',
fix: 'retry when a command finishes, or raise BROWSE_MAX_SESSIONS (default 16)',
},
// Phone emulation and touch (#3058).
BROWSE_DEVICE_UNKNOWN: {
anchor: 'browse-device-unknown',
summary: 'no Playwright device has that name, so nothing on the tab changed',
fix: '$B device --list',
},
BROWSE_DEVICE_CDP_FAILED: {
anchor: 'browse-device-cdp-failed',
summary: 'Chromium refused a device emulation call on this tab',
fix: '$B device off',
},
BROWSE_TOUCH_NO_DEVICE: {
anchor: 'browse-touch-no-device',
summary: 'touch input needs a phone profile on this tab first',
fix: '$B device "iPhone 15"',
},
} as const satisfies Record<string, ResultCode>;
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff. Show more