diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci
index 97b5b6b55..aef646180 100644
--- a/.github/docker/Dockerfile.ci
+++ b/.github/docker/Dockerfile.ci
@@ -33,9 +33,11 @@ RUN printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\nAcquire::https:
# bash bins shell out to it (macOS ships python3; the base image doesn't).
# file: skill-validation's no-compiled-binaries-in-git check runs `file --mime-type`.
# poppler-utils: make-pdf's e2e gates hard-require pdftotext/pdffonts/pdfinfo in CI.
+# zsh: the sourced-helper guard sources every skill helper under bash and zsh
+# (macOS's default shell) and fails under CI=true when zsh is missing.
RUN for i in 1 2 3; do \
apt-get update && apt-get install -y --no-install-recommends \
- git curl unzip xz-utils ca-certificates jq bc gpg python3 python3-venv file poppler-utils gcc libc6-dev && break || \
+ git curl unzip xz-utils ca-certificates jq bc gpg python3 python3-venv file poppler-utils gcc libc6-dev zsh && break || \
(echo "apt retry $i/3 after failure"; sleep 10); \
done \
&& rm -rf /var/lib/apt/lists/*
diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml
index 33e04fae5..516978e13 100644
--- a/.github/workflows/free-tests.yml
+++ b/.github/workflows/free-tests.yml
@@ -102,7 +102,8 @@ jobs:
# $TMPDIR sits under /var/folders and /var is a symlink to /private/var, so
# 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.
+ # 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).
macos-named-regressions:
runs-on: macos-latest
timeout-minutes: 20
@@ -124,6 +125,13 @@ jobs:
git config --global user.email "free-tests-ci@gstack.test"
git config --global user.name "Free Tests CI"
git config --global init.defaultBranch main
+ - name: Codex probe selects a model when sourced from zsh (macOS default shell, no paid call)
+ working-directory: ${{ runner.temp }}
+ run: |
+ set -euo pipefail
+ out="$(zsh -c 'source "$GITHUB_WORKSPACE/bin/gstack-codex-probe" && _gstack_codex_select_model exec' 2>&1)"
+ printf '%s\n' "$out"
+ printf '%s\n' "$out" | grep -E '^CODEX_MODEL: [A-Za-z0-9._:/-]+ \(exec; source: '
- name: Run the named macOS regressions on the default temp root
run: |
set -euo pipefail
@@ -131,7 +139,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)
+ 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)
for file in "${files[@]}"; do test -f "$file"; done
bun test "${files[@]}"
diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml
index 1216f6624..de15bd85e 100644
--- a/.github/workflows/windows-setup-e2e.yml
+++ b/.github/workflows/windows-setup-e2e.yml
@@ -1,15 +1,19 @@
name: Windows Setup E2E
-# End-to-end fresh-install gate for Windows. Runs `./setup` on a clean
-# windows-latest checkout and asserts the build completes, binaries
-# resolve via find-browse, and the gstack-paths state root resolves
-# cleanly. Catches Bun shell-parser regressions in package.json's build
-# chain (#1538, #1537, #1530, #1457, #1561) before they reach users.
+# End-to-end fresh-install gate for Windows. Builds a clean windows-latest
+# checkout and asserts the build completes, binaries resolve via
+# find-browse, and the gstack-paths state root resolves cleanly. Catches Bun
+# shell-parser regressions in package.json's build chain (#1538, #1537,
+# #1530, #1457, #1561) before they reach users. Then it runs `./setup` into
+# an isolated HOME and drives the installed runtime root's browse from an
+# unrelated directory: a piped two-command `chain` flow must print the page
+# text (the runtime root has no node_modules, so this is the checkout-bundle
+# path from #3026, and the piped-stdin path from #3039).
#
# Separate from windows-free-tests.yml because that one runs a curated
# unit-test subset; this one exercises the install path itself.
#
-# Runner: GitHub-hosted free windows-latest. ~3-5 min total.
+# Runner: GitHub-hosted free windows-latest. ~6-10 min total.
on:
pull_request:
@@ -21,6 +25,9 @@ on:
- 'setup'
- 'browse/src/cli.ts'
- 'browse/src/find-browse.ts'
+ - 'browse/src/server.ts'
+ - 'browse/src/meta-commands.ts'
+ - 'browse/scripts/build-node-server.sh'
- 'bin/gstack-paths'
- '.github/workflows/windows-setup-e2e.yml'
workflow_dispatch:
@@ -38,7 +45,7 @@ permissions:
jobs:
windows-setup:
runs-on: windows-latest
- timeout-minutes: 10
+ timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -111,3 +118,54 @@ jobs:
echo "PLAN_ROOT=$PLAN_ROOT"
echo "TMP_ROOT=$TMP_ROOT"
shell: bash
+
+ - name: Install with ./setup into an isolated HOME
+ # A real install: the codex host's runtime root is a copy of the
+ # runtime files with no node_modules beside it. setup installs
+ # Playwright's Chromium (best-effort) for the journey below.
+ run: |
+ set -euo pipefail
+ E2E_HOME="$RUNNER_TEMP/gstack-e2e-home"
+ mkdir -p "$E2E_HOME"
+ HOME="$E2E_HOME" USERPROFILE="$(cygpath -w "$E2E_HOME")" ./setup --host codex
+ ROOT="$E2E_HOME/.codex/skills/gstack"
+ test -f "$ROOT/.source-path" || (echo "MISSING: $ROOT/.source-path" && exit 1)
+ test ! -e "$ROOT/node_modules" || (echo "UNEXPECTED: $ROOT/node_modules (the journey must exercise the copy without it)" && exit 1)
+ echo "runtime root: $ROOT -> source $(cat "$ROOT/.source-path")"
+ shell: bash
+
+ - name: Installed browse runs a piped chain flow from an unrelated directory
+ run: |
+ set -euo pipefail
+ E2E_HOME="$RUNNER_TEMP/gstack-e2e-home"
+ USERPROFILE="$(cygpath -w "$E2E_HOME")"
+ export HOME="$E2E_HOME" USERPROFILE
+ BROWSE="$E2E_HOME/.codex/skills/gstack/browse/dist/browse.exe"
+ [ -f "$BROWSE" ] || BROWSE="$E2E_HOME/.codex/skills/gstack/browse/dist/browse"
+ test -f "$BROWSE" || (echo "MISSING: installed browse" && exit 1)
+ bun -e 'Bun.serve({ hostname: "127.0.0.1", port: 47123, fetch: () => new Response("
gstack e2e
gstack-windows-chain-ok
", { headers: { "content-type": "text/html" } }) }); setInterval(() => {}, 1 << 30);' &
+ PAGE_PID=$!
+ trap 'kill "$PAGE_PID" 2>/dev/null || true; "$BROWSE" stop >/dev/null 2>&1 || true' EXIT
+ UNRELATED="$RUNNER_TEMP/unrelated-dir"
+ mkdir -p "$UNRELATED" && cd "$UNRELATED"
+ set +e
+ OUT=$(printf '%s\n' '[["goto","http://127.0.0.1:47123/"],["text"]]' | "$BROWSE" chain 2>&1)
+ CODE=$?
+ set -e
+ echo "$OUT"
+ if [ "$CODE" -ne 0 ]; then
+ echo "browse chain exited $CODE; server logs:"
+ find "$UNRELATED" "$E2E_HOME" -maxdepth 4 \( -name 'browse-daemon.log' -o -name 'browse-startup-error.log' \) -print -exec tail -n 60 {} \; 2>/dev/null || true
+ cat "$E2E_HOME/.codex/skills/gstack/.source-path" 2>/dev/null || true
+ exit 1
+ fi
+ echo "$OUT" | grep -q 'gstack-windows-chain-ok' || (echo "chain output lacks the page text: the flow did not run" && exit 1)
+ # Empty piped input is a usage error, never a silent exit 0.
+ set +e
+ EMPTY_OUT=$(printf '' | "$BROWSE" chain 2>&1)
+ EMPTY_CODE=$?
+ set -e
+ echo "$EMPTY_OUT"
+ test "$EMPTY_CODE" -eq 1 || (echo "empty chain exited $EMPTY_CODE, expected 1" && exit 1)
+ echo "$EMPTY_OUT" | grep -q 'no flow to run' || (echo "empty chain lacks the usage error" && exit 1)
+ shell: bash
diff --git a/.osv-scanner.toml b/.osv-scanner.toml
index f4afdad3a..76ab7c36e 100644
--- a/.osv-scanner.toml
+++ b/.osv-scanner.toml
@@ -54,3 +54,32 @@ id = "GHSA-p7fg-763f-g4gf"
# deliberate claude-agent-sdk bump. Tracking: #2754.
reason = "Fix requires breaking the deliberate eval-harness agent-sdk pin; MEDIUM severity accepted until the next harness bump."
ignoreUntil = 2026-11-30T00:00:00Z
+
+[[IgnoredVulns]]
+id = "GHSA-vfj7-8cjw-p6xm"
+# braces 3.0.3 in lib/diagram-render only: @excalidraw/excalidraw -> sass ->
+# chokidar -> braces. chokidar is sass's Node file watcher; the diagram-render
+# bundle is built for the browser and contains no chokidar/braces code
+# (test/osv-config-wiring.test.ts checks the built page for chokidar). No fixed
+# braces release exists. Re-evaluate on expiry or when excalidraw drops sass.
+reason = "Build-time only (excalidraw -> sass -> chokidar); not in the shipped browser bundle; no fixed release."
+ignoreUntil = 2026-11-30T00:00:00Z
+
+[[IgnoredVulns]]
+id = "GHSA-hp3w-g68c-fv3c"
+# sprintf-js 1.1.3 (DoS through an unbounded precision specifier in the FORMAT
+# string). Reachable only as roarr's logger dependency (global-agent -> roarr),
+# which formats its own constant messages; no caller passes user-controlled
+# format strings. No fixed release exists.
+reason = "No fixed release; only roarr's constant-format logging reaches it."
+ignoreUntil = 2026-11-30T00:00:00Z
+
+[[IgnoredVulns]]
+id = "GHSA-238p-pmpm-9mq7"
+# katex 0.16.47 in lib/diagram-render (mermaid's math renderer). The advisory
+# needs prototype pollution to already exist in the page; the render page is
+# offline, loads only gstack's own bundle and the user's diagram source. The
+# only fix is 0.18.x, outside mermaid 11.16's ^0.16 range. Low severity (2.1).
+# Re-evaluate when mermaid accepts katex 0.18.
+reason = "Requires pre-existing prototype pollution in an offline single-bundle page; fix is outside mermaid's katex range; low severity."
+ignoreUntil = 2026-11-30T00:00:00Z
diff --git a/BROWSER.md b/BROWSER.md
index bb511bb14..63d278a9a 100644
--- a/BROWSER.md
+++ b/BROWSER.md
@@ -1606,7 +1606,7 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a.
| `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 |
| `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 | Set to `off` to skip the macOS headless GPU-taming flag set (applied by default on Darwin to stop runaway GPU-process spin) |
+| `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 |
| `GSTACK_SECURITY_OFF` | unset | Emergency kill switch — disable ML classifier |
| `GSTACK_STEALTH` | unset | Set to `extended` (also accepts `1`/`true`) to layer six aggressive patches (WebGL spoof, faked plugins, mediaDevices) on top of Layer C. Actively lies; can break sites. |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dacc75ddb..3225ee7f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,41 @@
# Changelog
+## [1.91.32.0] - 2026-10-06
+
+**Codex second opinions work on macOS again, and text from a PR or reviewer can no longer run as a shell command.**
+**Every failure in this wave names its real cause and the fix.**
+
+Since v1.91.16.0, every Codex outside pass sourced from zsh (the macOS default, including Claude Code's Bash tool) reported `CODEX_MODEL: invalid`, so /ship, /review, /codex and the plan reviews ran without their outside voice. Greptile replies, the pre-existing-failure issue template and /spec's issue title put reviewer, diff and error text inside double quotes, where bash runs backticks. This release fixes both, and turns several "inconclusive" or "retry" messages into the actual cause. Contributor PRs were used as diagnosis and rewritten; each contributor is credited on the commit.
+
+### The numbers that matter
+
+| Check | v1.91.31.0 | v1.91.32.0 |
+|---|---|---|
+| `zsh -c 'source bin/gstack-codex-probe && _gstack_codex_select_model exec'` | `Module not found "/../scripts/..."`, `CODEX_MODEL: invalid` | `CODEX_MODEL: (exec; source: ...)` |
+| Greptile reply that quotes a commit SHA in backticks | posts "Fixed in ." and runs the SHA as a command | posts the text byte for byte |
+| Codex usage-limit error at preflight | `MODEL_PROBE_INCONCLUSIVE ... proceeding`, re-probed in every skill | `CODEX_MODE: quota_exhausted` with Codex's reset line, cached 15 min |
+| /autoplan in a 52 MiB session journal | `code identity`, "Retry this phase-entry tool" (forever) | `code too_large` with the size and a recovery that keeps the work |
+| Weekly OSV scan | 10 findings (3 High) | no unignored findings |
+
+### What changes for you
+
+- **Codex works from zsh.** Sourced helpers (`gstack-codex-probe`, `gstack-egress-lib.sh`) locate themselves under bash and zsh; another shell, or a helper that cannot find itself, prints `CODEX_MODE: helper_unavailable` with a fix link instead of pretending you are logged out. Check it: `zsh -c 'source ~/.claude/skills/gstack/bin/gstack-codex-probe && _gstack_codex_select_model exec'` prints `CODEX_MODEL: (...)`.
+- **Codex limits are named.** A usage limit (`insufficient_quota`, "You've hit your usage limit") is `CODEX_MODE: quota_exhausted`: Codex's own line with the reset time, no more Codex calls that run, and `GSTACK_CODEX_PROBE_RETRY=1` to re-probe early. A plain rate limit (HTTP 429) is `unverified (rate_limited)` and the review still runs.
+- **Free text travels as a file.** Reply bodies, issue titles and bodies, Codex prompts, design briefs and PR-body drafts are written by the agent to a private file under the project's `.gstack/tmp/` (excluded from git) and sent with `--body-file`/`-F body=@`. If the file was never written, nothing is sent and the skill prints the exact command to send it by hand. A lint fails on any free-text placeholder in a generated command.
+- **/autoplan in long sessions.** Over the 32 MiB journal limit, the guard says so, with the size, and gives the recovery: `/context-save`, a new session, `/context-restore`, then `/autoplan`.
+- **A red paid eval gets measured, not rerun blindly.** /ship runs the case alone (rule: 10 trials, needs 9), fixes the cause, re-measures, and runs the full gate once. It asks before spending more than $2 a trial and stops at $25 per case or 3 repair rounds (`ship_measure_*` config keys). Diagnostic trials never change a recorded verdict.
+- **Setup and auto-update refuse a hook that does not parse.** Setup registers the hooks that parse and names the broken one with its file and line; team-mode auto-update holds an incoming release whose hook would not parse, so your current hooks keep running.
+- **Router wording.** The root router routes only to skills in your session's skill list (skills you turned off in Claude Code are not routed to) and says when to answer directly; "When in doubt, invoke the skill" is gone. To update a CLAUDE.md that gstack wrote for you: `sed -i.bak "s/When in doubt, invoke the skill\./Route only to skills in the session's available-skills list; answer directly for quick questions or small scoped edits./" CLAUDE.md`.
+- **Spawned sessions never auto-grant consent.** Granting a consent or publishing data off the machine counts as irreversible in every spawned-session directive.
+- **Smaller fixes.** /autoplan names a shell-built snapshot init as the cause of its denial; Windows `browse chain` reads piped input (empty input is a usage error); Windows runtime roots run the checkout's browse server bundle (with a version-skew guard); redaction treats TypeScript types and JSX expressions as code only in TS/JSX files; /ship skips Greptile triage when there is no PR yet, saying why; /sync-gbrain no longer offers a call-graph build for a source with no code pages; Copilot on Windows runs bash blocks in Git for Windows Bash; /cso on Windows finds a trusted `docker.exe` and says native Windows Docker is not supported yet (static assessment only); a Java repo is no longer asked for `requirements.txt`; `GSTACK_DISABLE_GPU=off` enables WebGL; the config-mismatch refusal says another session started the daemon.
+
+### For contributors
+
+- The Codex watchdog (stock macOS, no `timeout`) freezes the command before reaping its children, so output can no longer leak after the deadline.
+- Tests that call a real `codex` run it with a private HOME and CODEX_HOME; the CSO missing-scanner test points Docker at a dead socket.
+- `scripts/ship-measure.ts` owns diagnostic measurement; see `docs/TESTING_INTERNALS.md#ship-measure`.
+- Not verified on Windows hardware: the Windows items are exercised by the Windows CI lanes and the new isolated-install journey in `windows-setup-e2e.yml`.
+
## [1.91.31.0] - 2026-10-06
**Rails runtime qualification no longer fails at random when exporting the prepared app.**
diff --git a/CLAUDE.md b/CLAUDE.md
index 1ec9d299c..f8a1ec725 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -529,7 +529,7 @@ Or copy the binaries directly:
## Skill routing
-When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
+When the user's request matches an available skill, invoke it via the Skill tool. Route only to skills in the session's available-skills list; answer directly for quick questions or small scoped edits.
Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
diff --git a/SKILL.md b/SKILL.md
index 635cf5ef9..36313bbaf 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -227,11 +227,13 @@ Generic “second opinion”, “outside review”, or “cross-model review”
- User asks to tune question sensitivity, "stop asking me that" → invoke `/plan-tune`
- User asks for code quality dashboard, "health check" → invoke `/health`
-**When in doubt, invoke the skill.** A false positive (invoking a skill that wasn't
-needed) is cheaper than a false negative (answering ad-hoc when a structured workflow
-exists). The skill provides multi-step workflows, checklists, and quality gates that
-always produce better results than an ad-hoc answer. If no skill matches, answer
-directly as usual.
+**Route only to skills in your available-skills list.** A rule above whose skill is
+not listed there is turned off on this install; skip it, never try to invoke it.
+
+**When to invoke, and when not to.** Invoke when the request matches a skill's purpose:
+the skill's workflow, checklists and gates beat an ad-hoc answer. Answer directly when
+no skill matches, for a quick factual question or a small edit the user scoped
+themselves, and when the user asks for a direct answer instead of a workflow.
If the user opts out of suggestions, run `gstack-config set proactive false`.
If they opt back in, run `gstack-config set proactive true`.
diff --git a/SKILL.md.tmpl b/SKILL.md.tmpl
index e0c33a2da..4e6012ecf 100644
--- a/SKILL.md.tmpl
+++ b/SKILL.md.tmpl
@@ -98,11 +98,13 @@ quality gates that produce better results than answering inline.
- User asks to tune question sensitivity, "stop asking me that" → invoke `/plan-tune`
- User asks for code quality dashboard, "health check" → invoke `/health`
-**When in doubt, invoke the skill.** A false positive (invoking a skill that wasn't
-needed) is cheaper than a false negative (answering ad-hoc when a structured workflow
-exists). The skill provides multi-step workflows, checklists, and quality gates that
-always produce better results than an ad-hoc answer. If no skill matches, answer
-directly as usual.
+**Route only to skills in your available-skills list.** A rule above whose skill is
+not listed there is turned off on this install; skip it, never try to invoke it.
+
+**When to invoke, and when not to.** Invoke when the request matches a skill's purpose:
+the skill's workflow, checklists and gates beat an ad-hoc answer. Answer directly when
+no skill matches, for a quick factual question or a small edit the user scoped
+themselves, and when the user asks for a direct answer instead of a workflow.
If the user opts out of suggestions, run `gstack-config set proactive false`.
If they opt back in, run `gstack-config set proactive true`.
diff --git a/TODOS.md b/TODOS.md
index ca00db47d..a4b4b809d 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -2,6 +2,45 @@
## NEXT PRIORITY
+### P1–P3: Oct 6 fix-wave follow-ups (filed 2026-10-06)
+
+Deferred from the approved plan (docs/designs/FIX_WAVE_2026_10_06.md), each with its reason.
+
+- **Bounded or incremental owned-journal read (P1, next wave)** — /autoplan's
+ guard reads the whole parent journal and refuses at 32 MiB (`too_large`,
+ #3050); long sessions reach 50-70 MiB. Read incrementally while keeping every
+ ownership and ancestry check. Acceptance: /autoplan completes in a session
+ whose journal is over 100 MiB, with phase-entry latency and peak memory
+ measured on macOS and Linux. **Effort:** L. **Priority:** P1.
+- **Greptile in parallel during /ship (#3020, full version)** — push early so
+ Greptile reviews while /ship runs its other passes, then merge its findings
+ into the review. This wave only skips triage when no PR exists.
+ **Effort:** M. **Priority:** P2.
+- **Executed-subcommand Codex probe** — turn `bin/gstack-codex-probe` into an
+ executed command (`gstack-codex-probe select-model exec` printing its status
+ lines) so skills stop depending on the calling shell. The zsh fix (#3024)
+ keeps sourcing because skills call its functions across a block.
+ **Effort:** M. **Priority:** P2.
+- **Argument-array posting helper** — one gstack helper that posts PR and issue
+ text by passing arguments as an array, never through a shell string (the
+ stronger form of the free-text file rule). **Effort:** M. **Priority:** P2.
+- **Free text the lint cannot see yet** — question tuning's inline
+ `printf '%s' "" | … --summary-stdin`, /ship's `NEW_TITLE`
+ restore and Step 18's `""` prose, and the heredoc template in
+ `docs/gbrain-write-surfaces.md` should use the agent-written file too.
+ **Effort:** S. **Priority:** P2.
+- **/plan-tune free-text tune events** — preference writes from skills no
+ longer pass the user's own words (`free_text`), because they travelled
+ inside a single-quoted JSON argument. Pass them through the agent-written
+ file. **Effort:** S. **Priority:** P2.
+- **Native Windows Docker transport for /cso (#3028)** — `lib/cso/docker.ts`
+ accepts only `unix:///` endpoints, so /cso on Windows reports "static
+ assessment only" even with a trusted `docker.exe`. Add named-pipe transport
+ with the same trust checks. **Effort:** L. **Priority:** P2.
+- **Readiness command** — a `gstack doctor` (or a Codex row in
+ `./setup --status`) that shows CODEX_MODE, the self-locate result and the
+ hook check without starting a skill. **Effort:** S. **Priority:** P3.
+
### P2/P3: reliability follow-ups deferrals (filed 2026-10-04, v1.91.30.0)
Deferred from the approved reliability follow-ups plan (docs/designs/RELIABILITY_FOLLOWUPS_2026_10.md), each with its reason.
@@ -25,12 +64,14 @@ Deferred from the approved reliability follow-ups plan (docs/designs/RELIABILITY
Missing-step coverage now lives in `test/workflow-required-steps.test.ts`.
Proposal: anchor completeness/actionability 4 and 5 to named deliverables
and inputs, as a pre-registered EVAL_POLICY change. **Effort:** M. **Priority:** P2.
-- **auto-decide-preserved: the CEO handoff line must reach the chat verbatim**
- — the native detector now credits the helper's AUTO_DECIDE line only when a
- chat message begins with it (Claude Code collapses the Bash result). One
- diagnostic run on the pre-verbatim wording paraphrased it; one on the
- verbatim wording passed. Watch the next censuses; a red here is a template
- miss, not a detector false red. **Effort:** S. **Priority:** P2.
+- **auto-decide-preserved: the CEO handoff line no longer depends on the model**
+ — periodic run 37272185151 went red because the model led its chat with a
+ paraphrase; a reminder beside the line measured 1/10. The Oct 6 wave shows
+ the line through /plan-ceo-review's PostToolUse hook (system message), which
+ measured 10/10 with the line visible in every trial while no trial's chat led
+ with it. The native detector still credits only a chat that begins with the
+ line; if the screen path ever stops carrying the hook message, make the
+ detector read the hook's record instead. **Effort:** S. **Priority:** P3.
- **Named red: ship-docsync-late-result on PR run 37237194905** — the model
wrote its report to a mistyped shard root (`…-3GanyY` for `…-3GanyU`) at
call 20, disclosed it, then wrote the real report at the right path. The
diff --git a/VERSION b/VERSION
index 898f53d0e..0086fa199 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.91.31.0
+1.91.32.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index ce3081603..69815cc31 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.91.31.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.91.32.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md
index 4bba21fe2..00ea5148a 100644
--- a/autoplan/SKILL.md
+++ b/autoplan/SKILL.md
@@ -302,7 +302,7 @@ Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes). Substitute `SESSION_ID` with the value the preamble's skill-start output echoed — shell variables do not survive between Bash calls:
```bash
-~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"autoplan","question_id":"","question_summary":"","category":"","door_type":"","options_count":N,"user_choice":"","recommended":"","session_id":"SESSION_ID"}' 2>/dev/null || true
+~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"autoplan","question_id":"","question_summary":"","category":"","door_type":"","options_count":N,"user_choice":"","recommended":"","session_id":"SESSION_ID"}' 2>/dev/null || true
```
For two-way questions, offer: "Tune this question? Reply `tune: never-ask`, `tune: always-ask`, or free-form."
@@ -311,7 +311,7 @@ User-origin gate (profile-poisoning defense): write tune events ONLY when `tune:
Write (only after confirmation for free-form):
```bash
-~/.claude/skills/gstack/bin/gstack-question-preference --write '{"question_id":"","preference":"","source":"inline-user","free_text":""}'
+~/.claude/skills/gstack/bin/gstack-question-preference --write '{"question_id":"","preference":"","source":"inline-user"}'
```
Exit code 2 = rejected as not user-originated; do not retry. On success: "Set `` → ``. Active immediately."
@@ -685,6 +685,8 @@ Before scope/review:
```bash
bun "" init "" "" ""
```
+Run init as its own Bash call with the literal absolute paths: no variables,
+substitutions, chaining, pipes or redirects, which the guard cannot bind.
Use returned paths/`scope`; never hand-wrap. init backs up SOURCE_PLAN exactly,
then initializes ACTIVE_PLAN atomically without losing requirements.
Reviewers get only `## Implementation plan`; analysis stays in `## Review record`,
@@ -755,13 +757,16 @@ Review skills will load at each phase entry. Starting full review pipeline with
# Codex preflight: one block (functions sourced here don't persist to later blocks).
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
_CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || echo enabled)
-source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
+_gstack_helper_error=""
+. ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || _gstack_helper_error="${_gstack_helper_error:-gstack: cannot load gstack-codex-probe; re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location}"
if [ "$_CODEX_CFG" = "disabled" ]; then
_CODEX_MODE="disabled"
elif { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; then
_CODEX_MODE="under_codex"
elif ! command -v codex >/dev/null 2>&1; then
_CODEX_MODE="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true
+elif [ -n "$_gstack_helper_error" ]; then
+ _CODEX_MODE="helper_unavailable"; echo "$_gstack_helper_error"
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
_CODEX_MODE="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
else
@@ -773,10 +778,14 @@ else
_CODEX_MODE="sandbox_unavailable"
elif [ "$_CODEX_MP" -eq 2 ]; then
_CODEX_MODE="broken_install"
+ elif [ "$_CODEX_MP" -eq 4 ]; then
+ _CODEX_MODE="quota_exhausted"
elif [ "$_CODEX_MP" -ne 0 ]; then
_CODEX_MODE="model_unusable"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = inconclusive ]; then
_CODEX_MODE="unverified"
+ elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = rate_limited ]; then
+ _CODEX_MODE="unverified (rate_limited)"
else
_CODEX_MODE="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -786,13 +795,15 @@ echo "CODEX_MODE: $_CODEX_MODE"
Branch on the echoed `CODEX_MODE`:
- **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip the Codex passes only; the Claude adversarial subagent below STILL runs (it is free and fast). Print: "Codex passes skipped (codex_reviews disabled) — running Claude adversarial only."
+- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`under_codex`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`broken_install`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: `npm install -g @openai/codex`." Relay the probe's HINT lines. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=` or config.toml `model`); never substitute a model. Keep the required Claude adversarial pass; do not dispatch a duplicate. The ~10s round trip is cached for 1h.
+- **`quota_exhausted`** — Codex usage limit: relay the probe's lines verbatim (reset time, retry); no more Codex calls this run. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`sandbox_unavailable`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. Keep the required Claude adversarial pass; do not dispatch a duplicate.
-- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out; say so, and let the pass's own verdict decide.
+- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out or, with `(rate_limited)`, hit a 429; say so, and let the pass's own verdict decide.
Disabled/unavailable retains applicable native passes. Recheck each outside dispatch.
Record provider and completed/unavailable/disabled/skipped per phase; CEO covers
diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl
index d3e9015c0..287a64b6a 100644
--- a/autoplan/SKILL.md.tmpl
+++ b/autoplan/SKILL.md.tmpl
@@ -215,6 +215,8 @@ Before scope/review:
```bash
bun "" init "" "" ""
```
+Run init as its own Bash call with the literal absolute paths: no variables,
+substitutions, chaining, pipes or redirects, which the guard cannot bind.
Use returned paths/`scope`; never hand-wrap. init backs up SOURCE_PLAN exactly,
then initializes ACTIVE_PLAN atomically without losing requirements.
Reviewers get only `## Implementation plan`; analysis stays in `## Review record`,
diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts
index c8b3dbd80..25f141bf6 100644
--- a/autoplan/bin/phase-publication-hook.ts
+++ b/autoplan/bin/phase-publication-hook.ts
@@ -9,7 +9,7 @@ import { isDeepStrictEqual } from 'node:util';
import { extractImplementationPlan, checkPhaseImplementation, acceptedBlocks } from '../../bin/gstack-autoplan-snapshot';
import { autoplanPhaseCompletions } from '../../lib/autoplan-phase-publication';
import { readOwnedClaudePublicTranscript, nativePathSpelling, ownedNativePath, sameNativePath,
- type ClaudeParentPublicEvent, type OwnedTranscriptReason } from '../../lib/claude-public-transcript';
+ type ClaudeParentPublicEvent, type OwnedTranscriptReason, transcriptReadLimit } from '../../lib/claude-public-transcript';
import { resolveStateRoot } from '../../lib/state-root';
const PHASES = ['ceo', 'design', 'dx', 'eng', 'tasks'] as const;
@@ -224,13 +224,17 @@ function initArguments(command: unknown, root: string): string[] | undefined {
return args.slice(1);
}
+/** Diagnostic only, never binds: a Bash call that ran snapshot init in a shape the guard cannot bind (#3045). */
+const UNBINDABLE_INIT = /(?:gstack-autoplan-snapshot|SNAPSHOT_TOOL|\$\{?[A-Z_]+\}?"?)\S*\s+init\s/;
+
function invocation(events: Event[], root: string): Invocation {
let bound: Invocation | undefined;
let chosen: Record | undefined;
+ let unbindable = false;
for (const use of events) {
if (use.kind !== 'use' || use.name !== 'Bash') continue;
const args = initArguments(use.input?.command, root);
- if (!args) continue;
+ if (!args) { unbindable ||= typeof use.input?.command === 'string' && UNBINDABLE_INIT.test(use.input.command) && /\.md\b/.test(use.input.command); continue; }
const results = events.filter(x => x.kind === 'result' && x.toolUseId === use.toolUseId && x.order > use.order);
if (results.length !== 1) fail('Autoplan initialization acknowledgment is unavailable or ambiguous.');
const text = textResult(results[0]!);
@@ -244,6 +248,10 @@ function invocation(events: Event[], root: string): Invocation {
bound = { activePlan: result.activePlan, restorePath: result.restorePath,
originalSha256: result.originalSha256, start: results[0]!.order };
}
+ if ((!chosen || !bound) && unbindable)
+ fail('Autoplan invocation evidence is unavailable: snapshot init ran through a shell variable, substitution, chaining, ' +
+ 'a pipe or a redirect, which this guard cannot bind. Re-run it as one Bash call with the literal absolute paths: ' +
+ '`bun "" init "" "" ""` (it answers reused:true), then retry this tool.');
if (!chosen || !bound) fail('Autoplan invocation evidence is unavailable. Complete the existing snapshot init step before phase entry.');
const restore = read(bound.restorePath, true), active = read(bound.activePlan);
const reference = JSON.stringify(bound.restorePath).replace(/--/g, '\\u002d\\u002d');
@@ -528,6 +536,16 @@ const HARD_CAUSE: Partial> = {
agent: "The session journal's conversation ancestry passes through a subagent record.",
cycle: "The session journal's parent links form a cycle.",
};
+/** #3050: a journal only grows, so the size limit is a hard denial with the real size and a recovery that keeps the work. */
+function journalTooLarge(journal: string, read: OwnedRead): never {
+ const mib = (bytes: number) => { const m = bytes / (1024 * 1024); return `${m >= 10 ? Math.round(m) : Number(m.toPrecision(2))} MiB`; };
+ let size = 'over the limit';
+ try { size = mib(fs.statSync(journal).size); } catch { /* size stays generic */ }
+ fail(`This session's journal is ${size}, over the ${mib(transcriptReadLimit())} limit /autoplan can verify. Run /context-save, ` +
+ 'start a new session (resume and compact keep writing this journal), run /context-restore, then /autoplan ; ' +
+ 'or run /plan-ceo-review, /plan-devex-review and /plan-eng-review individually. ' +
+ `(code too_large, Claude Code ${read.diagnostic?.claudeVersion ?? 'version unknown'}). Troubleshooting: ${GUIDE}#journal-too-large`);
+}
const guidance = (code: string, read?: OwnedRead) =>
`(code ${code}, Claude Code ${read?.diagnostic?.claudeVersion ?? 'version unknown'}). Troubleshooting: ${GUIDE}`;
@@ -579,8 +597,10 @@ export async function runPublicationHook(value: unknown, root: string): Promise<
if (input.tool_name === 'Read' && evaluatePublication(input, root, read.events, true).allow)
return {};
}
- const code = read.transcript.reason, cause = code && HARD_CAUSE[code];
- if (cause) fail(`Publication guard cannot verify this session: ${cause} Fallback: run /plan-ceo-review, then ` +
+ const code = read.transcript.reason;
+ if (code === 'too_large') journalTooLarge(journal, read);
+ const cause = code && HARD_CAUSE[code];
+ if (code && cause) fail(`Publication guard cannot verify this session: ${cause} Fallback: run /plan-ceo-review, then ` +
`/plan-devex-review, then /plan-eng-review by hand, or start a new session. ${guidance(code, read)}`);
// Unflushed records, a missing current tool_use, malformed or changing
// bytes and identity failures stay deny-and-retry.
diff --git a/benchmark-models/SKILL.md b/benchmark-models/SKILL.md
index 7208de9fe..e9e6ad378 100644
--- a/benchmark-models/SKILL.md
+++ b/benchmark-models/SKILL.md
@@ -187,7 +187,16 @@ Use AskUserQuestion with the preamble format:
If A: list top-level gstack skills that have SKILL.md files (from `find . -maxdepth 2 -name SKILL.md -not -path './.*'`), ask the user to pick one via a second AskUserQuestion. Use the picked SKILL.md path as the prompt file.
-If B: ask the user for the inline prompt. Use it verbatim via `--prompt ""`.
+If B: ask the user for the inline prompt. It never goes into a shell command: create a prompt file and write the prompt into it verbatim, then use the printed path as the prompt file.
+
+```bash
+_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
+mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
+_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
+PROMPT_FILE=$(mktemp "${_GT:?}/benchmark-prompt.XXXXXX") || { echo "Not sent: mktemp failed in $_GT." >&2; exit 1; }; echo "PROMPT_FILE: $PROMPT_FILE (name: ${PROMPT_FILE##*/})"
+```
+
+Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
If C: ask for the path. Verify it exists. Use as positional argument.
@@ -235,10 +244,12 @@ If judge is NOT available, skip this question and omit the `--judge` flag.
Construct the command from Step 1, 2, 3 decisions:
```bash
-"$BIN" --models [--judge] --output table
+PROMPT_PATH=""
+[ -s "$PROMPT_PATH" ] || { echo "ERROR: $PROMPT_PATH is missing or empty; the benchmark needs a prompt file." >&2; exit 1; }
+"$BIN" --models [--judge] --output table -- "$PROMPT_PATH"
```
-Where `` is either `--prompt ""` (Step 1B), a file path (Step 1A or 1C), and `` is the comma-separated list from Step 2.
+`` is the prompt file from Step 1 (the SKILL.md path, the printed prompt file, or the user's path) and `` is the comma-separated list from Step 2. Use a path only if it has no `'`, `"`, backtick, `$` or `\`; otherwise copy the file into a new Step 1B prompt file and use that.
Stream the output as it arrives. This is slow — each provider runs the prompt fully. Expect 30s-5min depending on prompt complexity and whether `--judge` is on.
diff --git a/benchmark-models/SKILL.md.tmpl b/benchmark-models/SKILL.md.tmpl
index 2d804237f..7360183c7 100644
--- a/benchmark-models/SKILL.md.tmpl
+++ b/benchmark-models/SKILL.md.tmpl
@@ -60,7 +60,9 @@ Use AskUserQuestion with the preamble format:
If A: list top-level gstack skills that have SKILL.md files (from `find . -maxdepth 2 -name SKILL.md -not -path './.*'`), ask the user to pick one via a second AskUserQuestion. Use the picked SKILL.md path as the prompt file.
-If B: ask the user for the inline prompt. Use it verbatim via `--prompt ""`.
+If B: ask the user for the inline prompt. It never goes into a shell command: create a prompt file and write the prompt into it verbatim, then use the printed path as the prompt file.
+
+{{FREE_TEXT_FILE:PROMPT_FILE=benchmark-prompt}}
If C: ask for the path. Verify it exists. Use as positional argument.
@@ -108,10 +110,12 @@ If judge is NOT available, skip this question and omit the `--judge` flag.
Construct the command from Step 1, 2, 3 decisions:
```bash
-"$BIN" --models [--judge] --output table
+PROMPT_PATH=""
+[ -s "$PROMPT_PATH" ] || { echo "ERROR: $PROMPT_PATH is missing or empty; the benchmark needs a prompt file." >&2; exit 1; }
+"$BIN" --models [--judge] --output table -- "$PROMPT_PATH"
```
-Where `` is either `--prompt ""` (Step 1B), a file path (Step 1A or 1C), and `` is the comma-separated list from Step 2.
+`` is the prompt file from Step 1 (the SKILL.md path, the printed prompt file, or the user's path) and `` is the comma-separated list from Step 2. Use a path only if it has no `'`, `"`, backtick, `$` or `\`; otherwise copy the file into a new Step 1B prompt file and use that.
Stream the output as it arrives. This is slow — each provider runs the prompt fully. Expect 30s-5min depending on prompt complexity and whether `--judge` is on.
diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe
index 6f2340596..d0b8a72e8 100755
--- a/bin/gstack-codex-probe
+++ b/bin/gstack-codex-probe
@@ -8,7 +8,8 @@
# _gstack_codex_sandbox_mode — read-only, or full access only for GSTACK_CODEX_NO_SANDBOX=1
# _gstack_codex_sandbox_preflight — free `codex sandbox` check before paid calls (Linux)
# _gstack_codex_first_use_notice — once per machine: provider, account, how to turn off
-# _gstack_codex_model_probe — round-trip probe of the selected model (#2477)
+# _gstack_codex_model_probe — round-trip probe of the selected model (#2477);
+# exit 4 = the account's Codex usage limit
# _gstack_codex_version_check — warn on known-bad Codex CLI versions
# _gstack_codex_timeout_wrapper — gtimeout -> timeout -> bash-native watchdog
# _gstack_codex_log_event — telemetry emission to ~/.gstack/analytics/
@@ -17,7 +18,53 @@
# - Never change -e / -u / traps / IFS / PATH in the caller shell.
# - All internal vars prefix with _GSTACK_CODEX_.
# - All functions prefix with _gstack_codex_.
-# - No command execution at source time (only function defs).
+# - No command execution at source time (only function defs and the shared
+# self-location block below, which is parameter expansion only; its
+# _gstack_helper* variables are the one exception to the prefix rule and
+# are unset once _GSTACK_CODEX_BIN is set).
+
+# --- Self-location (bash and zsh) -------------------------------------------
+# Under zsh BASH_SOURCE is empty, so every "$dir/../scripts/..." path became
+# "/..." and Codex model selection failed. The shared block below sets
+# _gstack_helper_dir; inside a function zsh's %x names the function, not the
+# file, so it is copied once here.
+_gstack_helper=gstack-codex-probe
+# === gstack self-locate (shared, byte-identical in every sourced helper) ===
+# Skill blocks source helpers into the host's shell: bash, or zsh on macOS
+# (Claude Code's Bash tool included). Resolved once at source time, using
+# parameter expansion only: BASH_SOURCE under bash, %x under zsh (inside eval
+# so bash never parses it), made absolute with $PWD; else $GSTACK_ROOT/bin
+# when it holds this helper. Any other case, and any other shell, fails loudly
+# instead of guessing; the message and the return come before any bash- or
+# zsh-only syntax, so dash and sh print it instead of a syntax error.
+_gstack_helper_dir=""
+_gstack_helper_shell=""
+if [ -n "${BASH_VERSION:-}" ]; then
+ _gstack_helper_shell=bash
+ _gstack_helper_dir="${BASH_SOURCE[0]}"
+elif [ -n "${ZSH_VERSION:-}" ]; then
+ _gstack_helper_shell=zsh
+ eval '_gstack_helper_dir="${(%):-%x}"'
+fi
+case "$_gstack_helper_dir" in
+ /*) _gstack_helper_dir="${_gstack_helper_dir%/*}" ;;
+ */*) _gstack_helper_dir="$PWD/${_gstack_helper_dir%/*}" ;;
+ ?*) _gstack_helper_dir="$PWD" ;;
+esac
+if [ -n "$_gstack_helper_shell" ] && [ ! -f "$_gstack_helper_dir/$_gstack_helper" ]; then
+ _gstack_helper_dir=""
+ if [ -n "${GSTACK_ROOT:-}" ] && [ -f "$GSTACK_ROOT/bin/$_gstack_helper" ]; then
+ _gstack_helper_dir="$GSTACK_ROOT/bin"
+ fi
+fi
+if [ -z "$_gstack_helper_shell" ] || [ -z "$_gstack_helper_dir" ]; then
+ _gstack_helper_error="gstack: cannot locate $_gstack_helper (shell: ${_gstack_helper_shell:-${0##*/}}). Source it from bash or zsh, or export GSTACK_ROOT=. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location"
+ printf '%s\n' "$_gstack_helper_error" >&2
+ return 1 2>/dev/null || exit 1
+fi
+# === end gstack self-locate ===
+_GSTACK_CODEX_BIN="$_gstack_helper_dir"
+unset _gstack_helper _gstack_helper_dir _gstack_helper_shell
# --- Auth probe -------------------------------------------------------------
@@ -43,7 +90,10 @@ _gstack_codex_auth_probe() {
if [ -f "$_codex_home/config.toml" ]; then
while IFS= read -r _env_key; do
case "$_env_key" in ''|[0-9]*|*[!A-Za-z0-9_]*) continue ;; esac
- _resolved=$(printf '%s' "${!_env_key:-}" | tr -d '[:space:]')
+ # Indirect read through eval, not ${!name}: zsh rejects bash's
+ # indirection, and _env_key is a validated identifier by this point.
+ eval "_resolved=\${$_env_key:-}"
+ _resolved=$(printf '%s' "$_resolved" | tr -d '[:space:]')
if [ -n "$_resolved" ]; then
echo "AUTH_OK"
return 0
@@ -72,9 +122,9 @@ _gstack_codex_select_model() {
return 1
fi
if [ -n "$_explicit" ]; then
- _line=$(bun run "${BASH_SOURCE[0]%/*}/../scripts/resolve-codex-generation-model.ts" --runtime "$_kind" --explicit "$_explicit")
+ _line=$(bun run "$_GSTACK_CODEX_BIN/../scripts/resolve-codex-generation-model.ts" --runtime "$_kind" --explicit "$_explicit")
else
- _line=$(bun run "${BASH_SOURCE[0]%/*}/../scripts/resolve-codex-generation-model.ts" --runtime "$_kind")
+ _line=$(bun run "$_GSTACK_CODEX_BIN/../scripts/resolve-codex-generation-model.ts" --runtime "$_kind")
fi
_rc=$?
_GSTACK_CODEX_SEL="${_line%%$'\t'*}"
@@ -135,7 +185,7 @@ _gstack_codex_first_use_notice() {
# and how to turn reviews off. Shown once per state root; never blocks and
# never prints a credential, only where it comes from.
local _root _marker _codex_home="${CODEX_HOME:-$HOME/.codex}" _account _provider
- _root="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || return 0
+ _root="$(. "$_GSTACK_CODEX_BIN/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || return 0
[ -n "$_root" ] || return 0
_marker="$_root/.codex-review-notice-shown"
[ -e "$_marker" ] && return 0
@@ -174,11 +224,24 @@ _gstack_codex_model_probe() {
# ENOENT, non-executable binary, missing vendor payload). Deterministic,
# so fail-open is wrong: retrying never helps. Never cached — a reinstall
# fixes it and must be picked up on the very next probe (#2742).
+ # MODEL_QUOTA_EXHAUSTED (exit 4) — Codex's usage-limit or insufficient_quota
+ # error on a failed, non-timeout call: the account, not the model. Codex's
+ # own line (with its reset time) is relayed verbatim; cached 15 min under
+ # the same signature as MODEL_UNUSABLE, so later fresh-shell blocks in the
+ # run make no Codex call. GSTACK_CODEX_PROBE_RETRY=1 skips both negative
+ # cache entries for that probe.
+ # MODEL_PROBE_RATE_LIMITED (exit 0) — a plain 429 / rate-limit error: often
+ # gone in seconds, so the review still runs, like the inconclusive case
+ # (_GSTACK_CODEX_PROBE_STATE=rate_limited, CODEX_MODE: unverified
+ # (rate_limited)). Codex's line is relayed verbatim; never cached.
# MODEL_PROBE_INCONCLUSIVE (exit 0) — timeout/transient; the review still
# runs so a slow network never wedges codex mode, but readiness is
# unverified (_GSTACK_CODEX_PROBE_STATE=inconclusive, CODEX_MODE:
# unverified) and the review's own classification decides. Never cached.
#
+ # Quota and rate-limit signatures read only Codex's own trailing error lines
+ # (ERROR: / stream error), never the whole output: it echoes the prompt.
+ #
# Only call this AFTER _gstack_codex_auth_probe passes — probing without
# auth just measures the auth failure again.
local _kind="${1:-exec}"
@@ -194,7 +257,7 @@ _gstack_codex_model_probe() {
# State root from the shared twin (sourced in a subshell, so the caller's
# shell is untouched). A missing twin only disables the cache.
local _gstack_home _cache=""
- _gstack_home="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || _gstack_home=""
+ _gstack_home="$(. "$_GSTACK_CODEX_BIN/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || _gstack_home=""
[ -n "$_gstack_home" ] && _cache="$_gstack_home/.codex-model-probe"
# Cache signature (#2787): everything that can change the round trip's
# outcome without the hour passing — the model, the Codex home and its
@@ -222,6 +285,7 @@ _gstack_codex_model_probe() {
_sig=$(printf '%s\n' "$_model" "$_codex_home" "$_cfg_m" "$_auth_mode" "$_auth_m" "$_bin" "$_bin_m" | cksum | tr -d ' ')
local _now
_now=$(date +%s 2>/dev/null || echo 0)
+ local _docs="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
if [ -n "$_cache" ] && [ -f "$_cache" ]; then
local _c_line _c_status _c_ts _c_sig
_c_line=$(head -1 "$_cache" 2>/dev/null)
@@ -233,6 +297,13 @@ _gstack_codex_model_probe() {
echo "MODEL_OK (cached)"
return 0
fi
+ [ "${GSTACK_CODEX_PROBE_RETRY:-}" = "1" ] && _c_sig="retry"
+ if [ "$_c_status" = "MODEL_QUOTA_EXHAUSTED" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 900 ]; then
+ echo "MODEL_QUOTA_EXHAUSTED (cached)"
+ sed -n '2,4p' "$_cache" 2>/dev/null
+ echo "HINT: Codex refused this account's calls for its usage limit (Codex's line above names the reset time); the model choice is fine. No Codex call was made: gstack skips Codex for $(( (_c_ts + 900 - _now + 59) / 60 )) more minute(s). Retry now: GSTACK_CODEX_PROBE_RETRY=1, or delete $_cache. $_docs#codex-quota-exhausted"
+ return 4
+ fi
if [ "$_c_status" = "MODEL_UNUSABLE" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 900 ]; then
echo "MODEL_UNUSABLE (cached)"
echo "HINT: gstack requested model '$_model' (source: $_GSTACK_CODEX_SEL_SRC)."
@@ -248,6 +319,38 @@ _gstack_codex_model_probe() {
echo "MODEL_OK"
return 0
fi
+ # A usage limit is the account, not the model or the network: Codex says so
+ # plainly ("You've hit your usage limit ... try again at