diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index 8b4d6850a..f168925ad 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -757,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 @@ -790,6 +793,7 @@ 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. diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index dcc9e3bc2..64a80c925 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -18,27 +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 and the one -# self-location assignment below, which is parameter expansion only). +# - 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) ------------------------------------------- -# Template blocks source this file into whatever shell the host runs; on macOS -# that is zsh (Claude Code's Bash tool included). BASH_SOURCE is bash-only, so -# under zsh every "${BASH_SOURCE[0]%/*}/..." path became "/..." and Codex -# model selection failed (#3024). zsh names the sourced file with %x; the eval -# keeps bash from parsing zsh-only syntax. Resolved once, at source time, -# because inside a function zsh's %x names the function, not the file. -_GSTACK_CODEX_BIN="" +# 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_CODEX_BIN="${BASH_SOURCE[0]}" + _gstack_helper_shell=bash + _gstack_helper_dir="${BASH_SOURCE[0]}" elif [ -n "${ZSH_VERSION:-}" ]; then - eval '_GSTACK_CODEX_BIN="${(%):-%x}"' + _gstack_helper_shell=zsh + eval '_gstack_helper_dir="${(%):-%x}"' fi -case "$_GSTACK_CODEX_BIN" in - */*) _GSTACK_CODEX_BIN="${_GSTACK_CODEX_BIN%/*}" ;; - ?*) _GSTACK_CODEX_BIN="$PWD" ;; - *) _GSTACK_CODEX_BIN="$HOME/.claude/skills/gstack/bin" ;; +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 +# <<< gstack self-locate <<< +_GSTACK_CODEX_BIN="$_gstack_helper_dir" +unset _gstack_helper _gstack_helper_dir _gstack_helper_shell # --- Auth probe ------------------------------------------------------------- diff --git a/bin/gstack-egress-lib.sh b/bin/gstack-egress-lib.sh index b8e97208a..ca8b754b0 100644 --- a/bin/gstack-egress-lib.sh +++ b/bin/gstack-egress-lib.sh @@ -36,22 +36,46 @@ # their own EXIT traps and a trap set by a sourced library would clobber # the caller's. All temp handling is immediate, per call. -# Self-locate without dirname (builtins only), so the lib works even under -# a stripped test PATH. BASH_SOURCE is bash-only; under zsh (the macOS -# default, sourced from skill blocks) %x names this file, read through eval -# so bash never parses it. Without it the lib resolved to the caller's cwd -# and looked for its siblings in the user's project (#3024). -_gstack_egress_self="" +# Self-locate with builtins only (no dirname), so the lib works under a +# stripped test PATH and under zsh, where it used to resolve to the caller's +# cwd and look for its siblings in the user's project. +_gstack_helper=gstack-egress-lib.sh +# >>> 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_egress_self="${BASH_SOURCE[0]}" + _gstack_helper_shell=bash + _gstack_helper_dir="${BASH_SOURCE[0]}" elif [ -n "${ZSH_VERSION:-}" ]; then - eval '_gstack_egress_self="${(%):-%x}"' + _gstack_helper_shell=zsh + eval '_gstack_helper_dir="${(%):-%x}"' fi -case "$_gstack_egress_self" in - */*) _gstack_egress_lib_dir="$(cd "${_gstack_egress_self%/*}" && pwd)" ;; - *) _gstack_egress_lib_dir="$(pwd)" ;; +case "$_gstack_helper_dir" in + /*) _gstack_helper_dir="${_gstack_helper_dir%/*}" ;; + */*) _gstack_helper_dir="$PWD/${_gstack_helper_dir%/*}" ;; + ?*) _gstack_helper_dir="$PWD" ;; esac -unset _gstack_egress_self +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 +# <<< gstack self-locate <<< +_gstack_egress_lib_dir="$_gstack_helper_dir" +unset _gstack_helper _gstack_helper_dir _gstack_helper_shell # State root from the shared twin (bin/gstack-state-root.sh, builtins only), # sourced lazily on first use. diff --git a/codex/SKILL.md b/codex/SKILL.md index ecdd4ba39..7a9e3c7bb 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -476,7 +476,7 @@ invalid or unavailable choice stops with a repair message, never the default. ```bash _TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off) -source ~/.claude/skills/gstack/bin/gstack-codex-probe +source ~/.claude/skills/gstack/bin/gstack-codex-probe || { echo "HELPER_UNAVAILABLE"; exit 1; } # GSTACK_ACTIVE_HOST names the harness, never the model. if { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; then @@ -499,6 +499,10 @@ _gstack_codex_version_check # warns if known-bad, non-blocking If the runtime guard reports a harness mismatch, stop. Outside coverage is unavailable. Repair with `./setup --host codex`; do not silently substitute another provider or force a same-harness invocation. +If the output contains `HELPER_UNAVAILABLE`, stop: the gstack helper could not load in +this shell. Relay its `gstack: cannot locate ...` line verbatim; it names the shell and +links the fix. + If the output contains `AUTH_FAILED`, stop and tell the user: "No Codex authentication found. Run `codex login` or set `$CODEX_API_KEY` / `$OPENAI_API_KEY`, then re-run this skill." diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index e25c1cbb2..a24362311 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -77,7 +77,7 @@ invalid or unavailable choice stops with a repair message, never the default. ```bash _TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off) -source ~/.claude/skills/gstack/bin/gstack-codex-probe +source ~/.claude/skills/gstack/bin/gstack-codex-probe || { echo "HELPER_UNAVAILABLE"; exit 1; } {{OUTSIDE_SELF_GUARD:codex}} if ! _gstack_codex_auth_probe >/dev/null; then @@ -91,6 +91,10 @@ _gstack_codex_version_check # warns if known-bad, non-blocking If the runtime guard reports a harness mismatch, stop. Outside coverage is unavailable. Repair with `./setup --host codex`; do not silently substitute another provider or force a same-harness invocation. +If the output contains `HELPER_UNAVAILABLE`, stop: the gstack helper could not load in +this shell. Relay its `gstack: cannot locate ...` line verbatim; it names the shell and +links the fix. + If the output contains `AUTH_FAILED`, stop and tell the user: "No Codex authentication found. Run `codex login` or set `$CODEX_API_KEY` / `$OPENAI_API_KEY`, then re-run this skill." diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8d83d14db..cd3c7f9a1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -31,6 +31,29 @@ P0 or P1 finding blocks exactly like a native P0/P1. `unverified` and `unavailable` are missing coverage: /ship and /review continue, show the gap in the readiness dashboard and the PR body, and never count it as a pass. + +### `gstack: cannot locate gstack-codex-probe (shell: ...)` / `CODEX_MODE: helper_unavailable` + +**Meaning.** Skill blocks load gstack's shell helpers (`gstack-codex-probe`, +`gstack-egress-lib.sh`) into the shell your agent runs. A helper finds its own +directory from bash (`BASH_SOURCE`) or zsh (`%x`). In any other shell (dash, +sh), or when the shell cannot say which file it is reading, the helper stops +instead of guessing a path. The message names the shell it saw. + +**Fix.** Run the skill from bash or zsh (the macOS and Linux defaults). If +the shell cannot be changed, tell the helper where gstack is installed: + +```bash +export GSTACK_ROOT=~/.claude/skills/gstack # your install dir; it holds bin/ +``` + +If the message says `cannot load ...`, the helper file is missing: re-run +`./setup` from your gstack checkout. + +**Expected result.** `zsh -c 'source ~/.claude/skills/gstack/bin/gstack-codex-probe && _gstack_codex_select_model exec'` +prints `CODEX_MODEL: (exec; source: ...)`, and preflights print a +`CODEX_MODE` other than `helper_unavailable`. + ### `Codex outside review unavailable: Codex's sandbox could not start here (...)` diff --git a/document-release/sections/release-body.md b/document-release/sections/release-body.md index ca96c7131..e0fe956fa 100644 --- a/document-release/sections/release-body.md +++ b/document-release/sections/release-body.md @@ -225,13 +225,16 @@ health summary and continue to Step 9. Ship-owned children already stopped at St # 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 @@ -258,6 +261,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip this section entirely; do NOT fall back to a Claude subagent — disabled means no extra review step. Print: "Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`." +- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Fall back to the Claude subagent path. - **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Fall back to the Claude subagent path. - **`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`." Fall back to the Claude subagent path. diff --git a/lib/gate-outcomes.ts b/lib/gate-outcomes.ts index 3c35ce400..32b254b32 100644 --- a/lib/gate-outcomes.ts +++ b/lib/gate-outcomes.ts @@ -68,6 +68,11 @@ export const GATE_OUTCOMES = { summary: "Codex refused the call for this account's usage or rate limit", fix: "wait for the reset time in Codex's message above, or add credits or a higher plan for that account; the model choice is fine", }, + helper_unavailable: { + state: 'unavailable', anchor: 'sourced-helper-location', + summary: 'the gstack Codex helper could not be loaded into this shell', + fix: 'run the skill from bash or zsh, or export GSTACK_ROOT=; if the helper file is missing, re-run ./setup (https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location)', + }, probe_inconclusive: { state: 'ran', anchor: 'codex-mode-unverified', summary: 'the Codex model check timed out or hit a network error, so readiness is unverified', diff --git a/plan-ceo-review/sections/review-sections.md b/plan-ceo-review/sections/review-sections.md index eff18a6d3..22e45fd76 100644 --- a/plan-ceo-review/sections/review-sections.md +++ b/plan-ceo-review/sections/review-sections.md @@ -360,13 +360,16 @@ review. The user turns this off only by asking explicitly # 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 @@ -393,6 +396,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip the reviewer invocation; record disabled coverage as directed below; do NOT fall back to a Claude subagent — disabled means no extra review step. Print: "Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`." +- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Fall back to the Claude subagent path. - **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Fall back to the Claude subagent path. - **`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 construct the prompt below, then follow **Native fallback**. 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`." Fall back to the Claude subagent path. diff --git a/plan-devex-review/sections/review-sections.md b/plan-devex-review/sections/review-sections.md index c6a016a5a..206f15f90 100644 --- a/plan-devex-review/sections/review-sections.md +++ b/plan-devex-review/sections/review-sections.md @@ -299,13 +299,16 @@ review. The user turns this off only by asking explicitly # 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 @@ -332,6 +335,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip this section entirely; do NOT fall back to a Claude subagent — disabled means no extra review step. Print: "Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`." +- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Fall back to the Claude subagent path. - **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Fall back to the Claude subagent path. - **`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`." Fall back to the Claude subagent path. diff --git a/plan-eng-review/sections/review-sections.md b/plan-eng-review/sections/review-sections.md index 79f4f37b3..8f2fc8595 100644 --- a/plan-eng-review/sections/review-sections.md +++ b/plan-eng-review/sections/review-sections.md @@ -805,13 +805,16 @@ review. The user turns this off only by asking explicitly # 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 @@ -838,6 +841,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip the reviewer invocation; record disabled coverage as directed below; do NOT fall back to a Claude subagent — disabled means no extra review step. Print: "Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`." +- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Fall back to the Claude subagent path. - **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Fall back to the Claude subagent path. - **`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 construct the prompt below, then follow **Native fallback**. 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`." Fall back to the Claude subagent path. diff --git a/review/sections/adversarial.md b/review/sections/adversarial.md index 862233786..c55a42b75 100644 --- a/review/sections/adversarial.md +++ b/review/sections/adversarial.md @@ -21,13 +21,16 @@ echo "DIFF_SIZE: $DIFF_TOTAL" # 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 @@ -55,6 +58,7 @@ 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. diff --git a/scripts/resolvers/constants.ts b/scripts/resolvers/constants.ts index c3e4c80b7..0baed1b28 100644 --- a/scripts/resolvers/constants.ts +++ b/scripts/resolvers/constants.ts @@ -103,15 +103,18 @@ On any error: continue — ${feature} is informational, not a gate.`; * fenced block — CLAUDE.md: each block is a fresh shell, so functions sourced * here do NOT persist to later blocks). It: * 1. reads the `codex_reviews` master switch, - * 2. sources `gstack-codex-probe`, + * 2. sources `gstack-codex-probe` with POSIX `.`, so even sh/dash reach the + * helper's own diagnostic (`CODEX_MODE: helper_unavailable`), * 3. runs `command -v codex` (literal — keeps the e2e substring assertion), * then `_gstack_codex_auth_probe`, then `_gstack_codex_version_check`, * 4. logs the relevant `_gstack_codex_log_event` for each non-ready outcome, * 5. sets ONE canonical mode var and echoes `CODEX_MODE: ` so the agent * gates later blocks on the echoed value. * - * Mode values: `disabled` (config off) | `not_installed` | `not_authed` | - * `broken_install` | `sandbox_unavailable` | `model_unusable` | `quota_exhausted` | `unverified` | `ready`. + * Mode values: `disabled` (config off) | `helper_unavailable` (the probe could + * not be sourced; its own diagnostic is echoed) | `not_installed` | `not_authed` | + * `broken_install` | `sandbox_unavailable` | `model_unusable` | `quota_exhausted` | + * `unverified` | `ready`. * The path is host-rewritten at gen-skill-docs time (pathRewrites), so the * literal `~/.claude/skills/gstack` is correct here and becomes `$GSTACK_ROOT` * etc. for non-Claude hosts. @@ -135,13 +138,16 @@ export function codexPreflight(opts: { modeVar?: string; disabledBehavior: 'skip # 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 ${m}="disabled" elif { [ -n "\${CODEX_THREAD_ID:-}" ] || [ -n "\${CODEX_SANDBOX:-}" ] || [ "\${GSTACK_ACTIVE_HOST:-}" = codex ]; }; then ${m}="under_codex" elif ! command -v codex >/dev/null 2>&1; then ${m}="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true +elif [ -n "$_gstack_helper_error" ]; then + ${m}="helper_unavailable"; echo "$_gstack_helper_error" elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then ${m}="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true else @@ -169,6 +175,7 @@ echo "CODEX_MODE: $${m}" Branch on the echoed \`CODEX_MODE\`: - **\`disabled\`** — the user turned Codex reviews off (\`codex_reviews=disabled\`). ${disabledLine} +- **\`helper_unavailable\`** — the helper could not load; relay the line above (cause and fix). ${nativeRoute} - **\`not_installed\`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: \`npm install -g @openai/codex\`." ${nativeRoute} - **\`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\`." ${nativeRoute} diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index 36d8357aa..31cda1bf4 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -21,13 +21,16 @@ echo "DIFF_SIZE: $DIFF_TOTAL" # 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 @@ -55,6 +58,7 @@ 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. diff --git a/test/helpers/carve-guards.ts b/test/helpers/carve-guards.ts index 07901f4eb..6d96ee761 100644 --- a/test/helpers/carve-guards.ts +++ b/test/helpers/carve-guards.ts @@ -166,7 +166,7 @@ export const CARVE_GUARDS: Record = { // wave's headline capability) grows the union to 1.195x. Deliberate: // the section is on-demand (loads only for Apple store targets), so // per-invocation cost for non-iOS ships is one manifest line. - maxSizeRatio: 1.432, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + maxSizeRatio: 1.434, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + A1 rework: the preflight's helper_unavailable branch (the sourced helper's own diagnostic with its doc URL); measured 269,141 / 187,706 = 1.4338 (2026-10-06). }, 'plan-ceo-review': { skill: 'plan-ceo-review', @@ -221,7 +221,7 @@ export const CARVE_GUARDS: Record = { // 1.08 → 1.10: the scope-gate exceptions block (+ its adversarial-review // hardening: host-anchored mode signal, precedence, passing-mention // guards) and the plan-mode preamble reword land the union at 1.092. - maxSizeRatio: 1.175, // + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01). + maxSizeRatio: 1.176, // + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01). + Oct 6 fix wave: quota_exhausted and helper_unavailable preflight branches; measured 146,404 / 124,597 = 1.1750 (2026-10-06). }, 'plan-design-review': { skill: 'plan-design-review', @@ -485,7 +485,7 @@ do not launch the downstream skill or open a browser.`, maxSkeletonBytes: 74_881, // Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357 mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'], - maxSizeRatio: 1.202, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + maxSizeRatio: 1.206, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + A1 rework: helper_unavailable preflight branch; measured 130,845 / 108,523 = 1.2057 (2026-10-06). }, codex: { skill: 'codex', diff --git a/test/regression-issue3024-zsh-sourced-helpers.test.ts b/test/regression-issue3024-zsh-sourced-helpers.test.ts index fa2b008cd..9494e9b9b 100644 --- a/test/regression-issue3024-zsh-sourced-helpers.test.ts +++ b/test/regression-issue3024-zsh-sourced-helpers.test.ts @@ -7,20 +7,34 @@ * "/../scripts/...". * * Class guard: the helper list is derived from the generated skills and - * resolvers (not a hand list), and each helper is sourced under bash and zsh - * from an unrelated cwd. CI installs zsh (free-tests.yml), so a missing zsh - * there fails instead of skipping. + * resolvers, plus every helper those helpers source in turn (not a hand list). + * Each helper is sourced under bash and zsh from an unrelated cwd, by absolute + * path, by relative path followed by `cd`, and by bare name through PATH, with + * one real call each to the model resolver, the auth probe, the state root and + * the egress home. Any other shell, or a shell that cannot name the file, gets + * the helper's own message instead of a guessed path or a syntax error. CI + * installs zsh (free-tests.yml, Dockerfile.ci), so a missing zsh there fails + * instead of skipping. */ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { codexPreflight } from '../scripts/resolvers/constants'; const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin'); +const PROBE = path.join(BIN, 'gstack-codex-probe'); +const EGRESS = path.join(BIN, 'gstack-egress-lib.sh'); const HAS_ZSH = Boolean(Bun.which('zsh')); +const HAS_DASH = Boolean(Bun.which('dash')); const ZSH_REQUIRED = Boolean(process.env.CI) && process.platform !== 'win32'; +const DASH_REQUIRED = Boolean(process.env.CI) && process.platform === 'linux'; +const ANCHOR = 'https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location'; +const SELF_LOCATE = /# >>> gstack self-locate[^\n]*\n[\s\S]*?# <<< gstack self-locate <<<\n/; + +if (!HAS_ZSH && !ZSH_REQUIRED) console.warn('zsh not installed: install zsh to run the sourced-helper guard (CI requires it)'); function walk(dir: string, keep: (file: string) => boolean, out: string[] = []): string[] { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -32,7 +46,13 @@ function walk(dir: string, keep: (file: string) => boolean, out: string[] = []): return out; } -/** Every bin/ helper a generated skill or resolver loads with `source` or `.`. */ +const code = (text: string) => text.split('\n').filter(line => !/^\s*#/.test(line)).join('\n'); + +/** + * Every bin/ helper a generated skill or resolver loads with `source` or `.`, + * plus every bin/ file those helpers source in turn (for example + * gstack-state-root.sh, sourced by the codex probe and the egress lib). + */ function sourcedHelpers(): string[] { const files = [ ...walk(path.join(ROOT, 'scripts', 'resolvers'), f => f.endsWith('.ts')), @@ -43,6 +63,7 @@ function sourcedHelpers(): string[] { const names = new Set(); const direct = /(?:^|[\s;&|({]|then\s)(?:source|\.)\s+"?[^"\s;)<]*\bbin\/([A-Za-z0-9._-]+)/g; const viaVar = /\/(gstack-[A-Za-z0-9._-]+\.sh)"; \[ -r "\$[A-Z_]+" \] && \. "\$[A-Z_]+"/g; + const nested = /(?:^|[\s;&|({]|then\s)(?:source|\.)\s+"?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?\/([A-Za-z0-9._-]+)/g; for (const file of files) { const text = fs.readFileSync(file, 'utf8'); for (const re of [direct, viaVar]) { @@ -51,47 +72,93 @@ function sourcedHelpers(): string[] { } } } + const queue = [...names]; + while (queue.length) { + const text = code(fs.readFileSync(path.join(BIN, queue.shift()!), 'utf8')); + for (const re of [direct, nested]) { + for (const m of text.matchAll(re)) { + if (!names.has(m[1]!) && fs.existsSync(path.join(BIN, m[1]!))) { names.add(m[1]!); queue.push(m[1]!); } + } + } + } return [...names].sort(); } -function run(shell: 'bash' | 'zsh', script: string, env: Record = {}) { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-cwd-')); - const args = shell === 'zsh' ? ['-f', '-c', script] : ['--noprofile', '--norc', '-c', script]; +function run(shell: string, script: string, env: Record = {}, cwd?: string) { + const dir = cwd ?? fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-cwd-')); + const args = shell === 'zsh' ? ['-f', '-c', script] : shell === 'bash' ? ['--noprofile', '--norc', '-c', script] : ['-c', script]; const r = spawnSync(shell, args, { - cwd, - env: { PATH: process.env.PATH ?? '', HOME: cwd, ...env }, + cwd: dir, + env: { PATH: process.env.PATH ?? '', HOME: dir, ...env }, encoding: 'utf8', timeout: 20_000, }); - fs.rmSync(cwd, { recursive: true, force: true }); - return { code: r.status, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }; + if (!cwd) fs.rmSync(dir, { recursive: true, force: true }); + return { code: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '', out: `${r.stdout ?? ''}${r.stderr ?? ''}` }; } +/** A HOME whose ~/.claude/skills/gstack is this checkout, a stub codex that answers OK, and Codex credentials. */ +function preflightHome(install: string = ROOT) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-preflight-')); + fs.mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true }); + fs.symlinkSync(install, path.join(home, '.claude', 'skills', 'gstack')); + fs.mkdirSync(path.join(home, 'stub-bin')); + fs.writeFileSync(path.join(home, 'stub-bin', 'codex'), '#!/usr/bin/env bash\n[ "$1" = sandbox ] && exit 0\necho OK\n', { mode: 0o755 }); + fs.mkdirSync(path.join(home, '.codex')); + fs.writeFileSync(path.join(home, '.codex', 'config.toml'), 'model = "gpt-test-model"\n'); + fs.writeFileSync(path.join(home, '.codex', 'auth.json'), '{}'); + const env = { + PATH: `${path.join(home, 'stub-bin')}:${process.env.PATH ?? ''}`, + CODEX_HOME: path.join(home, '.codex'), + GSTACK_HOME: path.join(home, '.gstack'), + }; + return { home, env }; +} + +const PREFLIGHT = codexPreflight({ disabledBehavior: 'skip-all' }).match(/```bash\n([\s\S]*?)\n```/)![1]!; const SHELLS = ['bash', 'zsh'] as const; const skipShell = (shell: string) => shell === 'zsh' && !HAS_ZSH && !ZSH_REQUIRED; +const skipDash = !HAS_DASH && !DASH_REQUIRED; describe('#3024: sourced helpers work under bash and zsh', () => { const helpers = sourcedHelpers(); - test('zsh is present wherever CI runs this suite', () => { + test('zsh (and dash on Linux) is present wherever CI runs this suite', () => { if (ZSH_REQUIRED) expect(HAS_ZSH).toBe(true); + if (DASH_REQUIRED) expect(HAS_DASH).toBe(true); }); - test('the derived helper list covers the known sourced helpers', () => { - for (const known of ['gstack-codex-probe', 'gstack-egress-lib.sh', 'gstack-gbrain-lib.sh']) { + test('the derived helper list covers the known sourced helpers, including nested sources', () => { + for (const known of ['gstack-codex-probe', 'gstack-egress-lib.sh', 'gstack-gbrain-lib.sh', 'gstack-state-root.sh']) { expect(helpers).toContain(known); } }); - test('no sourced helper uses bash-only self-location or indirection without a zsh branch', () => { + test('BASH_SOURCE appears only inside the shared self-locate block, byte for byte in every helper', () => { + const blocks = new Map(); for (const name of helpers) { - const text = fs.readFileSync(path.join(BIN, name), 'utf8') - .split('\n').filter(line => !/^\s*#/.test(line)).join('\n'); - if (text.includes('BASH_SOURCE')) { - expect({ name, zshBranch: text.includes('ZSH_VERSION') }).toEqual({ name, zshBranch: true }); - expect({ name, rawUse: /\$\{BASH_SOURCE\[0\]%/.test(text) }).toEqual({ name, rawUse: false }); - } - expect({ name, indirect: /\$\{![A-Za-z_]/.test(text) }).toEqual({ name, indirect: false }); + const text = fs.readFileSync(path.join(BIN, name), 'utf8'); + const block = text.match(SELF_LOCATE)?.[0]; + if (block) blocks.set(name, block); + expect({ name, outside: code(text.replace(SELF_LOCATE, '')).includes('BASH_SOURCE') }).toEqual({ name, outside: false }); + } + expect([...blocks.keys()]).toEqual(expect.arrayContaining(['gstack-codex-probe', 'gstack-egress-lib.sh'])); + const [first] = [...blocks.values()]; + for (const [name, block] of blocks) expect({ name, block }).toEqual({ name, block: first! }); + }); + + test('no sourced helper uses other bash-only constructs', () => { + const bashOnly: Array<[string, RegExp]> = [ + ['${!name} indirection', /\$\{![A-Za-z_]/], + ['declare -n', /\bdeclare\s+-[A-Za-z]*n/], + ['mapfile/readarray', /\b(?:mapfile|readarray)\b/], + ['BASH_REMATCH', /\bBASH_REMATCH\b/], + ['[[ =~ ]]', /\[\[[^\]\n]*=~/], + ]; + for (const name of helpers) { + const text = code(fs.readFileSync(path.join(BIN, name), 'utf8')); + const found = bashOnly.filter(([, re]) => re.test(text)).map(([label]) => label); + expect({ name, found }).toEqual({ name, found: [] }); } }); @@ -107,11 +174,7 @@ describe('#3024: sourced helpers work under bash and zsh', () => { test.skipIf(skipShell(shell))(`codex probe resolves its own directory and selects the model (${shell})`, () => { const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-codex-')); fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-test-model"\n'); - const r = run( - shell, - `. '${path.join(BIN, 'gstack-codex-probe')}' && echo "BIN=$_GSTACK_CODEX_BIN" && _gstack_codex_select_model exec`, - { CODEX_HOME: codexHome }, - ); + const r = run(shell, `. '${PROBE}' && echo "BIN=$_GSTACK_CODEX_BIN" && _gstack_codex_select_model exec`, { CODEX_HOME: codexHome }); fs.rmSync(codexHome, { recursive: true, force: true }); expect(r.out).toContain(`BIN=${BIN}\n`); expect(r.out).toContain('CODEX_MODEL: gpt-test-model (exec;'); @@ -119,10 +182,39 @@ describe('#3024: sourced helpers work under bash and zsh', () => { expect(r.code).toBe(0); }); + test.skipIf(skipShell(shell))(`relative-path and bare-name (PATH) sources keep working after cd (${shell})`, () => { + // realpath: macOS's temp root is a symlink, and `..` walks the physical path. + const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-rel-'))); + const codexHome = path.join(cwd, 'codex-home'); + fs.mkdirSync(codexHome); + fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-test-model"\n'); + const env = { CODEX_HOME: codexHome, GSTACK_HOME: path.join(cwd, 'state') }; + const after = `cd / && _gstack_codex_select_model exec && _gstack_egress_home`; + try { + for (const [label, load] of [ + ['relative', `. '${path.relative(cwd, PROBE)}' && . '${path.relative(cwd, EGRESS)}'`], + ['bare name', `PATH='${BIN}':"$PATH" && . gstack-codex-probe && . gstack-egress-lib.sh`], + ] as const) { + const r = run(shell, `${load} && ${after}`, env, cwd); + expect({ label, model: r.out.includes('CODEX_MODEL: gpt-test-model (exec;'), home: r.stdout.trim() === env.GSTACK_HOME, code: r.code }) + .toEqual({ label, model: true, home: true, code: 0 }); + } + } finally { fs.rmSync(cwd, { recursive: true, force: true }); } + }); + + test.skipIf(skipShell(shell))(`a set (unexported) GSTACK_ROOT locates a helper the shell cannot name; nothing else is guessed (${shell})`, () => { + const located = run(shell, `GSTACK_ROOT='${ROOT}'; eval "$(cat '${PROBE}')" && echo "BIN=$_GSTACK_CODEX_BIN"`); + expect(located.out).toContain(`BIN=${BIN}\n`); + const lost = run(shell, `eval "$(cat '${PROBE}')"; echo "after=$?"`); + expect(lost.out).toContain(`gstack: cannot locate gstack-codex-probe (shell: ${shell}). Source it from bash or zsh, or export GSTACK_ROOT=. ${ANCHOR}`); + expect(lost.out).not.toContain('after='); + expect(lost.code).toBe(1); + }); + test.skipIf(skipShell(shell))(`codex auth probe reads a custom provider's env_key (${shell})`, () => { const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-auth-')); fs.writeFileSync(path.join(codexHome, 'config.toml'), '[model_providers.local]\nenv_key = "LOCAL_PROVIDER_KEY"\n'); - const r = run(shell, `. '${path.join(BIN, 'gstack-codex-probe')}' && _gstack_codex_auth_probe`, { + const r = run(shell, `. '${PROBE}' && _gstack_codex_auth_probe`, { CODEX_HOME: codexHome, LOCAL_PROVIDER_KEY: 'present', }); @@ -131,9 +223,66 @@ describe('#3024: sourced helpers work under bash and zsh', () => { expect(r.code).toBe(0); }); - test.skipIf(skipShell(shell))(`egress lib resolves its own directory, not the caller's cwd (${shell})`, () => { - const r = run(shell, `. '${path.join(BIN, 'gstack-egress-lib.sh')}' && echo "DIR=$_gstack_egress_lib_dir"`); - expect(r.out).toContain(`DIR=${BIN}\n`); + test.skipIf(skipShell(shell))(`state root and egress home resolve through the helpers, not the caller's cwd (${shell})`, () => { + const state = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-state-')); + try { + const root = run(shell, `. '${path.join(BIN, 'gstack-state-root.sh')}' && gstack_state_root`, { GSTACK_HOME: state }); + expect(root.out.trim()).toBe(state); + const egress = run(shell, `. '${EGRESS}' && echo "DIR=$_gstack_egress_lib_dir" && _gstack_egress_home`, { GSTACK_HOME: state }); + expect(egress.out).toContain(`DIR=${BIN}\n`); + expect(egress.stdout).toBe(`DIR=${BIN}\n${state}`); + } finally { fs.rmSync(state, { recursive: true, force: true }); } + }); + + test.skipIf(skipShell(shell))(`the full generated Codex preflight reaches CODEX_MODE: ready (${shell})`, () => { + const { home, env } = preflightHome(); + try { + const r = run(shell, PREFLIGHT, env, home); + expect(r.out).toContain('CODEX_MODEL: gpt-test-model (exec;'); + expect(r.out).toContain('CODEX_MODE: ready'); + } finally { fs.rmSync(home, { recursive: true, force: true }); } }); } + + test.skipIf(skipDash)('dash reaches each helper\'s message, never a syntax error, even with GSTACK_ROOT set', () => { + for (const name of ['gstack-codex-probe', 'gstack-egress-lib.sh']) { + for (const env of [{}, { GSTACK_ROOT: ROOT }] as Record[]) { + const r = run('dash', `. '${path.join(BIN, name)}'; echo "rc=$?"`, env); + expect([r.stderr, r.stdout]).toEqual([`gstack: cannot locate ${name} (shell: dash). Source it from bash or zsh, or export GSTACK_ROOT=. ${ANCHOR}\n`, 'rc=1\n']); + } + } + }); + + test('an unknown shell (no BASH_VERSION or ZSH_VERSION) fails loudly instead of guessing', () => { + const r = run('bash', `unset BASH_VERSION; . '${PROBE}'; echo "rc=$? bin=\${_GSTACK_CODEX_BIN:-unset}"`, { GSTACK_ROOT: ROOT }); + expect(r.out).toContain(`gstack: cannot locate gstack-codex-probe (shell: bash). Source it from bash or zsh, or export GSTACK_ROOT=. ${ANCHOR}`); + expect(r.out).toContain('rc=1 bin=unset'); + }); + + for (const [label, shell, prefix, expected] of [ + ['dash', 'dash', '', `gstack: cannot locate gstack-codex-probe (shell: dash). Source it from bash or zsh, or export GSTACK_ROOT=. ${ANCHOR}`], + ['an unknown shell', 'bash', 'unset BASH_VERSION\n', `gstack: cannot locate gstack-codex-probe (shell: bash). Source it from bash or zsh, or export GSTACK_ROOT=. ${ANCHOR}`], + ] as const) { + test.skipIf(shell === 'dash' && skipDash)(`the full generated preflight under ${label} reports helper_unavailable with the helper's line`, () => { + const { home, env } = preflightHome(); + try { + const r = run(shell, `${prefix}${PREFLIGHT}`, env, home); + expect(r.out).toContain(`${expected}\nCODEX_MODE: helper_unavailable\n`); + expect(r.out).not.toContain('not_authed'); + expect(r.out).not.toMatch(/Syntax error|not found/); + } finally { fs.rmSync(home, { recursive: true, force: true }); } + }); + } + + test('the full generated preflight with the helper missing reports helper_unavailable, not not_authed', () => { + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-empty-install-')); + const { home, env } = preflightHome(empty); + try { + const r = run('bash', PREFLIGHT, env, home); + expect(r.out).toContain(`gstack: cannot load gstack-codex-probe; re-run ./setup. ${ANCHOR}\nCODEX_MODE: helper_unavailable\n`); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(empty, { recursive: true, force: true }); + } + }); });