v1.91.96.0 fix: honest install and self-upgrade on Capy (field wave A, items 1-5) (#3164)

* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture

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

* feat(doctor): --min-revision, a launch Bun found off PATH, planning-only pins SKIP

gstack-doctor --check gains --min-revision <v>|unknown: below it the revision
row FAILs with REVISION_BELOW_MINIMUM naming the Capy wrapper or
bin/gstack-upgrade; unknown says so and never claims the minimum. The runtime
and pins rows name a supported Bun found in $BUN_INSTALL/bin (not on PATH)
with the export/GSTACK_LAUNCH fix (gstack_bun_launch_dir, one helper in
bin/gstack-bun-version.sh). --for a planning-only workflow (autoplan, the plan
reviews, office-hours) reports a repo test-pin mismatch as SKIP pins
(planning-only; ...). New result codes UPGRADE_REFUSED, UPGRADE_FETCH_FAILED,
REVISION_BELOW_MINIMUM, CAPY_WRAPPER_STALE, INSTALL_UNVERIFIED with
troubleshooting anchors; REVISION_UNMET's fix names bin/gstack-upgrade.

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

* feat(install): install verdict and install admission at skill start

bin/gstack-install-verdict.sh (Bash) and lib/install-verdict.ts (TypeScript)
are the one reader of the install verdict gstack-capy-install writes to
<state root>/install-verdicts/<sha256(checkout realpath)>.json (checkout,
HEAD, workflow and required components, planning-only pins, trailer). The
source checkout and capy/support.env resolve through gstack_source_root /
sourceRoot, which follow an installed root's .source-path.

gstack-skill-start, after SESSION_KIND: an unattended session (any host)
refuses on a PATH Bun below the supported minimum; with GSTACK_CAPY=1 an
unattended or explicit spawned session also refuses without a passing
verdict for this checkout, HEAD and workflow (INSTALL_UNVERIFIED). A refusal
prints the INSTALL: row, an install-refused instruction block bound to its
SESSION_ID, GSTACK_RESULT ... status=refused run=- and exits 3. Ambient
spawned sessions warn; GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and says so.
capy/support.env declares GSTACK_MIN_REVISION and GSTACK_CAPY_MIN_WRAPPER.

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

* feat(capy-install): launch PATH, Bun reuse and CI pins, setup skip on HEAD, verdict, --check

The GSTACK_LAUNCH line carries PATH=<dir>:$PATH when the certified Bun is not
first on PATH, plus GSTACK_CAPY=1 and GSTACK_INSTALL_VERDICT, and
GSTACK_LAUNCH_PATH: <dir>; the doctor runs with that PATH. A supported Bun in
$BUN_INSTALL/bin is reused instead of downloaded again. Project pins are read
with bin/gstack-runtime-pins.ts (CI lanes included): when every exact pin is
gstack's verified version the archive is installed alongside a different PATH
Bun; any other exact pin is RUNTIME_PIN_CONFLICT. Both ./setup runs are
skipped when this HEAD was set up with the same hosts, model and browser flag
and the check passes (never after a fast-forward); a failed setup ends with
gstack: fail setup and is retried next run. --check certifies only;
GSTACK_CAPY_LOCK_HELD=1 accepts the bootstrapper's held lock; result anchors
come from the generated components table; REV_NOTE names bin/gstack-upgrade.

Intentional pin change: test/gstack-capy-install.test.ts --revision trailer
now names <root>/bin/gstack-upgrade (the old text named a command that never
existed).

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

* feat(upgrade): bin/gstack-upgrade, the unattended fast-forward every fix line names

--check fetches and prints current|behind <installed> -> <remote> (<n>
commits); the default fast-forwards to the remote default branch tip and
reinstalls (bin/gstack-capy-install on a Capy install, else ./setup
--refresh-registered), completing a pending reinstall on the next run. It
refuses (UPGRADE_REFUSED, exit 3, nothing changed) a dirty tracked byte, a
diverged commit, a pinned checkout, the incoming Bun floor and a held
.setup-lock; never stashes or resets; an unreachable remote is
UPGRADE_FETCH_FAILED. Fetches are receipted (fail-open). The unattended
skill-end UPGRADE_AVAILABLE line is followed by UPGRADE_COMMAND naming it.

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

* feat(capy): vendored Capy skill, frozen bootstrapper and full wrapper bin/gstack-capy-session

capy/skill/gstack/ is the Drive skill's source of truth. scripts/install.sh
is a frozen bootstrapper: it prints gstack-capy-skill: wrapper <n> (<path>),
clones or fetches under .gstack-capy-install.lock, reads the remote
capy/support.env (FETCH_HEAD), fast-forwards a clean unpinned checkout below
GSTACK_MIN_REVISION (refusing dirty, diverged or pinned checkouts with the
exact commands; GSTACK_CAPY_AUTO_UPGRADE=0 disables), and execs the
checkout's own bin/gstack-capy-session with GSTACK_CAPY_LOCK_HELD=1 so the
held lock is never taken twice. Offline it says the remote minimum is
unknown. A Drive copy older than GSTACK_CAPY_MIN_WRAPPER prints an owner
action (CAPY_WRAPPER_STALE). SKILL.md: check at session start, install
lazily before the first workflow, the GSTACK_LAUNCH line, the trailer rule,
the subagent first-report line, gstack-pregate sealed before any push, and
the autoplan/DRIVER.md pointer. The context-budget fixture gains the skill's
entry.

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

* docs: Capy install layers, lazy install, snapshot bake, forward-only recovery, raising the minimum

docs/capy-install.md describes the three layers (Drive bootstrapper, full
wrapper, installer), the lazy install, the GSTACK_LAUNCH line, install
admission, baking gstack into a Capy snapshot, and that recovery after an
interrupted fast-forward completes or refuses the pending install stages
(checking out an older commit is not a rollback). docs/CHANGELOG_STYLE.md
(the release doc; there is no docs/RELEASING.md) says when
GSTACK_MIN_REVISION may be raised.

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

* refactor(capy): the stale-copy owner action moves from the frozen bootstrapper to bin/gstack-capy-session

The checkout-owned full wrapper reads GSTACK_CAPY_MIN_WRAPPER through
gstack_support_env_get(gstack_source_root) and compares it with the
bootstrapper revision it was handed, so the rule can change without
recopying the Drive skill. The bootstrapper keeps only what a stale checkout
cannot do: clone, fetch, read the remote minimum, fast-forward, exec.

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

* v1.91.96.0: honest install and self-upgrade on Capy; supported minimum 1.91.96.0

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

* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper

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

* test: bound the new sync spawns in the install-verdict and wrapper tests (spawnSync tripwire)

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

* ci(paid-pr-profile): the vendored Capy skill is free-only; free-only files are not prompts needing paid coverage

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

* chore(harness): non-behavioral HARNESS_VERSION decision for the PR-profile free-only rule

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

* test(doctor): lane A runtime and pins cases are platform-neutral (bash paths; a lane-free .bun-version pin)

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

* docs(designs): field wave plan with amendments and the gate rev 2-3 review record (ARTIFACT_VALID, 3 gate revisions)

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

* fix(capy-install): follow a project's exact Bun pin above the minimum when gstack has its verified archive (adds Bun 1.4.3)

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

* docs: the installer follows a verified exact project Bun pin (capy-install, troubleshooting, changelog)

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

* fix(capy-install): the install reason names the project's pin and gstack's real minimum

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-13 14:41:58 +00:00
1 parent dbf400ec3f
commit ada251bbc0
36 files changed
+2474 -137

No files matched your search

+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [1.91.96.0] - 2026-10-13
**On a Capy machine, gstack now tells the truth about itself and fixes the common cases on its own. An old checkout upgrades itself at session start, the install check agrees with the Bun your workflows run, and every fix line names a command that exists.**
### What this means for you
- **`bin/gstack-upgrade` exists.** It fast-forwards the checkout to the remote default branch and reinstalls (on Capy it runs the installer, so the Bun preflight comes first; elsewhere `./setup --refresh-registered`, which runs migrations). It never stashes, resets or discards anything. A dirty, diverged, pinned or locked checkout is refused with `UPGRADE_REFUSED`, and an unreachable remote with `UPGRADE_FETCH_FAILED`. `--check` prints `current` or `behind <installed> -> <remote>` and changes nothing. The doctor's revision fix, the installer's `--revision` note and the unattended upgrade notice now name this command.
- **A checkout below the supported minimum upgrades itself at session start.** The Capy skill's bootstrapper (`capy/skill/gstack/scripts/install.sh`) fetches the remote, reads `capy/support.env` from it, and, when the checkout is clean, unpinned and below `GSTACK_MIN_REVISION`, fast-forwards it under the install lock, reinstalls and rechecks. `--check` only reports `gstack: fail revision … (fast-forward available)`; install mode does the work. A setup interrupted after the fast-forward is finished by the next run. `GSTACK_CAPY_AUTO_UPGRADE=0` turns it off.
- **Workflows refuse while the install check fails.** The installer writes a verdict file for this checkout and HEAD. Under `GSTACK_CAPY=1`, `gstack-skill-start` refuses a workflow whose verdict is missing, failed, for another HEAD or for a different workflow, and in any unattended session it refuses a PATH Bun below the minimum (`status=refused`, exit 3). `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` overrides and is recorded. Interactive sessions and other hosts' spawned sessions only warn.
- **The install check agrees with the Bun your workflows run.** `GSTACK_LAUNCH` now carries `PATH=<verified bun dir>:$PATH`, `GSTACK_CAPY=1` and `GSTACK_INSTALL_VERDICT`, and the wrapper prints it on `--check` as well as install. A bare doctor run in a fresh shell names the off-PATH Bun (`bun 1.4.2 found at ~/.bun/bin (not on PATH)`) with the exact fix. The installer reuses a verified Bun instead of downloading it again, and installs the version a project's CI pins next to a different system Bun. Planning-only checks (`--for autoplan`, the plan reviews, office-hours) report a project test-pin mismatch as `SKIP pins (planning-only …)`; workflows that run the project's tests still require it.
- **A stale copy of the Capy skill gets an owner action instead of a silent pass.** The canonical skill now lives in the repo at `capy/skill/gstack/`. The Drive copy is a small frozen bootstrapper that hands off to the checkout's full wrapper (`bin/gstack-capy-session`). An older copy keeps working and prints `CAPY_WRAPPER_STALE` with the copy command.
- **Machines that never run a workflow don't pay for the install.** A warm install on the same HEAD skips setup (about 1 s). The skill installs before the first workflow rather than at every session start. `docs/capy-install.md` explains how to bake gstack into a Capy snapshot.
- **The installed Bun follows the project's exact pin.** When the target repo pins one Bun version exactly (a CI `bun-version:`, `.bun-version`, engines) above gstack's minimum and gstack carries its verified archive (now 1.4.2 and 1.4.3), that archive is installed, so a repo moving its CI to 1.4.3 does not leave Capy machines on 1.4.2 failing `pins`. An exact pin without a verified archive stops with `RUNTIME_PIN_CONFLICT` and lists the verified versions.
### For contributors
- New: `bin/gstack-upgrade`, `bin/gstack-capy-session`, `bin/gstack-install-verdict.sh`, `lib/install-verdict.ts`, `capy/support.env`, `capy/skill/gstack/`. Tests `test/gstack-upgrade-bin.test.ts`, `test/capy-skill-wrapper.test.ts`, `test/install-verdict.test.ts`, and the shared simulated Capy host `test/helpers/capy-host.ts` (Bun 1.3.14 first on PATH, no Codex CLI).
- `GSTACK_MIN_REVISION` is raised only for install-honesty or security fixes, at stamp time, never above VERSION (pinned by test).
## [1.91.95.0] - 2026-10-12
**A reopened `/autoplan` gate now exports a run that validates.**
+1 -1
View File
@@ -1 +1 @@
1.91.95.0
1.91.96.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.95.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.96.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,
+48 -10
View File
@@ -16,17 +16,29 @@
GSTACK_BUN_FLOOR="1.3.3"
GSTACK_BUN_TESTED="1.4.2"
# gstack_bun_archive_sha256 TARGET — the SHA-256 of
# https://github.com/oven-sh/bun/releases/download/bun-v$GSTACK_BUN_TESTED/bun-TARGET.zip
# from that release's SHASUMS256.txt, for the targets bin/gstack-capy-install
# installs on Capy cloud machines (the same exact-archive recipe as
# .github/docker/Dockerfile.ci; test/bun-version-drift.test.ts keeps them
# equal). Prints nothing and returns 1 for a target without a verified archive.
# gstack_bun_archive_sha256 TARGET [VERSION] — the SHA-256 of
# https://github.com/oven-sh/bun/releases/download/bun-v$VERSION/bun-TARGET.zip
# (VERSION defaults to $GSTACK_BUN_TESTED) from that release's SHASUMS256.txt,
# for the targets bin/gstack-capy-install installs on Capy cloud machines (the
# same exact-archive recipe as .github/docker/Dockerfile.ci;
# test/bun-version-drift.test.ts keeps the $GSTACK_BUN_TESTED rows equal). A
# newer version is listed so the installer can follow a project's exact pin
# above the minimum. Prints nothing and returns 1 without a verified archive.
GSTACK_BUN_VERIFIED="1.4.2 1.4.3"
gstack_bun_archive_sha256() {
case "$1" in
linux-x64) echo 36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;;
linux-x64-baseline) echo c678040f14fe0440eb839d37cbd0ce4c051a32da72806ac97de6a6aab6bf728f ;;
linux-aarch64) echo 54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 ;;
case "${2:-$GSTACK_BUN_TESTED}" in
1.4.2) case "$1" in
linux-x64) echo 36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;;
linux-x64-baseline) echo c678040f14fe0440eb839d37cbd0ce4c051a32da72806ac97de6a6aab6bf728f ;;
linux-aarch64) echo 54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 ;;
*) return 1 ;;
esac ;;
1.4.3) case "$1" in
linux-x64) echo 3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18 ;;
linux-x64-baseline) echo 1fc2edac843102909e3a1be1d8d9802cc6071cf074e67e88231f4ffe0f8b397b ;;
linux-aarch64) echo efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21 ;;
*) return 1 ;;
esac ;;
*) return 1 ;;
esac
}
@@ -88,3 +100,29 @@ gstack_bun_incoming_hold() {
_gb_ver=$(git -C "$1" show "$2:VERSION" 2>/dev/null | head -1)
echo "bun-too-old: found Bun $_gb_found at $(command -v bun); gstack ${_gb_ver:-update} needs $_gb_floor or newer"
}
# gstack_bun_launch_dir [exact] — the directory to put first on PATH so
# gstack's `#!/usr/bin/env bun` bins run a supported Bun: the first of
# $BUN_INSTALL/bin and $HOME/.bun/bin whose bun is ok (and equals <exact>
# when given), unless the bun already first on PATH qualifies. Prints the
# directory (callers read its version with "<dir>/bun --version"); prints
# nothing and returns 1 when PATH's bun suffices or no candidate qualifies. Shared by
# gstack-capy-install (the GSTACK_LAUNCH line) and gstack-doctor --check (the
# runtime row's `found at ... (not on PATH)`).
gstack_bun_launch_dir() {
_gb_path_bun=$(command -v bun 2>/dev/null || true)
if [ -n "$_gb_path_bun" ]; then
_gb_v=$(bun --version </dev/null 2>/dev/null | head -1)
if [ "$(gstack_bun_status "$_gb_v")" = ok ] && { [ -z "${1:-}" ] || [ "$_gb_v" = "$1" ]; }; then return 1; fi
fi
for _gb_dir in "${BUN_INSTALL:+$BUN_INSTALL/bin}" "${HOME:+$HOME/.bun/bin}"; do
[ -n "$_gb_dir" ] && [ -x "$_gb_dir/bun" ] || continue
[ "$_gb_dir/bun" = "$_gb_path_bun" ] && continue
_gb_v=$("$_gb_dir/bun" --version </dev/null 2>/dev/null | head -1)
[ "$(gstack_bun_status "$_gb_v")" = ok ] || continue
[ -z "${1:-}" ] || [ "$_gb_v" = "$1" ] || continue
printf '%s\n' "$_gb_dir"
return 0
done
return 1
}
+172 -69
View File
@@ -5,16 +5,23 @@
# Usage:
# gstack-capy-install [--for <skill> | --only <skill> | --require <id,id>] [--with-browser]
# [--revision <ref>] [--project <path>] [--root <dir>]
# [--check] [--min-revision <v>|unknown]
#
# Steps: clone or reuse the checkout under a lock (never reset, never pulled);
# preflight git, curl, node, jq and Bun (Bun is installed or upgraded from the
# exact release archive, SHA-256 verified, only when gstack's supported minimum
# and the project's pin agree); ./setup --host claude --no-prefix; ./setup
# --host codex --model gpt-6-astra; gstack-doctor --check. Chromium is lazy by
# default (bin/gstack-browser-ensure installs it on first use); --with-browser
# installs it now. The last lines are the doctor's trailer (`gstack: ok ...` or
# `gstack: fail ...`; grep that, never pipe through tail) and the GSTACK_LAUNCH
# line the parent supplies to every gstack command it launches.
# Steps: clone or reuse the checkout under a lock (never reset, never pulled
# here; the Capy skill bootstrapper fast-forwards below-minimum checkouts and
# hands its held lock over with GSTACK_CAPY_LOCK_HELD=1); preflight git, curl,
# node, jq and Bun (a supported Bun already in $BUN_INSTALL/bin is reused; the
# exact release archive, SHA-256 verified, is installed there only when
# gstack's supported minimum and the project's pins agree on it); ./setup
# --host claude --no-prefix; ./setup --host codex --model gpt-6-astra (both
# skipped when this HEAD was already set up and the check passes);
# gstack-doctor --check with the launch PATH; the install verdict
# (bin/gstack-install-verdict.sh). --check certifies only: no clone, no Bun
# install, no setup. Chromium is lazy by default (bin/gstack-browser-ensure
# installs it on first use); --with-browser installs it now. The last lines
# are the GSTACK_LAUNCH line the parent supplies to every gstack command it
# launches and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
# grep that, never pipe through tail).
# Exit: 0 ok, 1 a component failed, 2 usage, 3 preflight stopped before ./setup.
set -u
BASH_COMPAT=50
@@ -23,8 +30,8 @@ SELF="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="${GSTACK_CAPY_ROOT:-$SELF}"
REPO="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}"
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT=""
usage() { sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; }
FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}"
usage() { sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; }
need() { [ -n "${2:-}" ] || { echo "gstack-capy-install: $1 needs a value" >&2; usage >&2; exit 2; }; }
while [ $# -gt 0 ]; do
case "$1" in
@@ -34,6 +41,8 @@ while [ $# -gt 0 ]; do
--revision) need "$1" "${2:-}"; REVISION="$2"; shift ;;
--project) need "$1" "${2:-}"; PROJECT="$2"; shift ;;
--root) need "$1" "${2:-}"; ROOT="$2"; shift ;;
--check) CHECK_ONLY=1 ;;
--min-revision) need "$1" "${2:-}"; MIN_REVISION="$2"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "gstack-capy-install: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
esac
@@ -47,16 +56,26 @@ stop() { # CODE MESSAGE FIX — print and exit 3 before ./setup
echo "gstack-capy-install: $2; fix: $3 ($1; $DOCS#$(gstack_result_anchor "$1" 2>/dev/null || echo capy-install-preflight-failed))" >&2
exit 3
}
gstack_result_anchor() { case "$1" in INSTALL_PREFLIGHT_FAILED) echo capy-install-preflight-failed ;; RUNTIME_PIN_CONFLICT) echo capy-install-runtime-conflict ;; *) return 1 ;; esac; }
# Result-code anchors come from the generated table (lib/result-codes.ts).
. "$SELF/bin/gstack-doctor-components.sh" 2>/dev/null || gstack_result_anchor() { return 1; }
now() { date +%s; }
T0=$(now); ORIG_PWD=$(pwd)
# 1. Clone or reuse, under the same lock the user-scope skill takes.
mkdir -p "$(dirname "$ROOT")" || stop INSTALL_PREFLIGHT_FAILED "cannot create $(dirname "$ROOT")" "choose a writable --root"
command -v git >/dev/null 2>&1 || stop INSTALL_PREFLIGHT_FAILED "preflight: git not found" "apt-get install -y git"
exec 9>"$(dirname "$ROOT")/.gstack-capy-install.lock"
if command -v flock >/dev/null 2>&1; then flock -w 900 9 || stop INSTALL_PREFLIGHT_FAILED "another gstack-capy-install holds $(dirname "$ROOT")/.gstack-capy-install.lock" "wait for it to finish"; fi
if [ ! -f "$ROOT/setup" ] || [ ! -f "$ROOT/VERSION" ]; then
# The bootstrapper's fast-forward, this install and its check are one
# transaction: with GSTACK_CAPY_LOCK_HELD=1 the caller already holds the lock
# (inherited fd), so taking it again would deadlock.
if [ "${GSTACK_CAPY_LOCK_HELD:-}" != 1 ]; then
exec 9>"$(dirname "$ROOT")/.gstack-capy-install.lock"
if command -v flock >/dev/null 2>&1; then flock -w 900 9 || stop INSTALL_PREFLIGHT_FAILED "another gstack-capy-install holds $(dirname "$ROOT")/.gstack-capy-install.lock" "wait for it to finish"; fi
fi
if [ "$CHECK_ONLY" -eq 1 ] && { [ ! -f "$ROOT/setup" ] || [ ! -f "$ROOT/VERSION" ]; }; then
echo "gstack: fail not-installed — no gstack checkout at $ROOT; fix: $ROOT/bin/gstack-capy-install${FOR:+ --for $FOR} (installs)"
exit 1
elif [ "$CHECK_ONLY" -eq 1 ]; then :
elif [ ! -f "$ROOT/setup" ] || [ ! -f "$ROOT/VERSION" ]; then
[ -e "$ROOT" ] && [ -n "$(ls -A "$ROOT" 2>/dev/null)" ] && stop INSTALL_PREFLIGHT_FAILED "$ROOT exists but is not a gstack checkout" "remove it or pass --root <dir>"
say "cloning $REPO into $ROOT${REVISION:+ at $REVISION}"
if [ -n "$REVISION" ] && git clone --quiet --single-branch --depth 1 --branch "$REVISION" "$REPO" "$ROOT" 2>/dev/null; then :
@@ -69,79 +88,163 @@ else
say "reusing $ROOT ($(head -1 "$ROOT/VERSION")); never reset or pulled"
fi
. "$ROOT/bin/gstack-bun-version.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-bun-version.sh is missing" "re-clone gstack"
. "$ROOT/bin/gstack-install-verdict.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-install-verdict.sh is missing" "re-clone gstack"
# 2. Preflight: tools, then Bun against gstack's minimum and the project's pin.
# 2. Preflight: tools, then Bun against gstack's minimum and the project's pins.
# The launch directory is where the Bun the trailer certifies lives when it is
# not first on PATH; the GSTACK_LAUNCH line carries it to every later command.
LAUNCH_DIR="" WANT="$GSTACK_BUN_TESTED"
BUN_DIR="${BUN_INSTALL:-$HOME/.bun}/bin"
launch_with() { LAUNCH_DIR="$1"; export PATH="$1:$PATH"; }
install_bun() { # REASON — download, verify and install Bun $WANT into $BUN_DIR
say "$1; installing bun $WANT from its release archive (SHA-256 verified)"
case "$(uname -ms)" in
'Linux x86_64') TARGET=linux-x64; grep -q avx2 /proc/cpuinfo 2>/dev/null || TARGET=linux-x64-baseline ;;
'Linux aarch64'|'Linux arm64') TARGET=linux-aarch64 ;;
*) stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $(uname -ms) (this installer is for Capy cloud Ubuntu)" "install Bun $WANT by hand (https://bun.sh), then re-run" ;;
esac
SHA=$(gstack_bun_archive_sha256 "$TARGET" "$WANT") || stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $TARGET" "install Bun $WANT by hand, then re-run"
TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-bun.XXXXXX") || stop INSTALL_PREFLIGHT_FAILED "cannot create a temp dir" "point TMPDIR at a writable directory"
curl --retry 3 -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v$WANT/bun-$TARGET.zip" -o "$TMP/bun.zip" || stop INSTALL_PREFLIGHT_FAILED "could not download bun-v$WANT/bun-$TARGET.zip" "check network access to github.com, then re-run"
echo "$SHA $TMP/bun.zip" | sha256sum -c - >/dev/null 2>&1 || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "bun-$TARGET.zip SHA-256 mismatch (expected $SHA); nothing installed" "update gstack_bun_archive_sha256 in bin/gstack-bun-version.sh only after checking the release's SHASUMS256.txt"; }
mkdir -p "$BUN_DIR" && (cd "$TMP" && unzip -qo bun.zip) && install -m 0755 "$TMP/bun-$TARGET/bun" "$BUN_DIR/bun" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not unpack bun into $BUN_DIR" "install unzip, or set BUN_INSTALL to a writable prefix"; }
rm -rf "$TMP"
LD_BIN="$BUN_DIR/bun" LD_VER=$("$BUN_DIR/bun" --version 2>/dev/null | head -1)
[ "$LD_VER" = "$WANT" ] || stop INSTALL_PREFLIGHT_FAILED "installed bun reports $LD_VER, expected $WANT" "check $BUN_DIR/bun, then re-run"
launch_with "$BUN_DIR"
say "bun $WANT installed at $BUN_DIR/bun (the GSTACK_LAUNCH line puts $BUN_DIR first on PATH)"
}
if [ "$CHECK_ONLY" -eq 1 ]; then
LAUNCH_DIR=$(gstack_bun_launch_dir) && export PATH="$LAUNCH_DIR:$PATH"
else
for tool in curl node jq; do
command -v "$tool" >/dev/null 2>&1 || stop INSTALL_PREFLIGHT_FAILED "preflight: $tool not found" "install $tool (apt-get install -y jq; Node from https://nodejs.org or nvm), then re-run"
done
[ -n "$PROJECT" ] || { PROJECT=$(git rev-parse --show-toplevel 2>/dev/null || true); [ "$(cd "${PROJECT:-/nonexistent}" 2>/dev/null && pwd -P)" = "$ROOT" ] && PROJECT=""; }
PIN="" PIN_SRC=""
if [ -n "$PROJECT" ] && [ -f "$PROJECT/package.json" ]; then
PIN=$(jq -r '.engines.bun // (.packageManager // "" | select(startswith("bun@")) | ltrimstr("bun@")) // empty' "$PROJECT/package.json" 2>/dev/null | head -1)
[ -n "$PIN" ] && PIN_SRC="$PROJECT/package.json"
HAVE_BIN=$(command -v bun 2>/dev/null || true)
HAVE=$([ -n "$HAVE_BIN" ] && bun --version 2>/dev/null | head -1)
HAVE_OK=0; [ "$(gstack_bun_status "${HAVE:-0.0.0}")" = ok ] && HAVE_OK=1
LD_BIN="" LD_VER=""
for _d in "$BUN_DIR" "$HOME/.bun/bin"; do
[ -x "$_d/bun" ] && [ "$_d/bun" != "$HAVE_BIN" ] || continue
_v=$("$_d/bun" --version 2>/dev/null | head -1)
[ "$(gstack_bun_status "${_v:-0.0.0}")" = ok ] && { LD_BIN="$_d/bun" LD_VER="$_v"; break; }
done
# follow_pin VERSION SOURCE — an exact project pin becomes the Bun to install
# when it is at or above gstack's minimum and has a verified archive; anything
# else is a conflict naming both constraints and the verified versions.
follow_pin() {
[ "$1" = "$WANT" ] && return 0
if [ "$WANT" = "$GSTACK_BUN_TESTED" ] && [ "$(gstack_bun_status "$1")" = ok ] && gstack_bun_archive_sha256 linux-x64 "$1" >/dev/null; then WANT="$1"; return 0; fi
stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$GSTACK_BUN_TESTED (verified archives: $GSTACK_BUN_VERIFIED), $2 pins exactly $1" "raise the project's pin to a verified version, or install gstack on a machine the project already provisions"
}
reuse_ld() { launch_with "$(dirname "$LD_BIN")"; say "reusing bun $LD_VER at $LD_BIN (not first on PATH; the GSTACK_LAUNCH line puts it first)"; }
# No Bun at or above the security floor anywhere: install the verified one
# first, so the project's pins can be read with it.
case "$(gstack_bun_status "${HAVE:-0.0.0}")" in too-old|malformed) [ -z "$LD_BIN" ] && install_bun "bun ${HAVE:-absent} cannot read the project's pins" ;; esac
RUNNER="$HAVE_BIN"; case "$(gstack_bun_status "${HAVE:-0.0.0}")" in too-old|malformed) RUNNER="$LD_BIN" ;; esac
PINS_OUT=""
[ -n "$PROJECT" ] && [ -n "$RUNNER" ] && [ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && PINS_OUT=$("$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null)
pins_ok() { "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" 2>/dev/null | grep -Eq '^PINS_VERDICT: (pass|none)$'; }
if printf '%s\n' "$PINS_OUT" | grep -q '^PINS_VERDICT: '; then
# The project's pins, read by the doctor's own parser (lib/runtime-pins.ts):
# engines, packageManager, .tool-versions, .bun-version and CI lanes.
if [ "$HAVE_OK" -eq 1 ] && pins_ok "$HAVE"; then say "bun $HAVE satisfies gstack's supported minimum $WANT and the project pins"
elif [ -n "$LD_BIN" ] && pins_ok "$LD_VER"; then reuse_ld
else
while read -r _c _src; do
[ -z "$_c" ] || follow_pin "$_c" "$PROJECT/${_src%%:*}"
done <<EOF_PINS
$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN: bun \([^ ]*\) exact \([^ ]*\) lane=[a-z]*$/\1 \2/p')
EOF_PINS
pins_ok "$WANT" || stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PROJECT pins $(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_SUMMARY: //p' | grep '^bun' | tr '\n' ';' | sed 's/;$//')" "raise the project's pin to $WANT or newer, or install gstack on a machine the project accepts"
if [ "$LD_VER" = "$WANT" ]; then reuse_ld; else install_bun "bun ${HAVE:-absent} does not satisfy the project pins (bun $WANT) or gstack's minimum $GSTACK_BUN_TESTED"; fi
fi
else
# No pin report (no project, or a Bun that cannot run the parser): the
# package.json / .bun-version subset, read with jq.
PIN="" PIN_SRC="" EXACT=0
if [ -n "$PROJECT" ] && [ -f "$PROJECT/package.json" ]; then
PIN=$(jq -r '.engines.bun // (.packageManager // "" | select(startswith("bun@")) | ltrimstr("bun@")) // empty' "$PROJECT/package.json" 2>/dev/null | head -1)
[ -n "$PIN" ] && PIN_SRC="$PROJECT/package.json"
fi
if [ -z "$PIN" ] && [ -n "$PROJECT" ] && [ -s "$PROJECT/.bun-version" ]; then PIN=$(head -1 "$PROJECT/.bun-version" | tr -d ' v'); PIN_SRC="$PROJECT/.bun-version"; fi
case "$PIN" in
'') ;;
'>='*) _gstack_bun_below "$WANT" "${PIN#>=}" && stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PIN_SRC pins $PIN" "raise the project's pin to $WANT or newer, or install gstack on a machine the project accepts" ;;
[0-9]*.[0-9]*.[0-9]*) follow_pin "$PIN" "$PIN_SRC"; EXACT=1 ;;
*) say "note: project pin '$PIN' ($PIN_SRC) is not evaluated here; gstack-doctor's pins row checks it after install" ;;
esac
if [ "$HAVE_OK" -eq 1 ] && { [ "$EXACT" -eq 0 ] || [ "$HAVE" = "$WANT" ]; }; then say "bun $HAVE satisfies gstack's supported minimum $GSTACK_BUN_TESTED${PIN:+ and the project pin $PIN}"
elif [ -n "$LD_BIN" ] && { [ "$EXACT" -eq 0 ] || [ "$LD_VER" = "$WANT" ]; }; then reuse_ld
elif [ -z "$LAUNCH_DIR" ] || [ "$LD_VER" != "$WANT" ]; then install_bun "bun ${HAVE:-absent} does not satisfy gstack's supported minimum $GSTACK_BUN_TESTED${PIN:+ and the project pin $PIN}"
fi
fi
fi
if [ -z "$PIN" ] && [ -n "$PROJECT" ] && [ -s "$PROJECT/.bun-version" ]; then PIN=$(head -1 "$PROJECT/.bun-version" | tr -d ' v'); PIN_SRC="$PROJECT/.bun-version"; fi
WANT="$GSTACK_BUN_TESTED"
case "$PIN" in
'') ;;
'>='*) _gstack_bun_below "$WANT" "${PIN#>=}" && stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PIN_SRC pins $PIN" "raise the project's pin to $WANT or newer, or install gstack on a machine the project accepts" ;;
[0-9]*.[0-9]*.[0-9]*) [ "$PIN" = "$WANT" ] || stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PIN_SRC pins exactly $PIN" "raise the project's pin to $WANT, or install gstack on a machine the project accepts" ;;
*) say "note: project pin '$PIN' ($PIN_SRC) is not evaluated here; gstack-doctor's pins row checks it after install" ;;
esac
HAVE=$(bun --version 2>/dev/null | head -1 || true)
case "$(gstack_bun_status "${HAVE:-0.0.0}")" in
ok) say "bun $HAVE satisfies gstack's supported minimum $WANT${PIN:+ and the project pin $PIN}" ;;
*)
say "bun ${HAVE:-absent} is below gstack's supported minimum $WANT${PIN:+ (project pin $PIN agrees)}; installing bun $WANT from its release archive (SHA-256 verified)"
case "$(uname -ms)" in
'Linux x86_64') TARGET=linux-x64; grep -q avx2 /proc/cpuinfo 2>/dev/null || TARGET=linux-x64-baseline ;;
'Linux aarch64'|'Linux arm64') TARGET=linux-aarch64 ;;
*) stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $(uname -ms) (this installer is for Capy cloud Ubuntu)" "install Bun $WANT by hand (https://bun.sh), then re-run" ;;
esac
SHA=$(gstack_bun_archive_sha256 "$TARGET") || stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $TARGET" "install Bun $WANT by hand, then re-run"
TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-bun.XXXXXX") || stop INSTALL_PREFLIGHT_FAILED "cannot create a temp dir" "point TMPDIR at a writable directory"
curl --retry 3 -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v$WANT/bun-$TARGET.zip" -o "$TMP/bun.zip" || stop INSTALL_PREFLIGHT_FAILED "could not download bun-v$WANT/bun-$TARGET.zip" "check network access to github.com, then re-run"
echo "$SHA $TMP/bun.zip" | sha256sum -c - >/dev/null 2>&1 || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "bun-$TARGET.zip SHA-256 mismatch (expected $SHA); nothing installed" "update gstack_bun_archive_sha256 in bin/gstack-bun-version.sh only after checking the release's SHASUMS256.txt"; }
BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
mkdir -p "$BUN_INSTALL/bin" && (cd "$TMP" && unzip -qo bun.zip) && install -m 0755 "$TMP/bun-$TARGET/bun" "$BUN_INSTALL/bin/bun" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not unpack bun into $BUN_INSTALL/bin" "install unzip, or set BUN_INSTALL to a writable prefix"; }
rm -rf "$TMP"
export PATH="$BUN_INSTALL/bin:$PATH"
HAVE=$(bun --version 2>/dev/null | head -1)
[ "$HAVE" = "$WANT" ] || stop INSTALL_PREFLIGHT_FAILED "installed bun reports $HAVE, expected $WANT" "check PATH ($BUN_INSTALL/bin must come first), then re-run"
say "bun $WANT installed at $BUN_INSTALL/bin/bun (prepend $BUN_INSTALL/bin to PATH in later shells)" ;;
esac
# 3. Install both host profiles; Chromium lazy unless --with-browser.
# 3. Install both host profiles; Chromium lazy unless --with-browser. Skipped
# when this exact HEAD was set up with the same hosts, model and browser flag
# (recorded in the git dir after a successful setup, never keyed on VERSION)
# and the check passes; never after a fast-forward.
cd "$ROOT" || exit 1
export GSTACK_SKIP_COREUTILS="${GSTACK_SKIP_COREUTILS:-1}" GSTACK_SKIP_ASIDE="${GSTACK_SKIP_ASIDE:-1}"
BROWSER_FLAG="--no-browser"; [ "$WITH_BROWSER" -eq 1 ] && BROWSER_FLAG=""
T1=$(now)
./setup --host claude --no-prefix $BROWSER_FLAG </dev/null || { echo "gstack-capy-install: ./setup --host claude failed (exit $?)" >&2; exit 1; }
./setup --host codex --model gpt-6-astra $BROWSER_FLAG </dev/null || { echo "gstack-capy-install: ./setup --host codex failed (exit $?)" >&2; exit 1; }
T2=$(now)
say "planning-only install: $((T2 - T1))s (setup both hosts; Chromium not downloaded)"
if [ "$WITH_BROWSER" -eq 1 ]; then
T3=$(now)
"$ROOT/bin/gstack-browser-ensure" </dev/null | tail -1
say "browser bootstrap: $(( $(now) - T3 ))s"
else
echo "SKIP browser ($(. "$ROOT/bin/gstack-doctor-components.sh" && printf '%s' "$GSTACK_BROWSER_LAZY_REASON"))"
STATE_ROOT=$("$ROOT/bin/gstack-paths" --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:=$STATE_ROOT}"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
ROOT_REAL=$(pwd -P)
HEAD_SHA=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)
SETUP_STATE="$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null || echo "$ROOT/.git")/gstack-capy-install.state"
SETUP_RECORD="head=$HEAD_SHA hosts=claude,codex model=gpt-6-astra browser=$WITH_BROWSER"
VERDICT=$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL")
if [ -n "$FOR" ]; then set -- --for "$FOR"; else set -- --require "$REQUIRE"; fi
[ "$WITH_BROWSER" -eq 1 ] && [ -z "$FOR" ] && set -- --require "$REQUIRE,browse-bundle,browser"
certify() { OUT=$(cd "$ORIG_PWD" && GSTACK_EPHEMERAL="${GSTACK_EPHEMERAL:-1}" "$ROOT/bin/gstack-doctor" --check "$@" ${PROJECT:+--project "$PROJECT"} ${MIN_REVISION:+--min-revision "$MIN_REVISION"} 2>&1); RC=$?; }
record_verdict() { # TRAILER OK
gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "${FOR:-}" "$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1)" \
"$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0)" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \
|| say "could not write the install verdict $VERDICT"
}
setup_failed() { # MESSAGE
echo "gstack-capy-install: $1" >&2
OUT="" record_verdict "gstack: fail setup" 0
echo "gstack: fail setup — $1; fix: re-run the Capy skill installer or $ROOT/bin/gstack-capy-install (setup is re-run until it succeeds)"
exit 1
}
SKIPPED=0
if [ "$CHECK_ONLY" -eq 0 ] && [ -z "${GSTACK_CAPY_UPGRADED_FROM:-}" ] && [ -n "$HEAD_SHA" ] && [ "$(head -1 "$SETUP_STATE" 2>/dev/null)" = "$SETUP_RECORD" ]; then
certify "$@"
[ "$RC" -eq 0 ] && { SKIPPED=1; say "setup skipped (HEAD ${HEAD_SHA:0:12} already set up for these hosts; the check passes)"; }
fi
if [ "$CHECK_ONLY" -eq 0 ] && [ "$SKIPPED" -eq 0 ]; then
T1=$(now)
./setup --host claude --no-prefix $BROWSER_FLAG </dev/null || setup_failed "./setup --host claude failed (exit $?)"
./setup --host codex --model gpt-6-astra $BROWSER_FLAG </dev/null || setup_failed "./setup --host codex failed (exit $?)"
T2=$(now)
[ -n "$HEAD_SHA" ] && printf '%s\n' "$SETUP_RECORD" > "$SETUP_STATE" 2>/dev/null
say "planning-only install: $((T2 - T1))s (setup both hosts; Chromium not downloaded)"
if [ "$WITH_BROWSER" -eq 1 ]; then
T3=$(now)
"$ROOT/bin/gstack-browser-ensure" </dev/null | tail -1
say "browser bootstrap: $(( $(now) - T3 ))s"
else
echo "SKIP browser ($(. "$ROOT/bin/gstack-doctor-components.sh" && printf '%s' "$GSTACK_BROWSER_LAZY_REASON"))"
fi
certify "$@"
fi
[ "$CHECK_ONLY" -eq 1 ] && certify "$@"
# 4. Certify. The trailer is the contract; `(requested <ref>; ...)` when the
# existing checkout is not at --revision (it is never moved here).
STATE_ROOT=$("$ROOT/bin/gstack-paths" --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:=$STATE_ROOT}"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
INSTALLED=$(head -1 "$ROOT/VERSION")
REV_NOTE=""
if [ -n "$REVISION" ] && [ "$REVISION" != "$INSTALLED" ] && [ "$REVISION" != "v$INSTALLED" ] \
&& [ "$(git -C "$ROOT" rev-parse --verify --quiet "$REVISION^{commit}" 2>/dev/null)" != "$(git -C "$ROOT" rev-parse HEAD 2>/dev/null)" ]; then
REV_NOTE=" (requested $REVISION; run gstack-upgrade to move)"
REV_NOTE=" (requested $REVISION; run $ROOT/bin/gstack-upgrade to move)"
fi
if [ -n "$FOR" ]; then set -- --for "$FOR"; else set -- --require "$REQUIRE"; fi
[ "$WITH_BROWSER" -eq 1 ] && [ -z "$FOR" ] && set -- --require "$REQUIRE,browse-bundle,browser"
OUT=$(cd "$ORIG_PWD" && GSTACK_EPHEMERAL="${GSTACK_EPHEMERAL:-1}" "$ROOT/bin/gstack-doctor" --check "$@" ${PROJECT:+--project "$PROJECT"} 2>&1); RC=$?
TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)$REV_NOTE"
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
printf '%s\n' "$OUT" | grep -v '^gstack: '
say "total: $(( $(now) - T0 ))s"
echo "GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1}"
printf '%s\n' "$OUT" | grep '^gstack: ' | sed "s/\$/$(printf '%s' "$REV_NOTE" | sed 's/[&/\]/\\&/g')/"
echo "GSTACK_LAUNCH: ${LAUNCH_DIR:+PATH=$LAUNCH_DIR:\$PATH }GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1} GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=$VERDICT"
[ -n "$LAUNCH_DIR" ] && echo "GSTACK_LAUNCH_PATH: $LAUNCH_DIR"
printf '%s\n' "$TRAILER"
exit "$RC"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# gstack-capy-session — the full Capy skill wrapper: the checkout-owned half
# of a Capy session start. The frozen bootstrapper the Drive skill holds
# (capy/skill/gstack/scripts/install.sh) clones or fast-forwards this checkout
# and execs this script with its own arguments plus:
# GSTACK_CAPY_WRAPPER_REV / GSTACK_CAPY_WRAPPER_PATH the bootstrapper that ran
# GSTACK_CAPY_REMOTE_MIN the supported minimum from origin's capy/support.env, or `unknown`
# GSTACK_CAPY_UPGRADED_FROM the version it fast-forwarded from (setup is never skipped then)
# GSTACK_CAPY_LOCK_HELD=1 install mode: .gstack-capy-install.lock is held on an
# inherited descriptor; nothing here takes it again
# Because this file moves with the checkout, session-start behavior can change
# without anyone recopying the Drive skill; that includes the stale-copy owner
# action, read from capy/support.env through gstack_support_env_get.
#
# Usage: gstack-capy-session [--check] --root <checkout> [gstack-capy-install arguments]
# --check certify only (bin/gstack-capy-install --check: the doctor with the
# launch PATH and the remote minimum, the verdict, the launch line)
# default install or repair (bin/gstack-capy-install)
# The last line is the `gstack:` trailer; the exit status is the installer's.
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
prev=""
for a in "$@"; do [ "$prev" = --root ] && ROOT="$a"; prev="$a"; done
INSTALLER="$ROOT/bin/gstack-capy-install"
if [ ! -x "$INSTALLER" ]; then
echo "gstack: fail installer — $INSTALLER is missing; fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh} (re-clones or fast-forwards)"
exit 1
fi
[ "${GSTACK_CAPY_REMOTE_MIN:-}" = unknown ] && echo "gstack-capy-session: remote minimum unknown (fetch failed); the revision row is local only"
# shellcheck source=bin/gstack-install-verdict.sh
. "$ROOT/bin/gstack-install-verdict.sh"
MIN_WRAPPER=$(gstack_support_env_get "$(gstack_source_root "$ROOT/bin")" GSTACK_CAPY_MIN_WRAPPER)
if [ -n "$MIN_WRAPPER" ] && [ "${GSTACK_CAPY_WRAPPER_REV:-0}" -lt "$MIN_WRAPPER" ] 2>/dev/null; then
echo "gstack-capy-skill: owner action — Drive copy is wrapper ${GSTACK_CAPY_WRAPPER_REV:-0}, minimum $MIN_WRAPPER; copy $ROOT/capy/skill/gstack/ (CAPY_WRAPPER_STALE)"
fi
exec "$INSTALLER" "$@"
+4 -2
View File
@@ -5,7 +5,7 @@
# gstack-doctor print one row per check; spends nothing
# gstack-doctor --live also run the paid Codex model probe (one short call)
# gstack-doctor --check [--for <skill> | --require <id,id>] [--project <path>]
# [--revision <ref>] [--json] [--live]
# [--revision <ref>] [--min-revision <v>|unknown] [--json] [--live]
# one PASS | FAIL | SKIP row per component, then one
# trailer: `gstack: ok <revision> for=<what>` (exit 0)
# or `gstack: fail <id,id>` (exit 1). --for names the
@@ -39,7 +39,7 @@ usage() {
sed -n '3,13p' "$0" | sed 's/^# \{0,1\}//'
}
LIVE=0 CHECK=0 CHECK_JSON=0 CHECK_FOR="" CHECK_REQUIRE="" CHECK_PROJECT="" CHECK_REVISION=""
LIVE=0 CHECK=0 CHECK_JSON=0 CHECK_FOR="" CHECK_REQUIRE="" CHECK_PROJECT="" CHECK_REVISION="" CHECK_MIN_REVISION=""
_need_value() { [ -n "${2:-}" ] || { echo "gstack-doctor: $1 needs a value" >&2; usage >&2; exit 2; }; }
while [ $# -gt 0 ]; do
case "$1" in
@@ -54,6 +54,8 @@ while [ $# -gt 0 ]; do
--project=*) CHECK=1; CHECK_PROJECT="${1#--project=}" ;;
--revision) _need_value "$1" "${2:-}"; CHECK=1; CHECK_REVISION="$2"; shift ;;
--revision=*) CHECK=1; CHECK_REVISION="${1#--revision=}" ;;
--min-revision) _need_value "$1" "${2:-}"; CHECK=1; CHECK_MIN_REVISION="$2"; shift ;;
--min-revision=*) CHECK=1; CHECK_MIN_REVISION="${1#--min-revision=}" ;;
-h|--help) usage; exit 0 ;;
*) echo "gstack-doctor: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
esac
+73 -11
View File
@@ -14,7 +14,10 @@
# Codex model probe runs only with --live.
#
# Inputs (set by bin/gstack-doctor): CHECK_FOR (skill), CHECK_REQUIRE (csv),
# CHECK_JSON (0|1), CHECK_PROJECT (path or ""), CHECK_REVISION (ref or ""), LIVE.
# CHECK_JSON (0|1), CHECK_PROJECT (path or ""), CHECK_REVISION (ref or ""),
# CHECK_MIN_REVISION (the supported minimum the Capy wrapper read from the
# remote's capy/support.env, `unknown` when its fetch failed, or ""), LIVE.
# GSTACK_CAPY_WRAPPER_PATH (optional) names the wrapper a below-minimum fix runs.
. "$BIN/gstack-doctor-components.sh" || { echo "gstack-doctor: $BIN/gstack-doctor-components.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
. "$BIN/gstack-install-registry.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-install-registry.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
@@ -230,19 +233,32 @@ _dc_check_cso() {
_dc_fail_or_skip cso "native helper not built${reason:+ ($reason)}" "cd $SRC && ./setup (it names any missing build prerequisite)" COMPONENT_MISSING "optional; toolchain absent or helper not built${reason:+: $reason}"
}
# _dc_launch_note — when a supported Bun sits off PATH (gstack-capy-install
# leaves one in $BUN_INSTALL/bin), sets _dc_ln_dir/_dc_ln_ver/_dc_ln_note and
# _dc_ln_fix so a fresh shell's FAIL says where it is and how to launch with it.
_dc_launch_note() {
_dc_ln_dir=$(gstack_bun_launch_dir 2>/dev/null) || _dc_ln_dir=""
_dc_ln_ver="" _dc_ln_note="" _dc_ln_fix=""
[ -n "$_dc_ln_dir" ] || return 1
_dc_ln_ver=$("$_dc_ln_dir/bun" --version </dev/null 2>/dev/null | clamp 'A-Za-z0-9.+-' 40)
_dc_ln_note="; verified bun $_dc_ln_ver found at $_dc_ln_dir (not on PATH)"
_dc_ln_fix="export PATH=\"$_dc_ln_dir:\$PATH\" or prefix commands with the GSTACK_LAUNCH line"
}
_dc_check_runtime() {
local where found fix="install Bun $GSTACK_BUN_TESTED with the checksum-verified recipe in $SRC/bin/gstack-capy-install (or: bun upgrade), then cd $SRC && ./setup"
_dc_launch_note && fix="$_dc_ln_fix"
if ! where=$(command -v bun 2>/dev/null); then
_dc_row FAIL runtime "bun not found on PATH (gstack needs $GSTACK_BUN_TESTED; floor $GSTACK_BUN_FLOOR)" "$fix" RUNTIME_BELOW_MINIMUM
_dc_row FAIL runtime "bun not found on PATH (gstack needs $GSTACK_BUN_TESTED; floor $GSTACK_BUN_FLOOR)$_dc_ln_note" "$fix" RUNTIME_BELOW_MINIMUM
return
fi
found=$(bun --version </dev/null 2>/dev/null | clamp 'A-Za-z0-9.+-' 40)
_dc_bun="$found"
case "$(gstack_bun_status "$found")" in
ok) _dc_row PASS runtime "bun $found at $where (floor $GSTACK_BUN_FLOOR, supported minimum $GSTACK_BUN_TESTED)" ;;
untested) _dc_row FAIL runtime "bun $found at $where is below the supported minimum $GSTACK_BUN_TESTED (above the security floor $GSTACK_BUN_FLOOR)" "$fix" RUNTIME_BELOW_MINIMUM ;;
too-old) _dc_row FAIL runtime "bun $found at $where is below the security floor $GSTACK_BUN_FLOOR (supported minimum $GSTACK_BUN_TESTED)" "$fix" RUNTIME_BELOW_MINIMUM ;;
*) _dc_row FAIL runtime "could not read the version of $where" "$fix" RUNTIME_BELOW_MINIMUM ;;
untested) _dc_row FAIL runtime "bun $found at $where is below the supported minimum $GSTACK_BUN_TESTED (above the security floor $GSTACK_BUN_FLOOR)$_dc_ln_note" "$fix" RUNTIME_BELOW_MINIMUM ;;
too-old) _dc_row FAIL runtime "bun $found at $where is below the security floor $GSTACK_BUN_FLOOR (supported minimum $GSTACK_BUN_TESTED)$_dc_ln_note" "$fix" RUNTIME_BELOW_MINIMUM ;;
*) _dc_row FAIL runtime "could not read the version of $where$_dc_ln_note" "$fix" RUNTIME_BELOW_MINIMUM ;;
esac
}
@@ -275,7 +291,17 @@ _dc_check_pins() {
pass) _dc_row PASS pins "$project: $summary" ;;
none) _dc_row PASS pins "$project pins no runtime (no engines, .tool-versions, .nvmrc, .bun-version or CI pin)" ;;
fail) failline=$(printf '%s\n' "$out" | sed -n 's/^PIN_FAIL: //p' | tr '\n' '|' | sed 's/|$//; s/|/; /g')
_dc_row FAIL pins "$failline" "install the pinned version for this platform (bin/gstack-capy-install upgrades Bun when gstack and the project agree), or change the pin in $project" PROJECT_PIN_MISMATCH ;;
if [ -n "$CHECK_FOR" ] && gstack_doctor_skill_pins_advisory "$CHECK_FOR"; then
_dc_row SKIP pins "planning-only; repo pins differ: $failline, launch bun ${_dc_bun:-unknown}"
_dc_pins_advisory=1
return
fi
local fix="install the pinned version for this platform (bin/gstack-capy-install installs gstack's verified Bun alongside when a pin names it), or change the pin in $project" note=""
if [ -n "$_dc_ln_dir" ] && [ "$_dc_ln_ver" != "$_dc_bun" ] \
&& bun "$BIN/gstack-runtime-pins.ts" --project "$project" --bun "$_dc_ln_ver" ${node:+--node "$node"} </dev/null 2>/dev/null | grep -q '^PINS_VERDICT: pass'; then
note="; bun $_dc_ln_ver found at $_dc_ln_dir (not on PATH) satisfies them" fix="$_dc_ln_fix"
fi
_dc_row FAIL pins "$failline$note" "$fix" PROJECT_PIN_MISMATCH ;;
*) _dc_row SKIP pins "gstack-runtime-pins could not evaluate $project (exit $rc: $(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | head -1 | clamp ' -~' 120))" ;;
esac
}
@@ -317,25 +343,61 @@ _dc_check_cores() {
fi
}
# _dc_version_below A B — true when dotted numeric version A sorts below B.
_dc_version_below() {
local a="$1." b="$2." x y
while [ -n "$a" ] || [ -n "$b" ]; do
x="${a%%.*}" y="${b%%.*}"; a="${a#*.}" b="${b#*.}"
case "$x" in ''|*[!0-9]*) x=0 ;; esac
case "$y" in ''|*[!0-9]*) y=0 ;; esac
[ "$((10#$x))" -lt "$((10#$y))" ] && return 0
[ "$((10#$x))" -gt "$((10#$y))" ] && return 1
done
return 1
}
# _dc_check_min_revision INSTALLED HEAD — sets the PASS-row suffix _dc_min_note,
# or prints the REVISION_BELOW_MINIMUM FAIL row for --min-revision and returns 1.
# Returns 1 after printing a FAIL row.
_dc_check_min_revision() {
local installed="$1" head="$2" fix
_dc_min_note=""
case "$CHECK_MIN_REVISION" in
'') return 0 ;;
unknown*) _dc_min_note=" (remote minimum unknown (fetch failed); not compared)"; return 0 ;;
esac
if _dc_version_below "$installed" "$CHECK_MIN_REVISION"; then
if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then
fix="bash $GSTACK_CAPY_WRAPPER_PATH${CHECK_FOR:+ --for $CHECK_FOR} (fast-forwards), or $SRC/bin/gstack-upgrade"
else
fix="$SRC/bin/gstack-upgrade (fast-forwards to the remote default branch, then reinstalls)"
fi
_dc_row FAIL revision "installed $installed${head:+ at $head} is below the supported minimum $CHECK_MIN_REVISION" "$fix" REVISION_BELOW_MINIMUM
return 1
fi
_dc_min_note=" (>= supported minimum $CHECK_MIN_REVISION)"
}
_dc_check_revision() {
local installed head="" want="$CHECK_REVISION" target=""
installed=$(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)
head=$(git -C "$ROOT_REAL" rev-parse --short HEAD 2>/dev/null | clamp '0-9a-f' 12)
_dc_revision="${installed:-unknown}"
_dc_check_min_revision "${installed:-0}" "$head" || return
if [ -z "$want" ]; then
_dc_row PASS revision "${installed:-unknown}${head:+ ($head)}"
_dc_row PASS revision "${installed:-unknown}${head:+ ($head)}$_dc_min_note"
return
fi
if [ "$want" = "$installed" ] || [ "$want" = "v$installed" ]; then
_dc_row PASS revision "$installed${head:+ ($head)} matches --revision $want"
_dc_row PASS revision "$installed${head:+ ($head)} matches --revision $want$_dc_min_note"
return
fi
target=$(git -C "$ROOT_REAL" rev-parse --verify --quiet "$want^{commit}" 2>/dev/null | clamp '0-9a-f' 40)
if [ -n "$target" ] && [ "$target" = "$(git -C "$ROOT_REAL" rev-parse HEAD 2>/dev/null)" ]; then
_dc_row PASS revision "$installed ($head) is --revision $want"
_dc_row PASS revision "$installed ($head) is --revision $want$_dc_min_note"
return
fi
_dc_row FAIL revision "installed ${installed:-unknown}${head:+ at $head} (requested $want)" "cd $SRC && git fetch origin && git checkout $want && ./setup, or run /gstack-upgrade" REVISION_UNMET
_dc_row FAIL revision "installed ${installed:-unknown}${head:+ at $head} (requested $want)" "cd $SRC && git fetch origin && git checkout $want && ./setup, or run $SRC/bin/gstack-upgrade to move to the latest release (interactive: /gstack-upgrade)" REVISION_UNMET
}
_dc_print_json() {
@@ -373,7 +435,7 @@ ROWS
# gstack_doctor_check — the entry point; returns 0 (ok) or 1 (fail).
gstack_doctor_check() {
local id
_dc_bun="" _dc_revision=""
_dc_bun="" _dc_revision="" _dc_pins_advisory=0 _dc_ln_dir="" _dc_ln_ver="" _dc_ln_fix=""
_dc_resolve_required
[ "$CHECK_JSON" -eq 1 ] || echo "required: $_dc_required"
for id in $GSTACK_DOCTOR_COMPONENTS; do
+13
View File
@@ -75,6 +75,14 @@ gstack_doctor_skill_optional() {
esac
}
# gstack_doctor_skill_pins_advisory SKILL — true for a planning-only workflow (a repo test-pin mismatch SKIPs pins).
gstack_doctor_skill_pins_advisory() {
case "$1" in
autoplan|plan-ceo-review|plan-eng-review|plan-design-review|plan-devex-review|office-hours) return 0 ;;
*) return 1 ;;
esac
}
# gstack_result_anchor CODE — the docs/troubleshooting.md anchor for a lib/result-codes.ts code; returns 1 for an unknown code.
gstack_result_anchor() {
case "$1" in
@@ -166,6 +174,11 @@ gstack_result_anchor() {
CONTRIBUTOR_SOURCE_UNAVAILABLE) printf '%s' 'contributor-source-unavailable' ;;
BRIEF_INPUT_MISSING) printf '%s' 'brief-input-missing' ;;
DRAFT_DIRECTION_UNRESOLVED) printf '%s' 'draft-direction-unresolved' ;;
UPGRADE_REFUSED) printf '%s' 'upgrade-refused' ;;
UPGRADE_FETCH_FAILED) printf '%s' 'upgrade-fetch-failed' ;;
REVISION_BELOW_MINIMUM) printf '%s' 'revision-below-minimum' ;;
CAPY_WRAPPER_STALE) printf '%s' 'capy-wrapper-stale' ;;
INSTALL_UNVERIFIED) printf '%s' 'install-unverified' ;;
*) return 1 ;;
esac
}
+135
View File
@@ -0,0 +1,135 @@
# shellcheck shell=bash
# gstack-install-verdict.sh — the install verdict and install admission, Bash
# side (sourced, never executed). lib/install-verdict.ts is the TypeScript
# twin gstack-autoplan's mutating verbs call; test/install-verdict.test.ts
# pins both. Bash 3.2 plus POSIX tools (sha256sum or shasum).
#
# The verdict is what the last install check certified on this machine:
# <state root>/install-verdicts/<sha256 of the source checkout realpath>.json
# written atomically by bin/gstack-capy-install (install and --check) with
# schema_version, checkout, head, for, required[], pins_planning_only, ok,
# trailer, min_revision, launch_path, time
# and named in the GSTACK_LAUNCH line as GSTACK_INSTALL_VERDICT=<path>.
#
# Admission (gstack-skill-start, after SESSION_KIND): an unattended session,
# or an explicit GSTACK_SESSION_KIND=spawned session with GSTACK_CAPY=1,
# refuses when the Bun first on PATH is below gstack's supported minimum and,
# with GSTACK_CAPY=1 only, when the verdict is missing, failing, for another
# checkout or HEAD, or narrower than the workflow (a component it needs, or a
# planning-only pins SKIP for a workflow that runs the repo's tests). Other
# spawned sessions (ambient OpenClaw) only warn. GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1
# admits anyway and says so.
# gstack_source_root BIN_DIR — the source checkout behind an install: an
# installed runtime root's recorded .source-path (setup _link_runtime_dists),
# else BIN_DIR/.. resolved. Prints the realpath; returns 1 when unresolvable.
gstack_source_root() {
local up src=""
up=$(cd "$1/.." 2>/dev/null && pwd -L) || return 1
[ -f "$up/.source-path" ] && src=$(head -1 "$up/.source-path" 2>/dev/null)
[ -n "$src" ] && [ -d "$src" ] || src="$up"
(cd "$src" 2>/dev/null && pwd -P)
}
# gstack_support_env_get ROOT KEY — a dotted-number value from
# ROOT/capy/support.env (parsed, never sourced); empty when absent.
gstack_support_env_get() {
sed -n "s/^$2=\([0-9][0-9.]*\)[[:space:]]*\$/\1/p" "$1/capy/support.env" 2>/dev/null | head -1
}
_gstack_iv_sha256() {
if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -d' ' -f1
else printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1; fi
}
# gstack_install_verdict_path STATE_ROOT CHECKOUT_REALPATH
gstack_install_verdict_path() { printf '%s/install-verdicts/%s.json\n' "$1" "$(_gstack_iv_sha256 "$2")"; }
_gstack_iv_json() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\000-\037')"; }
# gstack_install_verdict_write FILE CHECKOUT HEAD FOR REQUIRED(space list) PINS_PLANNING_ONLY(0|1) OK(0|1) TRAILER MIN LAUNCH_PATH
gstack_install_verdict_write() {
local file="$1" tmp id req="" first=1
for id in $5; do
[ "$first" -eq 1 ] || req="$req, "
first=0
req="$req$(_gstack_iv_json "$id")"
done
mkdir -p "$(dirname "$file")" 2>/dev/null || return 1
tmp="$file.tmp.$$"
{
printf '{\n "schema_version": 1,\n "checkout": %s,\n "head": %s,\n "for": %s,\n' "$(_gstack_iv_json "$2")" "$(_gstack_iv_json "$3")" "$(_gstack_iv_json "$4")"
printf ' "required": [%s],\n "pins_planning_only": %s,\n "ok": %s,\n' "$req" "$([ "$6" = 1 ] && echo true || echo false)" "$([ "$7" = 1 ] && echo true || echo false)"
printf ' "trailer": %s,\n "min_revision": %s,\n "launch_path": %s,\n "time": %s\n}\n' "$(_gstack_iv_json "$8")" "$(_gstack_iv_json "$9")" "$(_gstack_iv_json "${10}")" "$(_gstack_iv_json "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
} > "$tmp" && mv -f "$tmp" "$file"
}
# gstack_install_verdict_field FILE KEY — a string, boolean or number value;
# for an array, its items space-separated.
gstack_install_verdict_field() {
local flat
flat=$(tr '\n' ' ' < "$1" 2>/dev/null) || return 1
case "$(printf '%s' "$flat" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\(.\).*/\1/p")" in
'"') printf '%s' "$flat" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" ;;
'[') printf '%s' "$flat" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p" | tr -d '",' | tr -s ' ' | sed 's/^ //; s/ $//' ;;
*) printf '%s' "$flat" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([A-Za-z0-9.]*\).*/\1/p" ;;
esac
}
# gstack_install_admission BIN_DIR SKILL KIND — sets _ia_applies (0|1),
# _ia_refuse (0|1), _ia_warn (0|1), _ia_status (ok|runtime|verdict),
# _ia_line (the INSTALL: or INSTALL_VERDICT: line to print, may be empty).
# Needs gstack-bun-version.sh and gstack-doctor-components.sh sourced.
gstack_install_admission() {
local bin="$1" skill="$2" kind="$3" hard=0 capy=0 src where found detail="" fix="" code="" file trailer v_head head id
_ia_applies=0 _ia_refuse=0 _ia_warn=0 _ia_status=ok _ia_line=""
case "$kind" in unattended|spawned) _ia_applies=1 ;; *) return 0 ;; esac
[ "${GSTACK_CAPY:-}" = 1 ] && capy=1
{ [ "$kind" = unattended ] || { [ "${GSTACK_SESSION_KIND:-}" = spawned ] && [ "$capy" -eq 1 ]; }; } && hard=1
where=$(command -v bun 2>/dev/null || true)
found=$([ -n "$where" ] && bun --version </dev/null 2>/dev/null | head -1 | tr -cd 'A-Za-z0-9.+-')
if [ "$(gstack_bun_status "${found:-0.0.0}")" != ok ]; then
_ia_status=runtime code=RUNTIME_BELOW_MINIMUM
if [ -n "$where" ]; then detail="bun $found at $where is below the supported minimum $GSTACK_BUN_TESTED"; else detail="bun not found on PATH (gstack needs $GSTACK_BUN_TESTED)"; fi
file=$(gstack_bun_launch_dir 2>/dev/null) || file=""
if [ -n "$file" ]; then fix="launch with the GSTACK_LAUNCH line (PATH=$file:\$PATH)"
else fix="run the Capy skill installer (scripts/install.sh --for $skill), which installs the verified Bun $GSTACK_BUN_TESTED, then launch with its GSTACK_LAUNCH line"; fi
elif [ "$capy" -eq 1 ]; then
src=$(gstack_source_root "$bin") || src=""
file="${GSTACK_INSTALL_VERDICT:-}"
[ -n "$file" ] || file=$(gstack_install_verdict_path "${_GH:-$HOME/.gstack}" "$src")
code=INSTALL_UNVERIFIED
fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints"
if [ ! -f "$file" ]; then
detail="no install verdict at $file for $src"
else
trailer=$(gstack_install_verdict_field "$file" trailer)
v_head=$(gstack_install_verdict_field "$file" head)
head=$(git -C "$src" rev-parse HEAD 2>/dev/null)
if [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then
detail="the install verdict says: $trailer"
elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then
detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src"
elif [ -z "$head" ] || [ "$v_head" != "$head" ]; then
detail="the install verdict is for another HEAD (${v_head:0:12}; $src is at ${head:0:12})"
elif [ "$(gstack_install_verdict_field "$file" pins_planning_only)" = true ] && ! gstack_doctor_skill_pins_advisory "$skill"; then
detail="the install verdict is planning-only (pins skipped for $(gstack_install_verdict_field "$file" for)) and $skill runs the repo's tests"
else
for id in $(gstack_doctor_skill_requires "$skill" 2>/dev/null); do
case " $(gstack_install_verdict_field "$file" required) " in *" $id "*) ;; *) detail="the install verdict certified for=$(gstack_install_verdict_field "$file" for), which does not cover $id that $skill needs"; break ;; esac
done
fi
[ -n "$detail" ] || { _ia_line="INSTALL_VERDICT: ok $trailer"; return 0; }
fi
_ia_status=verdict
else
return 0
fi
if [ "${GSTACK_ALLOW_UNSUPPORTED_RUNTIME:-}" = 1 ]; then
_ia_line="INSTALL_VERDICT: overridden (GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1): $detail"
elif [ "$hard" -eq 1 ]; then
_ia_refuse=1 _ia_line="INSTALL: fail $_ia_status — $detail; fix: $fix ($code)"
else
_ia_warn=1 _ia_line="INSTALL: warn $_ia_status — $detail; fix: $fix ($code)"
fi
}
+2 -1
View File
@@ -43,7 +43,8 @@ if [ "$_SESSION_KIND" = "unattended" ]; then
_TEL="off"
_CACHED_UPD=$(head -1 "$_GH/last-update-check" 2>/dev/null | tr -cd 'A-Za-z0-9._ ' || true)
case "$_CACHED_UPD" in
UPGRADE_AVAILABLE*) echo "UPGRADE_AVAILABLE: ${_CACHED_UPD##* }; run /gstack-upgrade" ;;
UPGRADE_AVAILABLE*) echo "UPGRADE_AVAILABLE: ${_CACHED_UPD##* }; run /gstack-upgrade"
echo "UPGRADE_COMMAND: $(cd "$_BIN/.." 2>/dev/null && pwd -P)/bin/gstack-upgrade" ;;
esac
echo "ARTIFACTS_SYNC: skipped (unattended session)"
fi
+19
View File
@@ -147,6 +147,25 @@ echo "REPO_MODE: $REPO_MODE"
echo "SESSION_KIND: $_SESSION_KIND$_SESSION_KIND_NOTE"
echo "STATE_ROOT: $_GH durable=$_DURABLE"
echo "SESSIONS: ${_SESSIONS:-0}"
# Install admission (field wave A2): an unattended or Capy-spawned run on an
# unsupported Bun, or on a Capy machine without a matching install verdict,
# refuses here instead of running anyway. Rules: bin/gstack-install-verdict.sh.
if . "$_BIN/gstack-install-verdict.sh" 2>/dev/null && . "$_BIN/gstack-bun-version.sh" 2>/dev/null && . "$_BIN/gstack-doctor-components.sh" 2>/dev/null; then
gstack_install_admission "$_BIN" "$SKILL_NAME" "$_SESSION_KIND"
[ -n "$_ia_line" ] && echo "$_ia_line"
if [ "$_ia_refuse" = 1 ]; then
# The preamble already honors instruction blocks bound to this run's
# SESSION_ID, so the stop rule rides here instead of growing every skill.
_ia_sid="$PARENT_PID-$(date +%s)-$(od -An -N4 -tx4 /dev/urandom 2>/dev/null | tr -d ' \n')"
_ia_result="GSTACK_RESULT: skill=$(printf '%s' "$SKILL_NAME" | tr -cd 'a-zA-Z0-9._-') status=refused run=-"
echo "SESSION_ID: $_ia_sid"
echo "GSTACK_INSTRUCTION_BEGIN: install-refused $_ia_sid"
echo "This workflow refused to start (the INSTALL: row above). STOP: report that row and its fix: command to the parent, run nothing else from this skill, and end with \`$_ia_result\`."
echo "GSTACK_INSTRUCTION_END"
echo "$_ia_result"
exit 3
fi
fi
# Tamper visibility (#2733 review): a repo-provided settings env block or a CI
# wrapper could set GSTACK_SESSION_KIND or OPENCLAW_SESSION for a real human's
# session, silently flipping every gate to auto-choose. Surface the env-driven
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# gstack-upgrade — move this gstack checkout to the tip of its remote default
# branch, unattended and fast-forward only. The deterministic twin of the
# /gstack-upgrade skill's git fast path, for parent agents and fix lines.
#
# Usage:
# gstack-upgrade --check fetch (refs only) and print `gstack-upgrade: current <ver>`
# (exit 0) or `gstack-upgrade: behind <installed> -> <remote>
# (<n> commits)` (exit 1); changes nothing else
# gstack-upgrade fast-forward, then reinstall: bin/gstack-capy-install on a
# Capy install (the marker it writes in the git dir),
# else ./setup --refresh-registered (setup runs migrations)
#
# Refuses with exit 3 and changes nothing (UPGRADE_REFUSED) when the directory
# is not a gstack checkout, HEAD is detached or not on the remote default
# branch (a pin), any tracked byte is dirty, a local commit diverged, the
# incoming release's Bun floor holds, or another updater holds the setup
# lock; never stashes, never resets. An unreachable remote is
# UPGRADE_FETCH_FAILED (exit 3). Prints `gstack-upgrade: upgraded <old> -> <new>`
# before reinstalling; a failed reinstall exits 1 and the next run completes it.
# Bash 3.2 plus git; the fetch is bounded by `timeout` when present.
set -u
BASH_COMPAT=50
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
CHECK=0
case "${1:-}" in
--check) CHECK=1 ;;
'') ;;
-h|--help) sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;;
esac
refuse() { echo "gstack-upgrade: $1; nothing was changed; fix: $2 (UPGRADE_REFUSED)" >&2; exit 3; }
fetch_failed() { echo "gstack-upgrade: could not reach origin ($1); fix: check network access to $(git -C "$ROOT" remote get-url origin 2>/dev/null || echo origin), then re-run $ROOT/bin/gstack-upgrade (UPGRADE_FETCH_FAILED)" >&2; exit 3; }
bounded() { if command -v timeout >/dev/null 2>&1; then timeout "${GSTACK_UPGRADE_FETCH_TIMEOUT:-60}" "$@"; else "$@"; fi; }
# Egress receipts (_receipted_git), fail-open like the session-start updater:
# a user-run upgrade must not die over an audit-log hiccup.
# shellcheck source=bin/gstack-egress-lib.sh
. "$ROOT/bin/gstack-egress-lib.sh" 2>/dev/null || _receipted_git() { shift 5; "$@"; }
[ -f "$ROOT/VERSION" ] && [ -f "$ROOT/setup" ] && [ "$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null)" = "$ROOT" ] \
|| refuse "$ROOT is not a gstack checkout" "run gstack-upgrade from a git checkout of https://github.com/garrytan/gstack"
export GIT_TERMINAL_PROMPT=0
HOST=$(git -C "$ROOT" remote get-url origin 2>/dev/null); HOST="${HOST#*://}"; HOST="${HOST#*@}"; HOST="${HOST%%[/:]*}"
R="user ran gstack-upgrade"
INSTALLED=$(head -1 "$ROOT/VERSION")
BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD) \
|| refuse "HEAD is detached at $(git -C "$ROOT" rev-parse --short HEAD) (a pinned revision)" "git -C $ROOT checkout <default branch>, then re-run"
DEFAULT=$(_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" ls-remote --symref origin HEAD 2>/dev/null | sed -n 's|^ref: refs/heads/\([^[:space:]]*\)[[:space:]]*HEAD$|\1|p' | head -1)
[ -n "$DEFAULT" ] || fetch_failed "git ls-remote origin HEAD failed"
[ "$BRANCH" = "$DEFAULT" ] || refuse "on branch $BRANCH, not the remote default branch $DEFAULT (a pin)" "git -C $ROOT checkout $DEFAULT, then re-run"
_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" fetch --quiet origin "$DEFAULT" 2>/dev/null || fetch_failed "git fetch origin $DEFAULT failed"
INCOMING=$(git -C "$ROOT" rev-parse FETCH_HEAD)
REMOTE_VER=$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1)
PENDING="$(git -C "$ROOT" rev-parse --absolute-git-dir)/gstack-upgrade.pending"
reinstall() { # FROM — run the install step; a pending marker survives a failure
printf '%s\n' "$1" > "$PENDING"
cd "$ROOT" || exit 1
if [ -f "$(git rev-parse --absolute-git-dir 2>/dev/null)/gstack-capy-install.state" ] && [ -x "$ROOT/bin/gstack-capy-install" ]; then
GSTACK_CAPY_UPGRADED_FROM="$1" "$ROOT/bin/gstack-capy-install" </dev/null
else
./setup --refresh-registered </dev/null
fi
_rc=$?
[ "$_rc" -eq 0 ] && { rm -f "$PENDING"; exit 0; }
echo "gstack-upgrade: the checkout is at $(head -1 "$ROOT/VERSION") but its reinstall failed (exit $_rc); fix: re-run $ROOT/bin/gstack-upgrade (it completes the pending reinstall)" >&2
exit 1
}
if [ "$INCOMING" = "$(git -C "$ROOT" rev-parse HEAD)" ] || git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null; then
if [ -f "$PENDING" ]; then
echo "gstack-upgrade: current $INSTALLED; reinstall pending from $(head -1 "$PENDING")"
[ "$CHECK" -eq 1 ] && exit 1
reinstall "$(head -1 "$PENDING")"
fi
echo "gstack-upgrade: current $INSTALLED"
exit 0
fi
if [ "$CHECK" -eq 1 ]; then
echo "gstack-upgrade: behind $INSTALLED -> ${REMOTE_VER:-unknown} ($(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?') commits)"
exit 1
fi
DIRTY=$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null | cut -c4- | tr '\n' ' ' | sed 's/ $//')
[ -z "$DIRTY" ] || refuse "tracked files changed: $DIRTY" "commit or move those changes (git -C $ROOT status --short), then re-run"
if ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then
_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" git -C "$ROOT" fetch --quiet --unshallow origin "$DEFAULT" 2>/dev/null || true
git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null \
|| refuse "diverged: $(git -C "$ROOT" rev-list --count FETCH_HEAD..HEAD 2>/dev/null || echo '?') local commits are not on origin/$DEFAULT" "git -C $ROOT log --oneline FETCH_HEAD..HEAD shows them; move them to another branch, then re-run"
fi
# shellcheck source=bin/gstack-bun-version.sh
. "$ROOT/bin/gstack-bun-version.sh" 2>/dev/null && HOLD=$(gstack_bun_incoming_hold "$ROOT" "$INCOMING") \
&& refuse "the incoming release holds: $HOLD" "install Bun $(git -C "$ROOT" show "$INCOMING:bin/gstack-bun-version.sh" 2>/dev/null | sed -n 's/^GSTACK_BUN_TESTED="\(.*\)"/\1/p') (bin/gstack-capy-install on Capy), then re-run"
# The same lock directory the session-start auto-updater (bin/gstack-session-update)
# holds while it pulls and runs setup; a live holder means refuse, never wait.
GSTACK_STATE_ROOT=$("$ROOT/bin/gstack-paths" --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
LOCK_DIR="$GSTACK_STATE_ROOT/.setup-lock"
mkdir -p "$GSTACK_STATE_ROOT" 2>/dev/null
mkdir "$LOCK_DIR" 2>/dev/null || refuse "another gstack updater holds $LOCK_DIR (pid $(cat "$LOCK_DIR/pid" 2>/dev/null || echo unknown))" "wait for it to finish, then re-run"
echo "$$" > "$LOCK_DIR/pid"
trap '[ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$$" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT
git -C "$ROOT" merge --ff-only -q FETCH_HEAD 2>/dev/null || refuse "git merge --ff-only FETCH_HEAD failed" "git -C $ROOT status, then re-run"
NEW=$(head -1 "$ROOT/VERSION")
echo "gstack-upgrade: upgraded $INSTALLED -> $NEW"
rm -rf "$LOCK_DIR"; trap - EXIT
reinstall "$INSTALLED"
+55
View File
@@ -0,0 +1,55 @@
---
name: gstack
description: Install and use gstack in Capy and Claude Code. Load at session start to check this machine, and for gstack planning, code review, debugging, QA, design, security, documentation, and shipping workflows.
---
# gstack for Capy and Claude Code
## Session start: check, then install only when needed
Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper <n> (<path>)` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`).
1. At session start run `bash scripts/install.sh --check`, adding `--for <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP <component>` row per component, a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>` (exit 0) or `gstack: fail <ids> …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout.
2. If the trailer is `gstack: fail revision …` or `gstack: fail runtime …` and a checkout exists, run the same command without `--check` (900-second timeout). A clean, unpinned checkout below the supported minimum is fast-forwarded (`gstack-capy-skill: upgraded <old> -> <new>`), reinstalled and rechecked in one locked step; about 1 s when nothing changed. A pinned (`--revision`, detached), dirty or diverged checkout is never moved: the trailer names the cause and the exact commands. `GSTACK_CAPY_AUTO_UPGRADE=0` disables the fast-forward.
3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for <skill>` (about 30 s cold: clone, the SHA-256-verified Bun 1.4.2, both host profiles without Chromium).
4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)` and `SKIP pins (planning-only; …)` are not failures.
**The trailer rule.** No `^gstack: ok` line from this machine in this session means gstack is not installed here: do not run a workflow, and do not claim the skills work. One machine's install never covers another.
**Subagents.** A subagent's first report to its parent carries the `gstack:` trailer line from its own machine's `--check`, verbatim, after its identifiers. A parent treats a missing line as `gstack: fail not-installed`.
`gstack-capy-skill: owner action — Drive copy is wrapper <n>, minimum <m>; copy <root>/capy/skill/gstack/ (CAPY_WRAPPER_STALE)` means this skill folder is older than the checkout wants. The run still works; tell the owner, who copies that directory over the skill folder. Never write the skill folder yourself.
## Launch every gstack command with the GSTACK_LAUNCH line
Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH) and, then, `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax. Prefix each gstack command with those assignments, or export them once in the shell that runs it; a child shell's export cannot configure the parent's later commands. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum.
With `GSTACK_CAPY=1`, a workflow started in an unattended or spawned session checks the install verdict the last run wrote for this checkout, HEAD and workflow, and the Bun on PATH. When either fails it prints an `INSTALL: fail …` row with its fix and `GSTACK_RESULT: skill=<s> status=refused run=-` and exits 3: stop, run the fix, start again. An ok whose pins row was `SKIP pins (planning-only; …)` does not admit a workflow that runs the repo's tests (`review`, `ship`, `qa`), and a verdict that did not check a component the workflow needs does not admit it either; run `--for <that skill>` first.
## Load the right workflow
For Capy running GPT-6 Astra, read `${CODEX_HOME:-$HOME/.codex}/skills/gstack/SKILL.md` for routing, then `${CODEX_HOME:-$HOME/.codex}/skills/gstack-<skill>/SKILL.md` for the selected workflow. These are the generated GPT-6 Astra instructions, not the Claude source files.
For Claude Code, read `~/.claude/skills/gstack/SKILL.md` for routing and `~/.claude/skills/<skill>/SKILL.md` for the selected workflow. When launching a Claude Code session, tell it to load gstack and name the intended workflow. For Capy running a Claude model, the Claude instruction files can be read directly with the Capy adaptations below; that does not turn Capy into the Claude Code harness.
If no specific skill was requested, use the router's task-to-skill mapping. Read only the selected workflow and its referenced files. For other models, use the closest applicable instruction profile without claiming a model-specific profile was generated or evaluated for them.
## Unattended runs for a parent agent
For `/autoplan` driven from Capy, read `autoplan/DRIVER.md` in the checkout (`~/.local/share/gstack/autoplan/DRIVER.md`): the compact phase loop, artifact contract and gate protocol, without the interactive preamble. A checkout without that file predates it; follow its `autoplan/SKILL.md` instead. Run every command with the `GSTACK_LAUNCH` assignments and read outputs as files, not prose: the run directory holds `run.json`, `decisions.jsonl`, `findings.jsonl`, `tasks.jsonl`, `timing.json`, `plan.md` and `review-record.md`, and the run ends with `GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>`. `~/.local/share/gstack/bin/gstack-artifact validate <dir>/run.json` is the completion check. The gate is answered through `gstack-autoplan answer` (or `gstack-gate parse`) with the printed `gate_rev`; an unattended run never approves its own gate. `docs/unattended.md` in the checkout is the contract.
## Before any push or pull request
In a repository with a sealed configuration (a `sealed` key in its `.gstack/pregate.json`), run `~/.local/share/gstack/bin/gstack-pregate sealed` before every push and before creating a pull request: Capy's pull-request tool may not run local git hooks, so this is the check that keeps sealed or eval output out of a public repository. A refusal names the file, line and rule; do not push until it passes.
## Adapt host-specific instructions to Capy
- An instruction to invoke `Skill` means read and follow the selected SKILL.md with the available file-reading tool. Do not call a nonexistent Skill tool or assume slash commands were registered in Capy's UI.
- Use Capy's user-visible messaging and question tools for output and decisions. Plain assistant text is not a delivered message in Capy. Use Capy's task and pull-request workflows rather than unavailable Claude or Codex host tools.
- Capy has no native gstack host target. Claude hooks do not become enforced Capy hooks; safety skills are advisory here, and `/autoplan` prints `autoplan guard: not enforced by this host` once to say so. Report missing capabilities instead of silently claiming equivalent enforcement.
- Selecting GPT-6 Astra or Claude in Capy does not install or authenticate the Codex or Claude Code CLI. For cross-model reviews, a subagent on another model family is a full outside voice; record the model that reviewed. Do not report a missing Codex CLI as degraded coverage when another runner reviewed.
- Preserve the user's requested scope, decisions, and consent. Do not enable telemetry or remote artifact sharing on their behalf. Installing gstack does not authorize merging, deploying, destructive operations, or bypassing approvals.
## Maintenance
`bash scripts/install.sh --check` is the health check; `bash scripts/install.sh` installs or repairs and fast-forwards a checkout below the supported minimum. Between minimums, `~/.local/share/gstack/bin/gstack-upgrade --check` says whether the checkout is behind, and `~/.local/share/gstack/bin/gstack-upgrade` fast-forwards it to the tip of the default branch and reinstalls (it refuses dirty, diverged or pinned checkouts and never stashes or resets). The installer is for Capy's Ubuntu cloud machines with Git, curl, Node.js, flock and timeout; do not run it on a personal device without permission.
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# gstack Capy skill bootstrapper. FROZEN: this file is copied into the
# owner's Drive skill folder and runs before any checkout code, so it does
# only what a stale checkout cannot do for itself:
# 1. clone gstack when absent (install mode);
# 2. fetch origin's default branch (refs only, no --depth) and read the
# supported minimum from FETCH_HEAD:capy/support.env (parsed, never sourced);
# 3. in install mode, fast-forward a clean, unpinned checkout below that
# minimum, under .gstack-capy-install.lock (--check never moves it);
# 4. exec the checkout's full wrapper, bin/gstack-capy-session, with the same
# arguments, handing the held lock over (GSTACK_CAPY_LOCK_HELD=1).
# Everything else (Bun, setup, the doctor, the verdict, the launch line) is
# the checkout's. The last line is the `gstack:` trailer; exit 0 only on
# `gstack: ok`. Contract: docs/capy-install.md; tests:
# test/capy-skill-wrapper.test.ts.
#
# Usage:
# bash install.sh --check [--for <skill> | --require <id,id>] [--project <path>]
# bash install.sh [--for <skill> | --require <id,id>] [--with-browser] [--revision <ref>] [--project <path>]
# Environment: GSTACK_CAPY_ROOT (default ~/.local/share/gstack), GSTACK_CAPY_REPO,
# GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s).
set -u
GSTACK_CAPY_WRAPPER=1
self="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
root="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}"
repo="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}"
echo "gstack-capy-skill: wrapper $GSTACK_CAPY_WRAPPER ($self)"
check=0 skill="" revision="" min_note="" remote_min=""
args=()
while [ $# -gt 0 ]; do
case "$1" in
--check) check=1 ;;
--for|--only) skill="${2:-}"; args+=("$1" "${2:-}"); shift ;;
--revision) revision="${2:-}"; args+=("$1" "${2:-}"); shift ;;
*) args+=("$1") ;;
esac
shift
done
fix_cmd="bash $self${skill:+ --for $skill}"
export GIT_TERMINAL_PROMPT=0
bounded() { if command -v timeout >/dev/null 2>&1; then timeout "${GSTACK_CAPY_FETCH_TIMEOUT:-60}" "$@"; else "$@"; fi; }
# below A B — dotted numeric A sorts below B.
below() {
local a="$1." b="$2." x y
while [ -n "$a" ] || [ -n "$b" ]; do
x="${a%%.*}" y="${b%%.*}"; a="${a#*.}" b="${b#*.}"
case "$x" in ''|*[!0-9]*) x=0 ;; esac
case "$y" in ''|*[!0-9]*) y=0 ;; esac
[ "$((10#$x))" -lt "$((10#$y))" ] && return 0
[ "$((10#$x))" -gt "$((10#$y))" ] && return 1
done
return 1
}
support_get() { printf '%s\n' "$1" | sed -n "s/^$2=\([0-9][0-9.]*\)[[:space:]]*\$/\1/p" | head -1; }
mkdir -p "$(dirname "$root")"
if [ "$check" -eq 0 ]; then
exec 9>"$(dirname "$root")/.gstack-capy-install.lock"
flock -w 900 9 || { echo "gstack: fail installer — another install holds $(dirname "$root")/.gstack-capy-install.lock; fix: wait for it, then $fix_cmd"; exit 1; }
fi
if [ ! -f "$root/setup" ]; then
if [ "$check" -eq 1 ]; then
echo "gstack: fail not-installed — no gstack checkout at $root; fix: $fix_cmd (installs)"
exit 1
fi
if [ -e "$root" ] && [ -n "$(ls -A "$root" 2>/dev/null)" ]; then
echo "gstack: fail installer — $root exists but is not a gstack checkout; fix: remove it or set GSTACK_CAPY_ROOT"
exit 3
fi
if [ -n "$revision" ]; then
git clone --quiet --single-branch --depth 1 --branch "$revision" "$repo" "$root" 2>/dev/null \
|| { git clone --quiet "$repo" "$root" && git -C "$root" checkout --quiet "$revision"; } \
|| { echo "gstack: fail installer — could not clone $repo at $revision; fix: check the revision and network, then $fix_cmd"; exit 1; }
else
git clone --quiet --single-branch --depth 1 "$repo" "$root" \
|| { echo "gstack: fail installer — could not clone $repo; fix: check network access, then $fix_cmd"; exit 1; }
fi
remote_min=$(support_get "$(cat "$root/capy/support.env" 2>/dev/null)" GSTACK_MIN_REVISION)
else
# The remote's rules: fetch the default branch into FETCH_HEAD (never --depth
# into an existing checkout), read capy/support.env from it.
default=$(bounded git -C "$root" ls-remote --symref origin HEAD 2>/dev/null | sed -n 's|^ref: refs/heads/\([^[:space:]]*\)[[:space:]]*HEAD$|\1|p' | head -1)
remote_min="" min_note=""
if [ -n "$default" ] && bounded git -C "$root" fetch --quiet origin "$default" 2>/dev/null; then
support=$(git -C "$root" show FETCH_HEAD:capy/support.env 2>/dev/null)
remote_min=$(support_get "$support" GSTACK_MIN_REVISION)
else
min_note="remote minimum unknown (fetch failed)"
echo "gstack-capy-skill: $min_note; checking the local install only"
fi
installed=$(head -1 "$root/VERSION" 2>/dev/null)
if [ -n "$remote_min" ] && below "${installed:-0}" "$remote_min"; then
branch=$(git -C "$root" symbolic-ref -q --short HEAD || true)
dirty=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | cut -c4- | tr '\n' ' ' | sed 's/ $//')
cause="" fix=""
if [ -n "$revision" ]; then cause="pinned by --revision $revision"; fix="drop --revision, or pin a revision at or above $remote_min, then $fix_cmd"
elif [ -z "$branch" ]; then cause="pinned: HEAD is detached at $(git -C "$root" rev-parse --short HEAD)"; fix="git -C $root checkout $default && $fix_cmd"
elif [ "$branch" != "$default" ]; then cause="pinned: on branch $branch, not $default"; fix="git -C $root checkout $default && $fix_cmd"
elif [ -n "$dirty" ]; then cause="tracked files changed: $dirty"; fix="git -C $root status --short shows them; commit or move those changes, then $fix_cmd"
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then cause="auto-upgrade disabled"; fix="unset GSTACK_CAPY_AUTO_UPGRADE, then $fix_cmd (or git -C $root merge --ff-only FETCH_HEAD and $root/bin/gstack-capy-install)"
elif ! git -C "$root" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null \
&& ! { git -C "$root" fetch --quiet --unshallow origin "$default" 2>/dev/null; git -C "$root" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; }; then
cause="diverged: local commits not on origin/$default"; fix="git -C $root log --oneline FETCH_HEAD..HEAD shows them; move them to another branch, then $fix_cmd"
elif [ "$check" -eq 1 ]; then cause="fast-forward available"; fix="$fix_cmd (fast-forwards)"
fi
if [ -n "$cause" ]; then
echo "gstack: fail revision — installed $installed is below the supported minimum $remote_min ($cause); fix: $fix (REVISION_BELOW_MINIMUM)"
exit 1
fi
if ! git -C "$root" merge --ff-only -q FETCH_HEAD 2>/dev/null; then
git -C "$root" fetch --quiet --unshallow origin "$default" 2>/dev/null
git -C "$root" merge --ff-only -q FETCH_HEAD 2>/dev/null \
|| { echo "gstack: fail revision — installed $installed is below the supported minimum $remote_min (fast-forward failed); fix: git -C $root status, then $fix_cmd (REVISION_BELOW_MINIMUM)"; exit 1; }
fi
echo "gstack-capy-skill: upgraded $installed -> $(head -1 "$root/VERSION")"
export GSTACK_CAPY_UPGRADED_FROM="$installed"
fi
fi
session="$root/bin/gstack-capy-session"
if [ ! -x "$session" ]; then
echo "gstack: fail installer — checkout at $root (installed $(head -1 "$root/VERSION" 2>/dev/null || echo unknown)) predates bin/gstack-capy-session${min_note:+ and the $min_note}; fix: check network access to $(git -C "$root" remote get-url origin 2>/dev/null || echo "$repo"), then $fix_cmd (it fast-forwards a checkout below the supported minimum)"
exit 1
fi
export GSTACK_CAPY_WRAPPER_REV="$GSTACK_CAPY_WRAPPER" GSTACK_CAPY_WRAPPER_PATH="$self" GSTACK_CAPY_REMOTE_MIN="${remote_min:-unknown}"
[ "$check" -eq 0 ] && export GSTACK_CAPY_LOCK_HELD=1
if [ "$check" -eq 1 ]; then
exec "$session" --check --root "$root" ${args[@]+"${args[@]}"}
fi
exec "$session" --root "$root" ${args[@]+"${args[@]}"}
+15
View File
@@ -0,0 +1,15 @@
# capy/support.env — what the Capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)
# reads from origin's default branch with `git show FETCH_HEAD:capy/support.env`,
# so a checkout too old to know today's rules learns them from the remote.
# Parsed, never sourced: KEY=<dotted number> lines only.
#
# GSTACK_MIN_REVISION: the oldest revision whose installer, trailer and fix
# lines are honest. A checkout below it is fast-forwarded at session start
# (install mode; --check only reports). Raise it only for install-honesty or
# security fixes, at stamp time, to the VERSION being released
# (docs/capy-install.md "Raising the supported minimum"); it never exceeds VERSION.
GSTACK_MIN_REVISION=1.91.96.0
# GSTACK_CAPY_MIN_WRAPPER: the oldest bootstrapper revision (GSTACK_CAPY_WRAPPER in
# capy/skill/gstack/scripts/install.sh) the owner should run. An older copy still
# works and prints an owner action (CAPY_WRAPPER_STALE).
GSTACK_CAPY_MIN_WRAPPER=1
+9
View File
@@ -125,6 +125,15 @@ sentence: "Version bump for branch-ahead discipline. No user-facing changes yet.
there. Do not pad. Do not explain the plan that will ship eventually. Do not narrate
the branch's history. When real work lands, the entry will replace this at /ship time.
### Raising the supported minimum (`capy/support.env`)
Release stamping can also raise `GSTACK_MIN_REVISION` in `capy/support.env`.
Every Capy machine below it is fast-forwarded at its next session start, so
raise it only for install-honesty or security fixes, and set it at stamp time
to the VERSION being released. It never exceeds VERSION
(test/capy-skill-wrapper.test.ts pins this). Details:
[capy-install.md](capy-install.md#raising-the-supported-minimum).
## Entry format
### Release-summary format (every `## [X.Y.Z]` entry)
+150 -30
View File
@@ -1,47 +1,114 @@
# Installing gstack on a Capy cloud machine
`bin/gstack-capy-install` installs gstack on a fresh Capy cloud machine
(Ubuntu) and ends with one line a parent agent greps. It is the repo-owned
installer the user-scope Capy skill runs after cloning; nothing in the skill
decides what "installed" means.
Three layers, each owned by gstack:
| Layer | Where | Job |
|---|---|---|
| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env`; in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper |
| Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies, otherwise install or repair |
| Installer | `bin/gstack-capy-install` | Bun, both host profiles, the doctor, the install verdict, the launch line, the trailer |
The vendored skill (`capy/skill/gstack/SKILL.md`) tells a Capy agent how to use
them. The Drive folder is a mirror: the owner copies `capy/skill/gstack/` there
when asked; nothing writes the Drive automatically.
Direct use, without the skill:
```bash
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.local/share/gstack
~/.local/share/gstack/bin/gstack-capy-install --for autoplan
```
What it does, in order:
## Session start
1. Reuses the checkout it lives in (or clones into `--root <dir>`), under a
lock. It never resets or pulls an existing checkout. `--revision <ref>`
pins a fresh clone; an existing checkout at another revision is reused and
the trailer says `(requested <ref>; run gstack-upgrade to move)`.
2. Preflight: `git`, `curl`, `node`, `jq`, then Bun. The default Capy image
ships Bun 1.3.14, below gstack's supported minimum (1.4.2), so the installer
downloads the exact release archive, verifies its SHA-256 against
`bin/gstack-bun-version.sh` and installs it to `$BUN_INSTALL` (default
`~/.bun`). It does that only when gstack's minimum and the target project's
Bun pin (`--project <path>`, default the git toplevel of the current
directory) agree; a conflict prints both constraints and stops
(`RUNTIME_PIN_CONFLICT`, exit 3) before anything is installed.
1. `bash <skill>/scripts/install.sh --check --for <skill>` (under a second when
a checkout exists). It fetches refs only and never moves the checkout. With
no checkout it prints `gstack: fail not-installed — ...` and the session
records that line and continues when it runs no gstack workflow; install
right before the first workflow.
2. When the trailer is `gstack: fail revision ...` or `gstack: fail runtime ...`
and a checkout exists, run the same command without `--check`. Below the
supported minimum, a clean checkout on the remote default branch is
fast-forwarded (`gstack-capy-skill: upgraded <old> -> <new>`), reinstalled
and rechecked as one transaction under `.gstack-capy-install.lock` (the
bootstrapper holds it and hands it over with `GSTACK_CAPY_LOCK_HELD=1`).
A pinned (`--revision`, detached HEAD, another branch), dirty or diverged
checkout is never moved; the trailer names the cause and the exact commands
([REVISION_BELOW_MINIMUM](troubleshooting.md#revision-below-minimum)).
`GSTACK_CAPY_AUTO_UPGRADE=0` disables the fast-forward.
3. When the fetch fails (offline), the bootstrapper says `remote minimum
unknown (fetch failed)` and checks the local install only; it never claims
the minimum.
The upgrade target is always the tip of the default branch: every commit there
is a stamped release that passed CI. Recovery runs forward, never back: an
install interrupted after a fast-forward leaves the trailer `fail`, and the
next install or `bin/gstack-upgrade` run completes the pending install stages
(setup is never skipped after a fast-forward) or refuses and names the cause.
Checking out an older commit is not a supported rollback; it does not undo
setup migrations that already ran.
Between minimums, `bin/gstack-upgrade --check` says `current <v>` or `behind
<installed> -> <remote> (<n> commits)`, and `bin/gstack-upgrade` fast-forwards
and reinstalls (`bin/gstack-capy-install` on a Capy install, else `./setup
--refresh-registered`). It refuses a dirty, diverged or pinned checkout with
[UPGRADE_REFUSED](troubleshooting.md#upgrade-refused) and never stashes or
resets.
## What the installer does
1. Reuses the checkout it lives in (or clones into `--root <dir>`), under the
lock. It never resets or pulls; `--revision <ref>` pins a fresh clone, and an
existing checkout at another revision is reused with the trailer note
`(requested <ref>; run <checkout>/bin/gstack-upgrade to move)`.
2. Preflight: `git`, `curl`, `node`, `jq`, then Bun. A supported Bun already in
`$BUN_INSTALL/bin` (default `~/.bun/bin`) is reused. The project's pins
(`--project <path>`, default the git toplevel of the current directory) are
read with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines,
packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes
for this platform). When neither the Bun on PATH nor the installed one
satisfies gstack's minimum and the pins, the installer downloads one exact
release archive, verifies its SHA-256 against `bin/gstack-bun-version.sh`
and installs it into `$BUN_INSTALL/bin`, beside any different system Bun (no
symlink into a shared bin directory). That archive is the supported minimum
(1.4.2), or the project's exact pin when it is above the minimum and gstack
carries a verified archive for it (`GSTACK_BUN_VERIFIED`: 1.4.2, 1.4.3), so a
repo whose CI pins `bun-version: 1.4.3` gets 1.4.3. With an exact pin, only
that version satisfies it. Any other exact pin (below the minimum, two
different ones, or one without a verified archive), or a range gstack's
minimum does not satisfy, stops with both
constraints ([RUNTIME_PIN_CONFLICT](troubleshooting.md#capy-install-runtime-conflict),
exit 3) before `./setup`. With no Bun at or above the security floor, the
verified one is installed first so the pins can be read.
3. `./setup --host claude --no-prefix`, then `./setup --host codex --model
gpt-6-astra`, both with `--no-browser`: Chromium is lazy by default.
gpt-6-astra`, both with `--no-browser`. Skipped when this exact HEAD was set
up with the same hosts, model and browser flag (recorded in the git dir
after a successful setup) and `gstack-doctor --check` passes; a warm rerun
takes about a second.
4. `gstack-doctor --check` for the requested workflow (`--for <skill>`,
`--only <skill>`, or `--require <id,id>`; default `claude,codex,patch`).
`--only <skill>`, or `--require <id,id>`; default `claude,codex,patch`),
run with the launch PATH, so the trailer certifies the Bun workflows run.
5. The install verdict, written atomically to
`<state root>/install-verdicts/<sha256 of the checkout realpath>.json`:
checkout, HEAD, the workflow and required components it certified, whether
pins were skipped as planning-only, the trailer, the remote minimum, the
launch PATH and the time.
`--check` runs only steps 4 and 5 (no clone, Bun install or setup).
## The one line to grep
The last line is the doctor's trailer:
The last line is the trailer:
```
gstack: ok 1.91.70.0 for=autoplan
gstack: ok 1.91.96.0 for=autoplan
gstack: fail runtime,pins
```
Exit 0 only with `gstack: ok`. Read the exit status and the trailer directly:
```bash
out=$(~/.local/share/gstack/bin/gstack-capy-install --for autoplan 2>&1); rc=$?
out=$(bash <skill>/scripts/install.sh --check --for autoplan 2>&1); rc=$?
printf '%s\n' "$out" | grep '^gstack: '
[ "$rc" -eq 0 ]
```
@@ -52,7 +119,13 @@ the incident this installer exists to end. Every `FAIL <id>` row carries a
`fix:` clause and a reason code with its [troubleshooting
anchor](troubleshooting.md#install-check-gstack-doctor---check-and-the-capy-installer).
A parent agent confirms each subagent machine with this grep; one machine's
install never covers another.
install never covers another, and a subagent's first report carries its own
`gstack:` line.
`SKIP pins (planning-only; repo pins differ: ..., launch bun <v>)` is not a
failure: planning workflows (`--for autoplan`, the plan reviews,
`office-hours`) never run the target repo's tests. Workflows that do (`review`,
`ship`, `qa`) and `--require` keep `pins` required.
When a lane reports to another thread (a handoff, a `gstack-lane-check`
result, a coordinator contract), store and use the full thread id (`jam_…`)
@@ -73,23 +146,70 @@ time separately.
## The launch line
The installer's last lines include:
Every successful run, `--check` included, prints before the trailer:
```
GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1
GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>
GSTACK_LAUNCH_PATH: <dir>
```
A child shell's `export` cannot configure the parent's later commands, so the
parent supplies these assignments to every gstack command it launches (prefix
the command, or export them once in the shell that runs it).
`PATH=<dir>:$PATH` and `GSTACK_LAUNCH_PATH` appear only when the certified Bun
is not first on PATH (the Capy image's `/usr/local/bin/bun` is 1.3.14; 57 gstack
commands start with `#!/usr/bin/env bun`). A child shell's `export` cannot
configure the parent's later commands, so the parent supplies these
assignments to every gstack command it launches (prefix the command, or
export them once in the shell that runs it). A fresh shell without them keeps
reporting the Bun it would really run: `FAIL runtime — bun 1.3.14 at
/usr/local/bin/bun ...; verified bun 1.4.2 found at ~/.bun/bin (not on PATH);
fix: export PATH="$HOME/.bun/bin:$PATH" or prefix commands with the
GSTACK_LAUNCH line`.
`GSTACK_EPHEMERAL=1` tells gstack the disk vanishes with the machine, so the
doctor's `state root` row reads `durable=no` and unattended runs say
`learnings: skipped` instead of writing silently. A durable root is opt-in:
set `GSTACK_STATE_ROOT` to a directory that outlives the machine. The
installer only prints the option and never writes to the Capy drive (its
policy excludes memory and verification evidence). See
installer never writes to the Capy drive. See
[docs/state-root.md](state-root.md#ephemeral-machines).
`GSTACK_CAPY=1` turns on install admission. An unattended session (any host)
refuses to start a workflow on a Bun below the supported minimum; with
`GSTACK_CAPY=1`, an unattended or `GSTACK_SESSION_KIND=spawned` session also
needs a passing verdict for this checkout, its current HEAD and a workflow at
least as wide as the one starting. A refusal prints `INSTALL: fail ...` and
`GSTACK_RESULT: skill=<s> status=refused run=-` and exits 3
([INSTALL_UNVERIFIED](troubleshooting.md#install-unverified)). Ambient
spawned sessions (OpenClaw) only warn. `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1`
admits anyway and says so.
## Bake into a Capy snapshot
Machines that never run a workflow should not pay the install (about 30 s
cold). A project that runs gstack on most machines can bake it instead: the
project Setup's initialize step runs
```bash
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.local/share/gstack
~/.local/share/gstack/bin/gstack-capy-install --require claude,codex,patch
```
and snapshots capture `$HOME` (the checkout, `~/.bun/bin`, both host
profiles). Each session then pays only the `--check`. A baked revision still
moves forward at session start when it falls below the supported minimum, and
the baked `~/.bun/bin` reaches workflows through the `GSTACK_LAUNCH` line. The
project Setup is shared by every thread in the project, so changing it is the
project's decision.
## Raising the supported minimum
`capy/support.env` holds `GSTACK_MIN_REVISION` and `GSTACK_CAPY_MIN_WRAPPER`.
Raise `GSTACK_MIN_REVISION` only for install-honesty or security fixes, at
stamp time, to the VERSION being released: every machine below it is
fast-forwarded at its next session start. It never exceeds VERSION
(test/capy-skill-wrapper.test.ts). Raise `GSTACK_CAPY_MIN_WRAPPER` together with
`GSTACK_CAPY_WRAPPER` in the bootstrapper when the bootstrapper itself
changes; older Drive copies keep working and print an owner action
([CAPY_WRAPPER_STALE](troubleshooting.md#capy-wrapper-stale)).
## Exit codes
| Exit | Meaning |
@@ -0,0 +1,283 @@
# gstack field wave (October 12, 2026): honest upgrades, closable unattended runs, plan-to-gate obligations
## URGENT, outside this plan
None known at plan time. The reviewers fill this block.
## Implementation plan
### Context
Base: origin/main `dbf400e` (v1.91.95.0). Input: field evidence from the GBRA threads that ran gstack on Capy cloud machines during gbrain fix waves 15 and 16 (2026-10-10..12, gstack 1.91.22–1.91.94), plus a 12-machine survey. Owner approval to run the wave: Garry, 2026-10-12 ("start it"), relayed by the gbrain coordinator thread. Garry merges gstack; this wave opens one PR and does not merge it.
Every item was reproduced on 1.91.95.0 before this plan was written, on a simulated Capy host (this machine: Ubuntu 24.04, `/usr/local/bin/bun` 1.3.14, no Codex CLI, a verified Bun 1.4.2 in `~/.bun/bin` off PATH). Receipts live in four investigation reports (lanes A, B1, B2, CD); the load-bearing ones are quoted inline below.
Already fixed on main and only verified here: item 6 for new runs (#3160, `decisionId` mints `<run>-r<rev>-<label>`). A 3-rev run driven only through the CLI (`next`/`submit` with hand-written results, `answer "all except d1b"` twice, then `all`, `export`) printed `ARTIFACT_VALID … status=complete`. Residual: a run gated on ≤1.91.94 and exported after upgrading produces 14×`ARTIFACT_DUPLICATE_ID` and 6×`ARTIFACT_DANGLING_REF`, because `finishGate` keeps old rows (lib/autoplan-gate.ts:111) while `buildTasks` points at the new id (lib/autoplan-export.ts:33).
Premise corrected by reproduction: item 11's words "doctor", "status" and "description" are not UI triggers. What sets `ui=yes` is non-visual uses of `component|form|screen|layout` (lib/autoplan-prompts.ts:23-26, two occurrences of any term) and Review-record prose, because UI detection reads the whole plan file (:41) while DX reads only the Implementation plan (bin/gstack-autoplan-snapshot.ts:25-26).
Repo constraints: SKILL.md is generated from `.tmpl` (`bun run gen:skill-docs`); new logic goes in `lib/*.ts` behind thin `bin/` entrypoints; registered modules are capped by `test/module-size-ratchet.test.ts`; `bin/gstack-autoplan-snapshot.ts` and `setup` are not grown; context-budget and parity ratchets apply to template edits; tests follow docs/test-value-bar.md (structure and machine-read tokens, not sentences). Pregate and ship-policy validators refuse unknown keys (lib/pregate/config.ts:58, lib/ship-policy.ts:158), so gstack ships before any consuming repo commits new keys.
Thesis: **a parent agent on Capy can trust what gstack tells it about itself, close what it starts, and carry plan obligations into the repo's own gates without hand-written records.**
### Release promises (the finish line)
- **P1. Install state is honest and self-correcting.** Every fix line names a command that exists. At session start a checkout below the supported minimum fast-forwards itself, reinstalls and rechecks; workflows refuse while the trailer is `fail`. The trailer agrees with the Bun that workflows run. A stale Capy skill wrapper is refused by name.
- **P2. An unattended /autoplan run closes in the run directory.** Phases reviewed by Capy subagents (free-form records or the canonical format) bind with `submit`; a host without the outside voice can mark it unavailable; a revised plan reopens affected phases in the same run; a 3-rev run validates; reruns ask only scope, cost or direction questions; user-challenge defaults follow agreeing voices.
- **P3. Plan obligations reach the repo's mechanical gates before a lane reports done,** paid gates are dry-run and priced before approval, a restamp is proved stamp-only by machine, and sealed or eval output is kept off any machine that can push to a public repo (owner custody, the primary control), with a local check that refuses an accidental push where local hooks run.
### Delivery shape
One wave PR on `capy/gstack-field-wave`, PATCH release (1.91.96.0 or the next free slot from `bin/gstack-next-version` at ship time). Four build lanes run in parallel on fresh machines, each on its own branch off `dbf400e`, each committing bisected commits without touching VERSION, CHANGELOG or package.json version; the integrator merges the lane branches, regenerates generated files, stamps, writes the CHANGELOG and runs the full free suite once on the integrated tree.
File ownership (to keep merges mechanical):
- Lane A owns `bin/gstack-capy-install`, `bin/gstack-doctor*`, `bin/gstack-bun-version.sh`, new `bin/gstack-upgrade`, `bin/gstack-skill-start`/`gstack-skill-end` (runtime gate only), new `capy/`, `docs/capy-install.md`.
- Lane B1 owns `lib/autoplan-run.ts`, `lib/autoplan-export.ts`, `lib/autoplan-state.ts`, `bin/gstack-autoplan` (`submit`, new `revise`), `bin/gstack-autoplan-snapshot.ts` (narrow `revise`/scaffold hooks only), `bin/gstack-artifact`.
- Lane B2 owns `lib/autoplan-gate.ts` (`buildGate`, answer/reopen logic), `lib/autoplan-reconcile.ts`, `lib/autoplan-prompts.ts`, `lib/gate-list.ts`, `lib/plan-reality.ts`, `bin/gstack-plan-reality`, `autoplan/` templates and the new driver doc. B1 adds only new functions to `lib/autoplan-gate.ts` (the `revise` reopen) and calls B2's existing ones.
- Lane CD owns `bin/gstack-pregate`, `lib/pregate/**`, `lib/eval-plan.ts`, `lib/pricing.ts`, `bin/gstack-eval-plan`, `lib/restamp.ts`, `lib/ship-policy.ts`, `lib/tree-receipt.ts`, `bin/gstack-redact-prepush`, new `lib/sealed-guard.ts`, `lib/coordinator-contract.ts`.
- Shared append-only files (`lib/result-codes.ts`, `docs/troubleshooting.md`, `docs/unattended.md`, `test/fixtures/module-size-ratchet.json`): each lane appends its own entries; the integrator resolves.
Every fix lands with a test that fails on `dbf400e` first, run on a simulated Capy host: fixture PATH with a stub or real Bun 1.3.14 first, no `codex`, scratch `HOME`/`GSTACK_HOME`/`GSTACK_STATE_ROOT`. No existing assertion is weakened; the only existing pins that change are named per item as intentional contract changes.
### Lane A: install and upgrade honesty (items 1–5)
**A1. `bin/gstack-upgrade` exists and every fix line names a real command (item 1).** No revision has ever had `bin/gstack-upgrade` (`git log --all -- bin/gstack-upgrade` is empty), yet bin/gstack-capy-install:138, docs/capy-install.md:18, docs/troubleshooting.md:1132 and the Capy skill (install.sh:34,55; SKILL.md:40) name it. The `/gstack-upgrade` skill cannot run unattended (gstack-skill-start:616 skips upgrades; its fallback uses AskUserQuestion and `git reset --hard`, gstack-upgrade/SKILL.md.tmpl:166-190).
- New `bin/gstack-upgrade` (bash 3.2, the gstack-capy-install style): `--check` fetches (bounded by `timeout 60` when present) and prints `gstack-upgrade: current <ver>` (exit 0) or `gstack-upgrade: behind <installed> -> <remote ver> (<n> commits)` (exit 1), writing nothing. Default mode refuses with exit 3 and changes nothing when the directory is not a gstack checkout, HEAD is detached or off the remote default branch (a `--revision` pin), the tree is dirty after discarding only the render footprint (`SKILL.md`, `*/SKILL.md`, `*/sections/*.md`, as the skill's fast path does), or the incoming Bun floor holds (`gstack_bun_incoming_hold`). Then `git merge --ff-only FETCH_HEAD`; divergence is `UPGRADE_REFUSED` (exit 3; never stash, never reset). Then `bin/gstack-capy-install` when `--capy` is passed (Bun preflight before setup), else `./setup --refresh-registered` (migrations run inside setup, setup:3852).
- Fix text: REV_NOTE becomes `run <root>/bin/gstack-upgrade to move`; `_dc_check_revision`'s fix names `$SRC/bin/gstack-upgrade` (keeping `/gstack-upgrade` as the interactive alternative); gstack-skill-end:46's `UPGRADE_AVAILABLE` line names the bin. New result code `UPGRADE_REFUSED` with a troubleshooting anchor.
- Tests: new `test/gstack-upgrade-bin.test.ts` (bare remote with commits A and B, depth-1 clone at A): `--check` exits 1 with `behind 1.0.0 -> 1.0.1` and HEAD unchanged; default exits 0 with HEAD at B and `setup --refresh-registered` logged; a dirty tracked file and a diverged local commit each exit 3 with HEAD, file bytes and reflog unchanged. Fails today: exit 127. A static test asserts that every `bin/<name>` a trailer or fix line names exists in the repo. Intentional pin change: test/gstack-capy-install.test.ts:203 (REV_NOTE text).
**A2. A checkout below the supported minimum upgrades itself at session start, and workflows refuse while the trailer fails (item 2).** Reproduced: a 1.91.12 checkout (7fca42ad8) printed the identical `gstack: fail installer` line on every run, `--check` or not; `--check` says run the installer and the installer says run the missing `bin/gstack-upgrade`, a closed loop. Checkouts ≥1.91.71 but behind pass forever, because the revision row compares only against an explicit `--revision` (doctor-check.sh:325). Unattended runs skip the update check (gstack-skill-start:119-123). Fast-forward plus the current installer on a real installed 1.91.12 checkout ran migrations 1.91.19 and 1.91.65 and ended `gstack: ok 1.91.95.0` (fetch plus ff of a depth-1 clone: 3.4 s).
- New `capy/support.env` (shell-sourceable, two keys): `GSTACK_MIN_REVISION=<x.y.z.w>` (the oldest revision whose installer, trailer and fix lines are honest; set to this wave's release) and `GSTACK_CAPY_MIN_WRAPPER=<n>`.
- The Capy skill wrapper (vendored in A4) at session start, in `--check` and install mode alike: `git fetch --depth 1 origin <default branch>` (bounded), read `capy/support.env` from `FETCH_HEAD` (so an old checkout learns today's minimum from the remote), compare with the checkout's VERSION. Below the minimum, and the checkout is clean, unpinned (no `--revision`, on the default branch) and fast-forwardable: `git merge --ff-only FETCH_HEAD`, run `bin/gstack-capy-install`, then `--check`; print `gstack-capy-skill: upgraded <old> -> <new>`. Pinned, dirty or diverged: no change, and the trailer is `gstack: fail revision — installed <v> is below the supported minimum <min>; fix: <exact commands>` (exit 1). Fetch failure (offline) degrades to the local check and says `remote minimum unknown (fetch failed)`, never a false ok. This reverses the documented "existing checkouts are never pulled" rule for below-minimum checkouts only; the reversal is the owner's request and is fast-forward-only, so it is reversible (`git checkout <old sha>`).
- `gstack-doctor --check` gains `--min-revision <v>` (the wrapper passes the remote minimum); the revision row FAILs below it with `REVISION_BELOW_MINIMUM` and a fix naming the wrapper or `bin/gstack-upgrade`.
- Workflow refusal: in an `unattended` or `spawned` session, `gstack-skill-start` runs the cheap verdict (the PATH Bun against the supported minimum, the checkout VERSION against the local `capy/support.env`) and, when either fails, prints the failing row with its fix and `GSTACK_RESULT: skill=<s> status=refused run=-`, exit 3. Interactive sessions keep today's warning. The Capy skill tells the agent never to run a workflow without a `gstack: ok` trailer from this session.
- Subagent drift: the coordinator contract's handoff format (lib/coordinator-contract.ts, rendered byte-identical in three places) gains, first, "the `gstack:` trailer line from this machine's `--check`"; the vendored skill says a subagent's first report to its parent carries that line.
- Tests (fail-first): `test/capy-skill-wrapper.test.ts` builds a bare remote whose main carries `capy/support.env` with a minimum above the clone's VERSION and a stub installer; running the vendored wrapper with `--check` fast-forwards, runs the installer and ends `gstack: ok <new>` (today: the wrapper does not exist; the drive copy repeats `fail installer`); a dirty checkout gets `gstack: fail revision` naming the commands and HEAD unchanged; an offline remote says `remote minimum unknown`. `test/gstack-doctor.test.ts`: `--min-revision` above VERSION FAILs the revision row. `test/gstack-skill-start.test.ts` (or the existing session-kind test file): unattended with a stub Bun 1.3.14 first on PATH prints `status=refused`.
- Live acceptance (the wave's done criterion): on this machine, a real 1.91.12 checkout at `~/.local/share/gstack` (moved aside first, restored after) upgrades itself to the wave head at session start through the vendored wrapper pointed at a local mirror whose default branch is the wave branch, and ends `gstack: ok`.
**A3. The trailer agrees with the Bun that workflows run (item 3).** Reproduced: a fresh shell's `--check` says `FAIL runtime` and `FAIL pins` while the installer's own `--check` said ok; the installer exports PATH only to itself (gstack-capy-install:108) and `GSTACK_LAUNCH` (:145) carries no PATH; 57 bins use `#!/usr/bin/env bun`, so a fresh shell silently runs 1.3.14. The installer also re-downloads Bun on every run (it checks only the PATH Bun, :91). With `/usr/local/bin/bun` 1.4.3 and gbrain's CI pin 1.4.2, the trailer is `fail pins — bun 1.4.3 outside CI 1.4.2` and the installer never installs 1.4.2, because its pin parser (:79-90) ignores CI pins.
- One helper in bin/gstack-bun-version.sh, `gstack_bun_launch_dir`: the first of `$BUN_INSTALL/bin`, `~/.bun/bin` holding a Bun that satisfies gstack's minimum and the project pin, else empty.
- `GSTACK_LAUNCH:` carries `PATH=<dir>:$PATH` when that directory is not already first on PATH. The wrapper's `--check` runs the doctor with the same PATH, so its trailer is the verdict for commands launched with `GSTACK_LAUNCH`. A bare doctor run in a fresh shell keeps reporting the PATH Bun (that is what an unprefixed workflow runs), but its runtime row says `bun 1.3.14 at /usr/local/bin/bun; verified bun 1.4.2 found at ~/.bun/bin (not on PATH); fix: export PATH="$HOME/.bun/bin:$PATH" or prefix commands with the GSTACK_LAUNCH line`. Same for the pins row.
- The installer reuses an installed verified Bun instead of downloading again, and reads the project's pins through `gstack-runtime-pins.ts` (runs on any Bun above the floor): when an exact pin (engines or the CI lane for this platform) equals the version gstack carries a verified archive for, it installs that version into `$BUN_INSTALL/bin` alongside a different PATH Bun; any other exact pin stops with `RUNTIME_PIN_CONFLICT` naming both. No symlink into a shared bin directory (it would shadow the system Bun for every project on the machine).
- Tests: test/gstack-capy-install.test.ts: after installing Bun, `GSTACK_LAUNCH: PATH=<home>/.bun/bin:$PATH GSTACK_SESSION_KIND=unattended …` (today: no PATH); a second run downloads nothing (today: two curls); stub PATH Bun 1.4.3 plus a CI pin of 1.4.2 downloads `bun-v1.4.2` and launches with its PATH (today: no curl). test/gstack-doctor.test.ts: PATH Bun 1.3.14 plus a verified 1.4.2 in `$HOME/.bun/bin` gives a runtime FAIL row containing `found at` and `not on PATH`, and with the launch PATH gives PASS.
**A4. A stale Capy skill wrapper is refused by name (item 4).** No copy of the Capy skill has ever been in the repo; Drive keeps only the current version, so the 10-07 copy (1,667 bytes, legacy "skills … are ready" line) is unrecoverable.
- Vendor the canonical skill at `capy/skill/gstack/SKILL.md` and `capy/skill/gstack/scripts/install.sh` (precedent: `openclaw/skills/`). The Drive folder becomes a mirror, updated by copying this directory when the owner asks; nothing writes the Drive automatically.
- The wrapper prints `gstack-capy-skill: wrapper <n> (<path>)` first and passes `GSTACK_CAPY_WRAPPER_REV=<n>` to the installer and doctor. `gstack-capy-install` refuses when that revision is below `GSTACK_CAPY_MIN_WRAPPER` from the checkout's `capy/support.env`: `gstack: fail wrapper — Capy skill wrapper <n> is older than this checkout's minimum <m>; fix: copy <root>/capy/skill/gstack/ over the skill folder (CAPY_WRAPPER_STALE)`, exit 1. Unset means a direct invocation and is allowed.
- A wrapper too old to call the installer cannot be caught from the checkout; the vendored SKILL.md states the rule that catches it: no `^gstack: ok` line in this session means not installed.
- Tests: `test/capy-skill-wrapper.test.ts`: the first line is the wrapper line; a fixture minimum of 99 gives `gstack: fail wrapper` with exit 1; `bash -n` passes; the wrapper's revision is at least the checkout minimum; every command the vendored SKILL.md names exists in `bin/`.
**A5. Machines that never run a workflow do not pay the install (item 5).** Measured cold install 32.5 s here (GitHub clone 7.2 s, Bun 2.6 s, `bun install` 10.6 s, build and render about 3 s, the rest of the claude setup 6.5 s, codex setup 2 s); a warm rerun 13 s. The field's 52 s is the same phases on slower machines.
- The installer skips both `./setup` runs when `gstack-doctor --check` already passes for the requested workflow at this checkout and revision (warm rerun about 1 s), and reuses the installed Bun (A3).
- The vendored SKILL.md makes the install lazy: run `--check` at session start (under 1 s, and it performs A2's upgrade when a checkout exists); with no checkout, record `gstack: not installed (no workflow yet)` and install right before the first workflow. Garry's user-scope AGENTS.md says to run the installer when the check fails; the wave reports the one-sentence amendment to the coordinator for Garry and changes nothing there itself.
- docs/capy-install.md gains "Bake into a Capy snapshot": the project Setup's initialize step runs the clone plus `gstack-capy-install --require claude,codex,patch`; snapshots capture `$HOME`; with A2 a baked revision still moves forward at session start, and with A3 the baked `~/.bun/bin` reaches workflows through `GSTACK_LAUNCH`.
- Tests: two installer runs on one fixture log setup only on the first (today: four setup calls). Intentional pin change: the default-install test (:86-105) gets a failing doctor stub before its first run so it still proves setup runs on a fresh checkout.
### Lane B1: closable runs (items 6, 7, 9)
**B1a. A 3-rev run validates, including runs gated before 1.91.95 (item 6).** `exportRun` rewrites legacy decision ids (rows with `gate_rev > 1` whose id lacks `-r<rev>-`) through `decisionId` before writing, and records `deviations: legacy decision ids rewritten (<n>)`. Safe: those runs never validated, and `ack` refuses invalid runs (bin/gstack-artifact:87-91), so no consumer holds the old ids. Tests: a new end-to-end CLI test drives three gate revisions through `next`/`submit`/`answer`/`export` and asserts `gstack-artifact validate` exit 0 with unique decision, finding, task and attempt ids (the regression pin for #3160); a fixture with legacy rev-2 ids exported on this version validates (today: 14 duplicate ids).
**B1b. `submit --record` closes phases that Capy subagents reviewed (item 7).** Four things block a parent today: the `INPUT:` receipt is required (lib/autoplan-run.ts:207-208), the findings fence is required (:209), `--attempt` is mandatory (bin/gstack-autoplan:99) though only one attempt can be open per phase and voice, and the CLI cannot mark the outside voice unavailable (`markOutside` is reachable only from `run`, bin:141), so on a host without Codex a phase can sit in `awaiting_result` forever. Hand-writing `review-record.md` does nothing: export regenerates it and validate reads `run.json`.
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]`. Under the run lock it binds to the single open attempt for that phase and voice (`--attempt` optional when exactly one is open); a phase `next` has not opened is refused with the `next` command to run. `--voice` stays native|outside and `--model` names the model, matching the existing path; the field request's `--voice <model>` is served by `--model`.
- Receipt: an `INPUT:` line must match the snapshot; with none, the record binds as `receipt: attested` (never stamped, since stamping would forge a verified receipt), the voice records it, and the review record shows a deviation line. Validate treats `attested` as terminal and lists it.
- Findings: the fenced block when present; else a markdown table with severity and title columns; else severity-tagged list items (`- [High] title`); else an explicit `No findings.` line; anything else is refused with `RESULT_FINDINGS_MISSING` naming the accepted shapes. The parser never guesses a severity.
- `submit --voice outside --unavailable "<reason>"` (and `--failed`) calls `markOutside` and closes the phase with `coverage=native_only`.
- When the last phase closes, `submit` writes the gate as today; `answer` plus `export` complete the run. The `--result` path is unchanged (its refusal pins in test/autoplan-run-recovery.test.ts:44-75 stay).
- Tests: a run whose three phases bind free-form records (one with a table, one with a list, one `No findings.`) through `--record` without `--attempt`, outside marked unavailable on one phase, reaches `gate_pending`, answers `all`, exports and validates; a record with a mismatched `INPUT:` is refused; prose with no findings shape is refused.
**B1c. `revise --plan <v2>` reopens affected phases in the same run; a plan without the two sections is scaffolded, not refused (item 9).** Reproduced: after editing the plan and answering `d1b`, the rerun used the same snapshot; an edit after CEO never reaches Eng because `openPhase` returns early once a snapshot exists (lib/autoplan-run.ts:117); the run reads only its private `plan-active.md` written once at init (:73-75). Plans with only one of the two sections, duplicated or reversed headings, or an empty section exit 1 with empty stdout and no result code (a plain Error falls through bin:217), and an empty section is misreported as a count error (gstack-autoplan-snapshot.ts:413-418).
- `gstack-autoplan revise --out <dir> --plan <v2> [--json]`: normalize v2 the way init does, replace `plan-active.md` atomically (restore point untouched), journal `plan_revised` with both hashes, and reopen every opened phase whose snapshot differs from v2's per-phase snapshot plus Eng; open attempts are superseded as `--redispatch` does; at `gate_pending` it is recorded as a `p1d` answer and bumps `gate_rev`. Scope may add phases, never remove them. Archives use a `.v<k>` suffix with a no-overwrite guard (today `reopenPhases` archives with `renameSync`, which silently overwrites). Prints `REVISED: plan=<sha12> reopened=<phases>` and the result line.
- Drift hints: `next` prints `PLAN_CHANGED: <source> differs from the reviewed plan; run gstack-autoplan revise --out <dir> --plan <source>` when `plan_source` changed since the last snapshot; `answer` prints the same as `PLAN_SOURCE_CHANGED` when a reply reopens phases.
- Sections: the first `next` returns `PLAN_SECTIONS_INVALID` (exit 3) with the exact problem and a fix line naming `--scaffold`; `next --scaffold` writes `<out>/plan-source.md` with the plan under `## Implementation plan` and an empty `## Review record`, never touching the user's file, and proceeds. Ambiguous plans (duplicate or reversed headings) still refuse, with the code. Snapshot `init` stays strict (test/autoplan-init.test.ts:125-138).
- Tests: revise after CEO closes reopens nothing closed but makes DX and Eng open on the v2 snapshot (prompt contains a v2-only sentence); revise at `gate_pending` bumps `gate_rev` and the rerun prompts carry v2; a second revise does not overwrite the first archive; a plan with only an Implementation plan exits 3 with `PLAN_SECTIONS_INVALID` and a non-empty stdout, and `--scaffold` proceeds.
### Lane B2: gate judgment, scope, receipts, context (items 8, 10–13)
**B2a. Reruns raise only scope, cost or direction challenges (item 8).** Reproduced: answering `uc2b` reopened Eng; the rerun's native voice raised a packaging challenge, which became `d1` with "b reopens eng", and `d1b` made rev 3. Causes: every override reopens its phase plus Eng (lib/autoplan-gate.ts:144-151); every `user_challenge` row becomes a gate item (:78-96) and the prompt asks for the flag on any merge, split, add or remove (lib/autoplan-prompts.ts:73); rerun prompts carry no gate decisions (writePrompts, :118-131); rev 2 re-asks an answered CEO item whose phase did not rerun (`buildGate` rescans every closed phase); Eng reran on byte-identical input.
- Findings gain an optional `changes: "scope"|"cost"|"direction"`. The prompt asks reviewers to set `user_challenge` only with `changes`; packaging, ordering, naming and other reversible choices are the agent's own decisions.
- On gate revisions after the first, a `user_challenge` without `changes` becomes an `auto` item whose override does not reopen any phase (`reopens: false`), recorded as an agent decision.
- Rerun prompts get a `DECIDED AT THE GATE` block listing every answered item (id, chosen option, text) so a rerun does not reopen settled questions; answers carry forward for items from phases that did not rerun, so they are not re-asked.
- `answer` prints one `APPLY: <decision id> <title> -> <chosen text>` line per accepted plan change, telling the parent to amend the plan (and, with B1c, to run `revise`).
- Tests: on a fixture run, a rerun native finding with `user_challenge` and no `changes` becomes an auto item, and answering it `b` does not reopen Eng (today: rev 3); a rerun prompt contains the `DECIDED AT THE GATE` block with the uc id; rev 2 does not re-ask a CEO item answered at rev 1 when CEO did not rerun.
**B2b. User-challenge defaults follow agreeing voices and say why (item 10).** Every `uc` item has `recommended: 'a'` (keep your direction, lib/autoplan-gate.ts:81), even though building a UC already requires both voices; the same branch also admits `disagree` rows. Flipping only `recommended` is wrong: editing gate.json so UCs recommend `b` and replying `all` printed `APPROVED: gate_rev=1 as-is`, so the change counted as accepted but was never applied and nothing reran.
- Gate options carry `changes_plan: true|false`; reopen and `APPLY:` decisions key on the chosen option's `changes_plan`, not on "differs from recommended".
- A UC whose voices confirmed each other recommends the change (`b`) with the basis rendered `recommended: b (both voices)`; a disagreement keeps `a` with `recommended: a (plan direction; voices disagree)`. The basis is stored on the gate item (`recommended_basis`) and in decisions.jsonl.
- This changes what `all` means for agreeing UCs: today it keeps every direction (and the CHANGELOG says the original direction stands); after the wave it accepts changes both voices agree on, and each is printed as `APPLY:`. The owner asked for exactly this; the CHANGELOG and docs/unattended.md say so, and it is reported to the coordinator as a default change.
- Tests: a confirmed UC renders `recommended: b (both voices)`; replying `all` prints an `APPLY:` line for it and reopens its phase plus Eng; a disagreeing UC renders `a (plan direction; voices disagree)`. Intentional pin changes: test/autoplan-answer-invalidation.test.ts around lines 79, 96 and 140 assert the old meaning of `all` and are rewritten to the new contract with equal or stronger assertions.
**B2c. Scope keys on what changes, and flags stick (item 11).**
- UI detection reads the Implementation plan only (the same input as DX).
- `ui=yes` needs a rendered surface: a path in the Implementation plan matching `\.(tsx|jsx|vue|svelte|css|scss|html|erb|hbs)\b` or `/(components|pages|views|styles)/`, or at least three distinct UI terms; fewer is weak evidence that keeps today's conservative section fallback but does not add the Design phase. `next` prints the evidence: `SCOPE: ui=no (terms: component×1; files: none) dx=yes (detected: CLI×1, flag×2)`.
- Per-axis provenance: `sources: { ui: 'flag'|'detected'|'marker', dx: … }`, printed `source=ui:flag,dx:detected`; `--no-ui` no longer labels a detected DX as flag-forced.
- New `--no-dx` (additive; without it the existing "flags only enable DX" behavior is unchanged).
- Sticky per plan: `initRun` reads `<!-- gstack-autoplan-scope: --no-ui --developer-tool -->` from the source plan (outside the Implementation plan, so the snapshot is unchanged); CLI flags win. When flags come from the CLI and the plan has no marker, init appends the marker after the plan's first heading in the source plan file and prints `SCOPE_PINNED: <marker>`.
- The interactive path's scope comes from the same detector (`snapshot scope` returns `uiRequired` with matches; autoplan/SKILL.md.tmpl uses it instead of a prose grep).
- Tests (new test/autoplan-scope.test.ts): UI words only in the Review record give `ui=false`; component/form/screen with no UI files give no Design phase; `src/components/Settings.tsx` gives `ui=true`; `{ui:false}` reports `dx:detected`; a marker with no flags gives `ui=false` from `marker`; a run with CLI flags writes the marker. Intentional pin change: test/review-checklists.test.ts:92-105 (the `source` line shape).
**B2d. PLAN_REALITY requires the rule text and a receipt per number (item 12).** Reproduced: one receipt covered five numbers, and "the decision rule was written first" passed with no rule text (`verdict=complete`, exit 0). Side bug: receipts on dot paths (`.gstack/ship-policy.json:1`, `.github/…`) are not recognized (lib/plan-reality.ts:160). `unreceipted=` works and must not regress.
- Each clause containing a number needs its own receipt (file:line, a script path or a command in backticks within the clause); misses print `numbers_unreceipted=<n>` with the clause text.
- A line claiming a decision rule exists ("rule written first", "decision rule", "preregistered rule") must quote or link the rule text (a quoted block, a fenced block, or a file:line); misses print `unquoted=<n>`.
- Either miss makes the verdict `incomplete` (exit 1) with a fix line; `unreceipted=` keeps its meaning and its text token.
- The receipt regex accepts dot paths.
- `gstack-autoplan submit` runs the check on the active plan once per run and records its verdict in run.json (report only, never refuses).
- Tests (test/plan-reality.test.ts): five numbers sharing one receipt give `numbers_unreceipted=4`; "the decision rule was written first" without the rule gives `unquoted=1`; a `.gstack/ship-policy.json:1` receipt counts; the existing `unreceipted=` case asserts the literal text token as well as the JSON (a keep-what-worked pin).
**B2e. A compact Capy-native driver doc (item 13).** A Capy parent on Claude running ceo+dx+eng reads about 1,800 lines (about 28k tokens: SKILL.md 995 lines, 521 of them preamble, plus phase sections and three phase-close reads); on GPT, one 1,923-line file (about 33k tokens). What the runner needs is about 106 lines (docs/unattended.md:135-240).
- New generated `autoplan/DRIVER.md` (template `autoplan/DRIVER.md.tmpl`, resolvers that read the code constants: `PHASES`, `FILES`, the RESULT FORMAT, the gate grammar, the result codes the loop can see, the `submit --record` and `revise` commands). Contents: phase order and scope flags, the next/submit/answer/revise/export loop, the artifact contract, the gate protocol and the coordinator contract. No hook, guard, preamble or AskUserQuestion prose.
- `gen:skill-docs` writes it; the existing freshness check covers it; a new test caps it at 160 lines and bans hook terms (`PreToolUse`, `hook`, `AskUserQuestion`, `Skill tool`). The vendored Capy skill and docs/capy-install.md point Capy at it.
### Lane CD: plan to gate, queue-aware shipping, sealed output (items 14–17)
**CD1. Repo guards run in pregate before a lane reports done (item 14).** Reproduced in a gbrain clone: a new serial test with no manifest row passed `check-test-isolation` and gstack-pregate (which ran only the new file), and `test/scripts/serial-files.test.ts` then failed. After rewording an op description, pregate selected 3,598 tests but not `test/cli-goldens.test.ts`, which spawns the CLI as a subprocess, so the import graph cannot see it. Pregate silently ignores unknown flags (`--sealed`, `--against` ran a normal pregate, bin/gstack-pregate:55,62-99). The lanes check runs whichever Bun is first on PATH (lanes.ts:26) and runs serial files in the unit process (:54).
- New `guards` check (lib/pregate/checks/guards.ts plus config.ts, as the existing pregate design names): config entries `{id, when: [globs], command, fix}`; a guard runs when any changed file matches `when`, and its failure fails pregate with the guard id and fix. `dependencies` entries already map globs to extra tests; guards cover manifest and golden scripts.
- Unknown flags exit 2 with usage. The lanes check runs Bun from `gstack_bun_launch_dir` when present (A3) and serial-pattern files (`serial_patterns` config, default `**/*.serial.test.*`) in their own process.
- The coordinator contract and docs/unattended.md say a lane runs `gstack-pregate` (unit tier on changed files plus the repo's guards) before reporting done, and its handoff carries the `PREGATE:` line.
- docs/pregate.md (or the existing pregate doc) gains a worked gbrain config: guards for `scripts/serial-files.tsv` (`test/scripts/serial-files.test.ts`), CLI goldens (`test/cli-goldens.test.ts` when `src/core/operations.ts` or `src/commands/**` change) and `bun run verify`. gbrain commits it after gstack ships (its .gitignore line 16 blocks `.gstack/` today; that change is gbrain's).
- Tests: a scratch repo with a guard on `test/**/*.serial.test.ts` fails pregate naming the guard when a serial test lands without its manifest row (today: pass); `gstack-pregate run --bogus` exits 2 (today: 0).
**CD2. Paid gates are dry-run and priced before plan approval (item 15).** eval-plan models one call per row per arm (lib/eval-plan.ts:140-171) and has no verb that takes a gate command. lib/pricing.ts has no `claude-sonnet-5-5`, so the newest Sonnet fails with `EVAL_PRICE_MISSING`.
- New `gstack-eval-plan gate --command "<exact gate command>" [--smoke-questions 2] [--cap-usd <n>]`: runs the command with every provider key stripped from the environment (keyless dry run), with `GSTACK_EVAL_DRY_RUN=1` and `GSTACK_EVAL_SMOKE_QUESTIONS=<n>` set, and reads `GSTACK_EVAL_CALLS: purpose=<p> model=<id> calls=<n> in_tokens=<n> out_tokens=<n>` lines from its output. It prices each line, prints `GATE_PRICE: total=<usd> calls=<n> purposes=<list>` plus every precondition the command printed (`GSTACK_EVAL_PRECONDITION: …`), and fails with `EVAL_GATE_UNPRICED` when the command emits no call lines (a gate whose cost is unknown cannot be approved), `EVAL_PRICE_MISSING` for an unpriced model, or `EVAL_GATE_OVER_CAP` above `--cap-usd`.
- New eval-plan check `reservation-fits-cap`: one call's input plus maximum output, priced, must fit under the smoke cap (the field's silently refused reservations).
- `claude-sonnet-5-5` pricing added with its source URL and date in the table comment, after checking the provider's current price page at build time; no number without a source.
- The /eval-plan template step that approves a plan runs `gate` for every paid gate the plan names (template text through the existing resolver; parity budget respected). Emitting the call lines is the consuming repo's change (gbrain's synopsis builds), reported to the coordinator, not made here.
- Tests (test/pricing-eval-plan.test.ts): a stub gate command that prints two call lines and one precondition is priced and listed; a stub that prints nothing gives `EVAL_GATE_UNPRICED`; the stub sees no `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`; a reservation above the smoke cap fails `reservation-fits-cap`.
**CD3. Restamp delegates to the repo's command and stamp-only is proved by machine (item 16).** Reproduced: on a pure gbrain restamp from 0.60.188.0 to 0.60.189.0, tree-receipt says `TREE: changed` for 13 of 16 files, because `rewriteStamps` (lib/restamp.ts:276-291) knows only `(was X)`, unquoted `since: X` and `vX`, and misses `since: '…'`, `"version": "…"` and `<!-- …-stamp -->` comments. Setting `release_tool: bun run release:restamp` fails with `RESTAMP_RELEASE_TOOL_FAILED` because gbrain's tool refuses a dirty tree (release-restamp.ts:530-533) and gstack writes VERSION first (lib/restamp.ts:314-321). Two branches each adding migration v240 give `LANE_CHECK: clear` and a pregate pass.
- Ship policy gains `stamp_patterns` (regexes with one version capture group, shared by the writer and the tree-receipt classifier; the default stays today's three forms) and `restamp_command` (the repo's own restamp, run in place on a clean tree before gstack writes anything; gstack then only runs the receipt). Running a repo command is gated by the existing `--allow-repo-commands` and read from the base branch's policy, as `release_tool` is.
- `gstack-pregate --against <gated-head>` runs the tree receipt between that head and HEAD under the policy's stamp patterns and prints `STAMP_ONLY: yes|no files=<n>` (each non-stamp difference listed with file and line), `MIGRATION_COLLISION: none|<number> <file> vs <PR #n file>` (migration directories from `migrations_dir` policy key; open PRs through `gh pr list --json number,files`, cached for the run; `gh` unavailable prints `unknown (gh unavailable)`, never `none`), and `REGATE: needed|not-needed` (not-needed only when stamp-only and no collision). It exits 1 when a regate is needed.
- Which files count as stamp files is the repo's rule: the worked gbrain policy lists exactly the files gbrain's tree rule names, so doc `vX` mentions outside them are differences (the repo's rule wins over gstack's default).
- Tests (test/gstack-restamp-queue.test.ts, test/tree-receipt*.test.ts, new pregate test): a restamp touching `since: '0.60.188.0'`, a package.json version and a stamp comment is `STAMP_ONLY: yes` under configured patterns (today: changed); a code edit is `no` naming the file; two branches adding the same migration number report the collision (today: clear); `restamp_command` runs on a clean tree before any gstack write (today: `RESTAMP_RELEASE_TOOL_FAILED`). The negative fixture at test/tree-receipt-stamp-sensitive.test.ts:58-69 stays as is.
**CD4. A sealed-output guard runs before every push (item 17).** Reproduced: with the managed pre-push hook installed, a push carrying a per-cell transcript, a quoted sealed answer, a seed and a `/home/user` path exited 0 with no output; the home path rates LOW `internal.user_path`, silent in pre-push. gbrain-evals is public.
- New lib/sealed-guard.ts, called from bin/gstack-redact-prepush after `addedLinesFor` (the existing hook, no reinstall) and exposed as `gstack-pregate --sealed [--range <a..b>]`. Config key `sealed` in the repo's `.gstack/pregate.json` read from the pushed commit: `{paths: [globs that may never be pushed], transcripts: [globs], answer_markers: [regexes], seed_patterns: [regexes], local_paths: true}`. In a public repo every match refuses the push, naming the file, line and rule id (`sealed.path`, `sealed.transcript`, `sealed.answer`, `sealed.seed`, `sealed.local_path`); in a private repo it warns. Repos without a `sealed` key see no change.
- Visibility: config override, then a 24-hour cache under the state root, then `gh repo view --json visibility` on the push target with a 5 s timeout; `unknown` counts as public.
- Capy's PR tool may not run local hooks, so the vendored Capy skill and the coordinator contract say to run `gstack-pregate --sealed` before any push or PR creation in a repo with a `sealed` config.
- Custody is the primary control: sealed held-out sets stay in owner custody (never on Capy Drive or a Capy machine that can push to the public repo); the guard is defense in depth. Whether Capy's PR tool runs local pre-push hooks is not measured in this wave; PR C's body says so and the coordinator records the measurement. Server-side push protection on the public repo is the owner's call and is listed in the rollout tranche.
- Tests: a scratch public repo (visibility override) with a `sealed` config refuses a push of each kind, naming file and rule (today: exit 0, no output); a private override warns and passes; a repo without the key passes silently.
### Keep what worked (regression pins)
- `install.sh --check` FAIL rows carry exact `fix:` lines: pinned by test/gstack-doctor.test.ts; A3 adds the `found at … not on PATH` row and A1 the static "every named bin exists" test.
- `gstack-autoplan next` prints phase order and the snapshot hash: pinned at test/autoplan-run-recovery.test.ts:207; B1's end-to-end test also asserts every voice's bound result carries the phase's snapshot hash.
- Accepted-obligation blocks that replace earlier ones: pinned at test/autoplan-obligations.test.ts:265, :326, :382; untouched.
- Exact-token gate answers with `REOPENED:` reruns: grammar pinned in test/gate-list.test.ts:50-117; reruns pinned only through JSON, so B2a adds an assertion on the literal `REOPENED: <phases>` line.
- PLAN_REALITY `unreceipted=`: pinned through JSON only; B2d pins the text token.
- Pluggable outside voice: pinned by test/outside-runner-contract.test.ts; B1b's `--unavailable` and `--record` keep the runner labels.
### Not in this wave
- Changing gbrain itself (its `.gstack/` gitignore line, its pregate and ship-policy config, emitting `GSTACK_EVAL_CALLS` lines, `since:` handling in its restamp tool): reported to the coordinator with the worked configs.
- `bun install --production` for the planning-only install (17.2 s to 3.7 s measured), because `build.sh` and `lib/conductor-env-shim.ts` load devDependencies; needs an audit first.
- Writing Garry's Drive skill or his user-scope AGENTS.md: the coordinator copies `capy/skill/gstack/` when Garry asks.
### Validation
1. Each lane: its fail-first tests fail on `dbf400e` and pass on the lane head, plus the focused suites it touches, run on this simulated host (PATH Bun 1.3.14 first for host-shape tests, Bun 1.4.2 for running the suite).
2. Integrator: `bun run gen:skill-docs`, `bun run typecheck`, the context-budget, parity and module-size ratchets, then `bun run test` (full free suite) once on the integrated tree. Paid PR evals (`eval:bg:pr`) on the pushed head for the touched templates.
3. Live acceptance on this machine: a real 1.91.12 checkout upgrades itself at session start through the vendored wrapper (A2); a 3-rev unattended run of this plan's own autoplan output validates (B1a); `submit --record` closes a run reviewed by subagents (B1b).
### Effort
Four lanes in parallel, each about 2–4 hours of agent time; integration and the full suite about 1 hour; no paid spend beyond the PR eval tier and this plan's reviews (host subagents, spend unknown).
### Gate decisions (gate_rev 1, 2026-10-13 00:29 UTC; answered `d1b d3b d4b d5b p1b` by the wave thread under Garry's standing rule: take the recommendation, escalate one-way doors)
Six reviewer passes (CEO, DX and Eng; native claude-opus-5-5 and outside gpt-6.1-sol, both as Capy host subagents) produced 111 findings: 3 Critical, 37 High. The gate offered five direction changes, every one defaulted to "keep your direction", which is item 10 in the field.
- **d1b, accepted: the Drive skill becomes a frozen bootstrapper.** Old checkouts run only the Drive copy, so P1 depends on it. Lane A ships `capy/skill/gstack/` with a short bootstrapper whose only job is clone-or-fetch, fast-forward under the lock when below the remote minimum, and exec the checkout's own wrapper. Copying it into Garry's Drive once is a release step owned by the coordinator (GBRA-40 was already asked to update the skill when this lands); the PR body lists it first under "after merge".
- **d2a, kept: agreeing user challenges recommend the change.** Garry's wave brief asks for exactly that ("When the voices agree, recommend their change, and mark the default's basis"), so it is not an unasked reversal. Amended per outside-ceo F2 and eng F13: "agree" means both voices flagged `user_challenge` on the same finding (reconciled `confirmed`) **and** carry the same `changes` value; anything weaker keeps `a` with the basis shown. The gate grammar gains `all as-is` (keep every direction in one token), and p1's recommended text names the changes it would apply. Reported to the coordinator as a default change.
- **d3b, accepted: three PRs, not one.** PR A (install and upgrade, lane A), PR B (autoplan, lanes B-core and B-aux), PR C (pregate, paid gates, restamp, sealed guard, lane CD). Each takes its own PATCH slot from `bin/gstack-next-version` at ship time; later PRs merge main and restamp. A dispute over PR B's gate defaults cannot hold back the install fix.
- **d4b, accepted: a coordinator-owned rollout tranche.** The field promises need steps outside gstack: the Drive bootstrapper copy, the one-sentence amendment to Garry's user-scope AGENTS.md (lazy install; trailer rule), gbrain's `.gstack/` config (pregate guards, ship policy stamp patterns, sealed paths), gbrain emitting `GSTACK_EVAL_CALLS` lines, and a post-release rerun of the 12-machine survey (each machine reports its `gstack:` trailer after one session start). The wave hands this list to GBRA-40 with worked configs; it does not claim the field promise met until the survey rerun reports.
- **d5b, accepted: paid gates are priced from a non-executing call plan.** CD2 no longer runs the gate command to learn its cost. The repo declares a plan command (`price_command` in `.gstack/pregate.json`, or `--plan-command`) that prints the call-plan lines without making provider calls; gstack runs it under an environment allowlist (`env -i` plus PATH, HOME, TMPDIR, LANG and names passed with `--keep-env`), with network denied when `unshare -rn` is available (and says `network: not contained` when it is not), with a timeout. A repo whose gates declare no plan command gets `EVAL_GATE_UNPRICED` as a recorded warning that needs `--allow-unpriced <reason>` to approve, never a silent pass.
- **p1b: approved with the overrides above.** On 1.91.95 the reopened CEO and Eng phases cannot see a revised plan (item 9, observed in this run: `REOPENED: ceo,eng` then the rerun would reuse snapshot `7ce72ff6…`). Instead of rerunning on stale bytes, the amendments below were verified by one outside pass (Review record), and PR B's own `revise` is exercised on this run as live acceptance.
### Accepted review amendments (replace conflicting text above)
**Delivery (replaces "Delivery shape" ownership).** Four builders, three PRs. Lane A → PR A. Lane B-core (items 6–10: `lib/autoplan-run.ts`, `lib/autoplan-gate.ts`, `lib/autoplan-export.ts`, `lib/autoplan-state.ts`, `lib/autoplan-reconcile.ts`, `lib/gate-list.ts`, `lib/headless-artifacts.ts`, `bin/gstack-autoplan`, `bin/gstack-artifact`, narrow hooks in `bin/gstack-autoplan-snapshot.ts`) and lane B-aux (items 11–13: `detectScope` and scope printing in `lib/autoplan-prompts.ts`, `lib/plan-reality.ts`, `bin/gstack-plan-reality`, `autoplan/DRIVER.md.tmpl` and its resolvers, `autoplan/SKILL.md.tmpl` scope step) → PR B; B-core owns every state transition, B-aux exposes pure functions B-core calls (`checkPlanReality`, `detectScope`) and writes DRIVER.md from the command spec in this plan. Lane CD → PR C. `lib/coordinator-contract.ts` and the vendored Capy skill text have one writer each (CD and A); other lanes send their sentences to the integrator. Shared append-only files are resolved by the integrator, and the generated `bin/gstack-doctor-components.sh` is regenerated, never hand-merged. All lanes use `test/helpers/capy-host.ts` (committed on the base before dispatch).
**Lane A amendments.**
- `--check` stays read-only for the checkout: it may fetch (refs only) to learn the remote minimum, and reports `gstack: fail revision — installed <v> is below the supported minimum <m> (<cause>); fix: bash <wrapper> --for <skill> (fast-forwards) (REVISION_BELOW_MINIMUM)`. Install mode performs the fast-forward. Session start in the vendored skill: run `--check`; when it fails on `revision` or `runtime` with a checkout present, run install mode (about 1 s when nothing changed, thanks to the setup skip). With no checkout and no workflow planned, record the `gstack: fail not-installed` trailer and continue (one trailer grammar, no third shape).
- Never pass `--depth` when fetching into an existing checkout: `git fetch origin <default branch>`, read `git show FETCH_HEAD:capy/support.env`; on "refusing to merge unrelated histories" run `git fetch --unshallow` once and retry. Fixtures clone from `file://` URLs and assert `git rev-parse --is-shallow-repository` is `true`; two consecutive session starts (below, then at, the minimum) are tested.
- The fast-forward, install and check run as one transaction under `.gstack-capy-install.lock`; the installer accepts the held lock through `GSTACK_CAPY_LOCK_HELD=1` instead of deadlocking. Any dirty tracked byte refuses the upgrade (no discarding of a "render footprint"; an installed checkout is clean after setup, verified on this machine). An interrupted setup after the fast-forward leaves the trailer `fail` and the next install run completes it (tested with a setup stub that fails once).
- Kill switch `GSTACK_CAPY_AUTO_UPGRADE=0`: no fast-forward, trailer `fail revision … (auto-upgrade disabled)`. docs/RELEASING.md gains when `GSTACK_MIN_REVISION` may be raised (install-honesty or security fixes only, set at stamp time from VERSION) and a test pins `GSTACK_MIN_REVISION <= VERSION`.
- `bin/gstack-upgrade` reuses `gstack-session-update`'s fast-forward logic and lock (extract the shared part rather than adding a fourth copy), autodetects a Capy install (the marker `gstack-capy-install` writes) and then runs the installer; `--check` offline exits 3 with `UPGRADE_FETCH_FAILED`.
- The trailer and the launch line come from every successful wrapper run, `--check` included: `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<p> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1` plus `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax.
- Workflow refusal is keyed on a verdict file, not a tautology: the wrapper writes `$GSTACK_STATE_ROOT/install-verdict` (trailer, HEAD sha, remote minimum, time; state root through `gstack-paths`). `gstack-skill-start` (after printing `SESSION_KIND`) refuses in `unattended` sessions and in explicit `GSTACK_SESSION_KIND=spawned` sessions with `GSTACK_CAPY=1` when the verdict is missing, `fail`, or for another HEAD, or when the PATH Bun is below the minimum; the preamble gains one rule (regenerated within budget) to stop on `status=refused`. `bin/gstack-autoplan` checks the same verdict at its mutating verbs (`next`, `submit`, `answer`, `revise`, `export`), leaving `status` and `resume` available. `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` overrides and is recorded as a run deviation. Ambient OpenClaw `spawned` sessions without `GSTACK_CAPY` only warn.
- Stale wrapper: the bootstrapper reads `GSTACK_CAPY_MIN_WRAPPER` from `FETCH_HEAD` and, when stale, execs the checkout's vendored wrapper with the same arguments and prints `gstack-capy-skill: owner action — Drive copy is wrapper <n>, minimum <m>; copy <root>/capy/skill/gstack/ (CAPY_WRAPPER_STALE)`. It refuses only when no vendored wrapper exists.
- Pins: installing the verified Bun comes first when no Bun at or above the floor exists; pins are then read with it. An installed Bun that satisfies every applicable constraint is reused, not treated as a conflict. For planning-only workflows (`--for autoplan`, the plan reviews, office-hours) a repo test-pin mismatch is reported as `SKIP pins (planning-only; repo pins bun 1.4.2, launch bun 1.4.3)` instead of failing; workflows that run the repo's tests (`ship`, `review`, `qa`, pregate) keep `pins` required.
- The setup skip keys on the source HEAD sha plus host and model recorded after a successful setup, never on VERSION alone; it does not apply after a fast-forward.
- New bash result codes source `gstack_result_anchor` from the generated components file (no private table in gstack-capy-install); a test resolves every `(CODE)` printed by bash bins to an anchor.
- The handoff line goes after the identifiers: `GSTACK_RESULT` first, then `gstack: ok|fail <rev>`; CD writes it into the coordinator contract.
- Live acceptance runs through the bootstrapper as Drive would hold it, against a real 1.91.12 checkout.
**Lane B-core amendments.**
- B1a: one idempotent `migrateLegacyDecisionIds` under the run lock at state load (journal event once when n > 0), not inside every `exportRun`. run.json records `gstack_version`; a command on a newer gstack prints `GSTACK_VERSION_CHANGED` and runs journaled forward migrations; a downgrade refuses.
- B1b: `submit --record` accepts only: the canonical fence, an exact `No findings.` line, or exactly one findings-shaped markdown table (severity and title columns) or list; more than one candidate shape refuses. A record without `INPUT:` refuses with `RESULT_RECEIPT_MISSING` unless `--attest "<reason>"` is passed; an `INPUT:` that is present but wrong always refuses. Attested binds require `--attempt` and are refused when a `plan_revised` event follows that attempt's dispatch. Validate prints `attested=<n>`; the voice keeps `receipt: attested` and the attest reason. `--attempt` stays optional on the canonical path when exactly one attempt is open.
- `submit --voice outside --unavailable|--failed "<reason>"`; one shared terminal-voice predicate is used by `next`, `submit`, `resume` and close, and terminal outcomes are journaled with their attempt id before the manifest is written, so `next` and `resume` never redispatch or resurrect an unavailable voice.
- B1c `revise`: drift is detected on the Implementation-plan hash, not file bytes. Revise reopens every closed or open phase whose reviewed hash differs from v2 (in practice all reviewed phases plus Eng), resets newly in-scope phases from their skipped stub and updates `required_phases`, stores `plan_heading` in run.json, publishes active plan, state, archives and journal as one recoverable transaction (journal intent first; `resume` completes or rolls back), keeps `.r<rev>` archive names and adds `.v<k>` only on collision (test/autoplan-answer-invalidation.test.ts:106 stays). Revise at `gate_pending` is its own journal event, not p1d; p1d's text points at `revise`.
- Amendment interlock (owns B2a's `APPLY:` lines): accepted plan changes are persisted as pending amendments keyed by decision id; `answer` prints `NEXT: gstack-autoplan revise --out <dir> --plan <source>` instead of `next` when any exist, `next` refuses with `PLAN_AMEND_PENDING` until `revise` binds a changed Implementation-plan hash or `--withdraw <decision id>` drops one, and `export` refuses `complete` with pending amendments.
- Partial replies resolve against persisted decisions: an earlier override is never overwritten by a later `all`, only unresolved items default, and the gate stays pending until every approval settles; validate rejects `complete` with a pending approval.
- Carry-forward uses a stable origin key (phase, finding id, snapshot hash, option text hash), not the display label; only unchanged proposals from phases that did not rerun carry forward.
- `changes` is wired through the result parser, canonical rows, the findings schema and decisions; `recommended_basis` is stored on gate items and decisions.
- `submit` records `checkPlanReality` on the active Implementation-plan hash, cached by hash and checker version and invalidated by `revise`.
**Lane B-aux amendments.**
- B2c: never write the source plan. Flags persist in run.json for the run (revise inherits); a `<!-- gstack-autoplan-scope: … -->` marker in the plan, if the author puts one there (first line, before any heading), is read; `next` prints `SCOPE_PIN: add <marker> as the first line of <file> to keep these flags`. UI evidence is visual context (rendered file paths, or a UI term next to a visual noun: page, layout, style, pixel, view, browser, CSS), not a three-term threshold; tests cover non-visual CLI prose, quoted paths and real rendered changes.
- B2d: `unreceipted_numbers=<n>` with one `NUMBER_UNRECEIPTED: <clause>` line each, scoped to measured quantities (a number with a unit: s, ms, %, $, USD, tokens, ×, MB, GB, or after measured/costs/takes/took); versions, file:line, ids and sha receipts are excluded. The sha alternative in `RECEIPT_RE` needs at least one a–f letter or a commit/backtick context. The verdict keeps the existing session-kind mapping (`incomplete` unattended, `repair` otherwise). A false-positive test runs the checker over this plan and pins a small count.
- B2e: DRIVER.md is the documented unattended entry for every host, printed as `DRIVER: <root>/autoplan/DRIVER.md` in `gstack-autoplan --help` and on the first `next`; installed-layout asset resolution covers it (runtime asset helper), and it is registered in the generator's normal, out-dir and dry-run inventories. The banned-terms test bans harness terms (`PreToolUse`, `SessionStart`, `AskUserQuestion`, `Skill tool`), not the bare word hook. The vendored Capy SKILL.md gets a line cap in its test.
**Lane CD amendments.**
- CD1: lanes run `gstack-pregate run --allow-repo-commands` (named in the contract, docs/unattended.md and DRIVER.md); required guards cannot be downgraded, missing permission is `incomplete`, guards are read from the base policy, each runs in its own process with a timeout. New check ids are added to the pinned stage list (test/pregate-selection.test.ts:101).
- CD2 per d5b. The call-plan wire format is versioned and documented (`GSTACK_EVAL_CALLS v1: purpose= model= calls= in_tokens= out_tokens= max_out_tokens= scope=full|smoke`), aggregate totals are separate from per-call reservations, a receipt binds the price to the command, head and inputs, and malformed, partial or failed producers refuse. Pricing rows for the newest Sonnet, embedding and Gemini models are added only with a source URL and date; an unpriced model makes the total `unknown`, never zero.
- CD3: verbs `gstack-pregate regate --against <head>` (bare `--against` kept as an alias) and `gstack-pregate sealed`. `REGATE: not-needed` requires stamp-only, no migration collision, and gate evidence for `<head>` (an evidence bundle or tree receipt naming it with the gate lanes passed); missing evidence prints `REGATE: needed (no gate evidence for <head>)`. Stamp eligibility is an explicit file allowlist from the policy (`stamp_files`) before any pattern applies; `stamp_patterns` are literal templates with one `{version}` slot (no free regex), bounded in count and length. Migration collisions use `migrations` policy (`dir`, `id` pattern), `gh pr list --state open --limit 1000 --json number,headRefOid,files`, treat a 100-file list as truncated (fetch that PR's files separately), exclude the PR itself, compare with the base branch's additions, and print `unknown` (which blocks `not-needed`) on any query failure.
- CD3 restamp: `restamp_command` runs as the first stage inside restamp's staging worktree (a clean checkout of HEAD), receives the allocated target version as an argument and in `GSTACK_RESTAMP_VERSION`, the generic version writer is skipped when it is set, the output-confinement check still runs, and a failure or a wrong-version output writes nothing to the branch.
- CD4: the guard enumerates every commit the push publishes relative to the destination (`git rev-list <remote sha or merge-base>..<local sha>`) and evaluates path rules on every path added, renamed or copied in those commits, independent of text (binary and empty files included; add-then-delete is caught). Text rules run on added lines of each commit. The effective policy is the union of the destination base branch's `sealed` config and the pushed one (a push can tighten, never relax); waivers come only from a base-branch allow file (rule, path, sha, reason). Refusals end with `(SEALED_OUTPUT_REFUSED)` and name the visibility override key. `gstack-pregate sealed --range <a..b>` shares the core and is what the Capy skill tells agents to run before any push or PR creation; whether Capy's PR tool runs local hooks is checked on this machine and the result recorded in the PR body. A required server-side check for public eval repos is in the coordinator tranche (d4b).
- State-root paths in new bash bins resolve through `gstack-paths` with the exact guard line; TypeScript uses `resolveStateRoot`.
**New result codes (one table; each gets a troubleshooting anchor).** `UPGRADE_REFUSED`, `UPGRADE_FETCH_FAILED`, `REVISION_BELOW_MINIMUM`, `CAPY_WRAPPER_STALE`, `PLAN_SECTIONS_INVALID`, `PLAN_AMEND_PENDING`, `GSTACK_VERSION_CHANGED` (notice), `EVAL_GATE_UNPRICED`, `EVAL_GATE_OVER_CAP`, `EVAL_DRY_RUN_FAILED`, `PREGATE_GUARD_FAILED`, `SEALED_OUTPUT_REFUSED`. Existing codes are reused where they fit (`RUNTIME_BELOW_MINIMUM`, `RESULT_RECEIPT_MISSING`, `RESULT_FINDINGS_MISSING`).
**Behavior changes (for each PR body's `## Behavior changes`).**
| Change | Old | New | Opt-out |
|---|---|---|---|
| Below-minimum checkout at session start (Capy wrapper install mode) | never pulled | fast-forward-only, then install and check | `GSTACK_CAPY_AUTO_UPGRADE=0`; `--revision` pins |
| Unattended or Capy-spawned workflow with a failing verdict | runs anyway | `status=refused` (skill-start and `gstack-autoplan` mutating verbs) | `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` (recorded) |
| `GSTACK_LAUNCH` line | no PATH; installer only | carries the verified Bun dir and `GSTACK_CAPY=1`; printed by `--check` too | none needed |
| Planning-only `--check` with a repo test-pin mismatch | `fail pins` | `SKIP pins (planning-only …)` | `--require pins` |
| Gate `all` on a UC both voices agree on with the same `changes` | keeps direction | applies the change (`APPLY:` line, revise required) | `all as-is` |
| Rerun user challenges without `changes` | approval items that reopen Eng | auto agent decisions, no reopen | none |
| UI detection | whole file, any two UI words | Implementation plan, visual context | `--ui` |
| PLAN_REALITY | one receipt covers a line | each measured number needs one; rule claims need the rule | none (report-only in `submit`) |
| `gstack-pregate` unknown flags | ignored | exit 2 | none |
| Pre-push hook in repos with a `sealed` config | no sealed check | refuses in public repos | base-branch allow file |
## Review record
Autoplan run `ap-20261012235347` (gstack 1.91.95.0, Capy cloud machine, no Claude Code harness; native claude-opus-5-5 and outside gpt-6.1-sol as host subagents; scope `--no-ui --developer-tool`, order ceo,dx,eng, snapshot `7ce72ff6…0f09d9` for all three phases). CEO: 15 + 10 findings (confirmed 1). DX: 30 + 16 (confirmed 0). Eng: 18 + 22 (confirmed 1). Gate rev 1 answered as recorded above; `REOPENED: ceo,eng`. Full results, consensus files and the gate are kept with the run; the amendments above are the accepted outcome.
### Amendment verification (outside voice gpt-6.1-sol, after gate rev 1)
One outside pass checked the gate decisions and amendments against every Critical and High finding in the six results (4 Critical and 53 High by its count of the fences; the gate summary above undercounted). 35 resolved, 1 declined, 21 open or partial; it ranked 15 builder corrections. Sent to the builders as replacements:
- **CD2:** price from a versioned call-plan data file (`price-gate --plan-file`, or base-policy `price_file`) bound to the gate command, head and inputs. No repository command runs before approval, and `--allow-unpriced` is a recorded owner override that never authorizes spend.
- **CD4 boundary:** published commits are `git rev-list <local> --not --remotes=<remote>`, not a local merge-base. Malformed policy fails closed. Unknown visibility counts as public, and a cached private verdict older than an hour is re-queried before it can downgrade a refusal. P3 promises a local check against accidental publication; hosted CI only detects bypasses after upload.
- **CD3:** `regate` derives reuse only from a freshly evaluated tree receipt, with per-lane verdicts and stamp-sensitive reruns. A delegated restamp requires a clean caller tree.
- **A:** the bootstrapper and the full wrapper live at distinct paths with a lock handoff. The verdict is keyed by checkout realpath and HEAD, and its path is carried in `GSTACK_LAUNCH`. It is required only under `GSTACK_CAPY=1`, so other hosts get the local runtime check. Support metadata resolves through `.source-path`.
- **B-core:** `revise --incorporates <ids>` clears only the named amendments. The UC option b renders both voices' remedies verbatim. One integrated conformance test covers the whole loop.
Declined, with reasons:
- **Fully semantic agreement detection (correction 3).** It cannot be decided mechanically. Rendering both remedies plus `all as-is` leaves the call visible to the owner.
- **Pinning the upgrade to a qualified release sha (correction 9).** Every gstack main commit is a stamped, CI-green release, so the minimum is the policy lever. The plan no longer claims that a checkout of an old sha rolls back migrations.
PR order: A, then B stacked on A (it reads A's verdict helper), and C independent.
### Gate revisions 2 and 3 (on PR B's code)
The run moved to PR B (`capy/gstack-autoplan-wave`). It printed `GSTACK_VERSION_CHANGED` and migrated. `revise --plan` with the amended plan reopened CEO and DX on snapshot `b5d68733…` (Eng was already reopened by the rev 1 answer). `revise --incorporates d1b,…` named rev 1 ids that 1.91.95 never recorded as amendments; it now refuses with `AMENDMENT_UNKNOWN` instead of `PLAN_AMEND_PENDING` (fixed in PR B, with a fail-first test). All three phases reran through `submit` with verified receipts.
- **Gate rev 2:** 75 findings, 3 items. `d1b` accepted: P3 now makes owner custody the primary control for sealed output, and the CD4 notes say that whether Capy's PR tool runs local hooks is unmeasured. `d2a` kept: every phase snapshots the whole plan, so any edit reopens each phase. `p1b`. The change was bound with `revise --incorporates ap-20261012235347-r2-d1` (snapshot `5ad91c4b…`), and CEO, DX and Eng reran with the `DECIDED AT THE GATE` block.
- **Gate rev 3:** 2 items. `all` (approved as-is). The open item `d1` (one voice: High findings that are not user challenges leave no disposition) is recorded as a follow-up, not a change to this wave.
- `export`, then `gstack-artifact validate run.json`: `ARTIFACT_VALID: run=ap-20261012235347 status=complete`, with 3 gate revisions, 2 plan revisions, 1 amendment incorporated, 76 findings, 29 tasks, 11 decisions and none pending.
- Across 9 phase closes, `confirmed=0`: the consensus matcher never paired a native finding with an outside one, so item 10's agreeing-voices default did not fire on a real run. This is a follow-up.
+86 -8
View File
@@ -1126,21 +1126,25 @@ constraint with its source), or change the project's pin.
revision (a VERSION string, tag, branch or commit) and the checkout is at
another. An unmet revision is never `ok`.
**Fix.** Move the checkout: `/gstack-upgrade`, or
**Fix.** Move the checkout: `<checkout>/bin/gstack-upgrade` (the tip of the default
branch; `/gstack-upgrade` interactively), or
`git -C <checkout> fetch origin && git -C <checkout> checkout <ref> && ./setup`.
`bin/gstack-capy-install --revision <ref>` never moves an existing checkout; it
reports `gstack: ok <installed> (requested <ref>; run gstack-upgrade to move)`.
reports `gstack: ok <installed> (requested <ref>; run <checkout>/bin/gstack-upgrade to move)`.
<a id="capy-install-runtime-conflict"></a>
### `gstack-capy-install: Bun conflict: gstack needs >=<minimum>, <project> pins <constraint> (RUNTIME_PIN_CONFLICT; ...)`
**Meaning.** The installer upgrades Bun only when gstack's supported minimum
and the target project's pin agree on a version. Here they do not (for
example the project pins `bun@1.3.14` exactly), so the installer changed
nothing and stopped before `./setup`.
**Meaning.** The installer installs one verified Bun archive: gstack's
supported minimum, or the project's exact pin when that pin is above the
minimum and listed in the message's `verified archives:`. Here the two do not
meet (for example the project pins `bun@1.3.14`, or a version gstack has no
verified archive for), so the installer changed nothing and stopped before
`./setup`.
**Fix.** Raise the project's pin to gstack's supported minimum, or install
gstack on a machine whose Bun the project accepts. `--project <path>` names
**Fix.** Pin a verified version in the project, upgrade gstack to a release
that verifies the pinned version, or install gstack on a machine whose Bun the
project accepts. `--project <path>` names
the project whose pins apply.
<a id="browser-ensure-failed"></a>
@@ -1170,6 +1174,80 @@ or nvm), then re-run `bin/gstack-capy-install`. A checksum mismatch means the
upstream installer changed: update `GSTACK_BUN_INSTALL_SHA` only after
reviewing the new script.
<a id="upgrade-refused"></a>
### `gstack-upgrade: <cause>; nothing was changed; fix: <command> (UPGRADE_REFUSED)`
**Meaning.** `bin/gstack-upgrade` moves a checkout only by fast-forward to the
tip of its remote default branch. It refused before changing anything: the
directory is not a gstack checkout, HEAD is detached or on another branch (a
pin), a tracked file is modified, a local commit is not on the remote branch,
the incoming release's Bun floor is above the Bun on PATH, or another updater
holds `<state root>/.setup-lock`. It never stashes, resets or discards.
**Fix.** Resolve the named cause with the printed command (commit or move the
listed changes, check out the default branch, move local commits to another
branch), then re-run `bin/gstack-upgrade`. A reinstall that failed after the
fast-forward is completed by the next run (`reinstall pending from <version>`).
<a id="upgrade-fetch-failed"></a>
### `gstack-upgrade: could not reach origin (...) (UPGRADE_FETCH_FAILED)`
**Meaning.** The remote default branch could not be listed or fetched (offline,
proxy, credentials), so `--check` cannot say whether the checkout is current.
It never reports `current` without the fetch.
**Fix.** Check network access to the checkout's origin
(`git -C <checkout> remote -v`), then re-run.
<a id="revision-below-minimum"></a>
### `gstack: fail revision — installed <v> is below the supported minimum <m> (<cause>); fix: ... (REVISION_BELOW_MINIMUM)`
**Meaning.** `capy/support.env` on the remote default branch declares the
oldest revision whose installer, trailer and fix lines are honest
(`GSTACK_MIN_REVISION`), and this checkout is older. The Capy skill
bootstrapper reads it from `FETCH_HEAD`, so even a checkout that predates the
file learns it. `--check` only reports (`fast-forward available`); install
mode fast-forwards a clean, unpinned checkout, reinstalls and rechecks under
one lock. The cause names why nothing moved: `pinned by --revision`, `pinned:
HEAD is detached`, `tracked files changed: <files>`, `diverged: ...` or
`auto-upgrade disabled` (`GSTACK_CAPY_AUTO_UPGRADE=0`). `gstack-doctor --check
--min-revision <m>` fails the revision row the same way.
**Fix.** Run the fix clause: the bootstrapper without `--check`, or
`<checkout>/bin/gstack-upgrade`. For a pin, drop `--revision` or pin a
revision at or above the minimum. An interrupted install after the
fast-forward leaves the trailer `fail`; the next install run completes it.
<a id="capy-wrapper-stale"></a>
### `gstack-capy-skill: owner action — Drive copy is wrapper <n>, minimum <m>; copy <root>/capy/skill/gstack/ (CAPY_WRAPPER_STALE)`
**Meaning.** The Capy skill folder that ran (the bootstrapper's
`GSTACK_CAPY_WRAPPER`) is older than the checkout's `GSTACK_CAPY_MIN_WRAPPER`.
The run continues through the checkout's own wrapper (`bin/gstack-capy-session`);
only a checkout with no such wrapper refuses (`gstack: fail wrapper`).
**Fix.** The owner copies `<checkout>/capy/skill/gstack/` over the skill folder
(on Capy, the user-scope Drive `skills/gstack/`). Agents never write it.
<a id="install-unverified"></a>
### `INSTALL: fail verdict — <detail>; fix: ... (INSTALL_UNVERIFIED)`
**Meaning.** On a Capy machine (`GSTACK_CAPY=1`, set by the `GSTACK_LAUNCH`
line), an unattended or spawned workflow starts only with a passing install
verdict (`<state root>/install-verdicts/<sha256 of the checkout path>.json`,
named as `GSTACK_INSTALL_VERDICT`) for this checkout, its current HEAD and a
workflow at least as wide as the one starting: a verdict for `--for autoplan`
does not cover `qa`'s browser, and a planning-only pins SKIP does not cover a
workflow that runs the repo's tests. `gstack-skill-start` printed this row and
`GSTACK_RESULT: skill=<s> status=refused run=-`, exit 3. The same start refuses
an unattended run whose Bun on PATH is below the supported minimum
(`INSTALL: fail runtime ... (RUNTIME_BELOW_MINIMUM)`) on any host.
**Fix.** Run the Capy skill installer with `--for <workflow>` on this machine
and launch with the `GSTACK_LAUNCH` line it prints.
`GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (printed as
`INSTALL_VERDICT: overridden`); record it as a deviation.
---
## Browser
+13
View File
@@ -282,3 +282,16 @@ line, on an ephemeral state root.
`/autoplan` run (the one that produced this wave's plan) and validates clean.
`test/fixtures/multi-agent-wave/INCIDENTS.md` records what went wrong while it
was produced by hand and which validator rule now catches each incident.
## Install admission
An unattended run, and a `GSTACK_SESSION_KIND=spawned` run with `GSTACK_CAPY=1`,
starts only on a supported Bun and, on a Capy machine (`GSTACK_CAPY=1`, set by
the installer's `GSTACK_LAUNCH` line), with a passing install verdict for this
checkout, its HEAD and the workflow. Otherwise `gstack-skill-start` prints
`INSTALL: fail runtime|verdict — <detail>; fix: <command> (<CODE>)` and
`GSTACK_RESULT: skill=<s> status=refused run=-`, and exits 3; stop and run the
fix. `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (`INSTALL_VERDICT:
overridden`), recorded as a deviation. TypeScript entrypoints use
`checkInstallAdmission` from `lib/install-verdict.ts`; the Bash side is
`bin/gstack-install-verdict.sh`. See [capy-install.md](capy-install.md#the-launch-line).
+19
View File
@@ -132,6 +132,17 @@ export const SKILL_REQUIREMENTS: Readonly<Record<string, SkillRequirement>> = {
unfreeze: CLAUDE_ONLY,
};
/**
* Planning-only workflows: they read and review a plan but never run the
* target repository's tests, so a repo test-pin mismatch is reported as
* `SKIP pins (planning-only; ...)` for `--for <skill>` instead of failing.
* Workflows that run the repo's tests (ship, review, qa, pregate) keep `pins`
* required.
*/
export const PINS_ADVISORY_SKILLS: readonly string[] = [
'autoplan', 'plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'plan-devex-review', 'office-hours',
];
/** Rows always evaluated and required, in print order. */
export const CORE_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'core').map(c => c.id);
export const OPTIONAL_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'optional').map(c => c.id);
@@ -201,6 +212,14 @@ export function renderDoctorComponentsSh(): string {
' esac',
'}',
'',
'# gstack_doctor_skill_pins_advisory SKILL — true for a planning-only workflow (a repo test-pin mismatch SKIPs pins).',
'gstack_doctor_skill_pins_advisory() {',
' case "$1" in',
caseArm(PINS_ADVISORY_SKILLS, 'return 0'),
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_result_anchor CODE — the docs/troubleshooting.md anchor for a lib/result-codes.ts code; returns 1 for an unknown code.',
'gstack_result_anchor() {',
' case "$1" in',
+140
View File
@@ -0,0 +1,140 @@
/**
* install-verdict — the TypeScript twin of bin/gstack-install-verdict.sh: the
* one reader of the install verdict gstack-capy-install writes, and the
* install-admission rule gstack-skill-start (Bash) and gstack-autoplan's
* mutating verbs share. test/install-verdict.test.ts pins both sides.
*
* Verdict file: <state root>/install-verdicts/<sha256(source checkout realpath)>.json,
* or the path the GSTACK_LAUNCH line names as GSTACK_INSTALL_VERDICT.
*
* Admission: applies to unattended and spawned sessions. It refuses (exit 3,
* `status=refused`) in unattended sessions and explicit
* GSTACK_SESSION_KIND=spawned sessions with GSTACK_CAPY=1 when the running Bun
* is below gstack's supported minimum and, with GSTACK_CAPY=1 only, when the
* verdict is missing, failing, for another checkout or HEAD, or narrower than
* the workflow. Other spawned sessions only warn.
* GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and sets `overridden` (the caller
* records it as a run deviation).
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { PINS_ADVISORY_SKILLS, SKILL_REQUIREMENTS } from './doctor-components';
import type { ResultCodeName } from './result-codes';
import { resolveStateRoot } from './state-root';
export interface InstallVerdict {
schema_version: 1;
checkout: string;
head: string;
for: string;
required: string[];
pins_planning_only: boolean;
ok: boolean;
trailer: string;
min_revision: string;
launch_path: string;
time: string;
}
export interface InstallAdmission {
/** The session kind is one admission covers (unattended or spawned). */
applies: boolean;
/** Stop: print `line`, then `GSTACK_RESULT: skill=<s> status=refused run=<dir>`, exit 3. */
refuse: boolean;
/** Ambient spawned session: print `line` and continue. */
warn: boolean;
/** GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admitted a failing check; record `detail` as a deviation. */
overridden: boolean;
status: 'ok' | 'runtime' | 'verdict';
detail: string;
code?: ResultCodeName;
/** The `INSTALL: fail|warn …` or `INSTALL_VERDICT: …` line; empty when nothing to say. */
line: string;
}
type Env = Record<string, string | undefined>;
/** The source checkout behind an install root: its recorded .source-path, else the root itself (realpath). */
export function sourceRoot(gstackRoot: string): string {
const recorded = path.join(gstackRoot, '.source-path');
let src = gstackRoot;
if (fs.existsSync(recorded)) {
const line = fs.readFileSync(recorded, 'utf8').split('\n')[0].trim();
if (line && fs.existsSync(line)) src = line;
}
return fs.realpathSync(src);
}
export function installVerdictPath(checkout: string, stateRoot: string): string {
return path.join(stateRoot, 'install-verdicts', `${createHash('sha256').update(checkout).digest('hex')}.json`);
}
export function readInstallVerdict(file: string): InstallVerdict | null {
try {
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
return v && v.schema_version === 1 ? v as InstallVerdict : null;
} catch {
return null;
}
}
const below = (a: string, b: string) => {
const x = a.split(/[.+-]/).map(Number), y = b.split('.').map(Number);
for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) < (y[i] || 0);
return /-/.test(a);
};
function supportedBun(gstackRoot: string): string {
const text = fs.readFileSync(path.join(gstackRoot, 'bin/gstack-bun-version.sh'), 'utf8');
return text.match(/^GSTACK_BUN_TESTED="([0-9.]+)"/m)?.[1] ?? '0.0.0';
}
function verdictProblem(skill: string, gstackRoot: string, env: Env): string {
const src = sourceRoot(gstackRoot);
const file = env.GSTACK_INSTALL_VERDICT || installVerdictPath(src, resolveStateRoot(env));
const v = readInstallVerdict(file);
if (!v) return `no install verdict at ${file} for ${src}`;
if (!v.ok) return `the install verdict says: ${v.trailer}`;
if (v.checkout !== src) return `the install verdict is for checkout ${v.checkout}, not ${src}`;
const head = spawnSync('git', ['-C', src, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout?.trim() ?? '';
if (!head || v.head !== head) return `the install verdict is for another HEAD (${v.head.slice(0, 12)}; ${src} is at ${head.slice(0, 12)})`;
if (v.pins_planning_only && !PINS_ADVISORY_SKILLS.includes(skill)) return `the install verdict is planning-only (pins skipped for ${v.for}) and ${skill} runs the repo's tests`;
const missing = (SKILL_REQUIREMENTS[skill]?.requires ?? []).find(id => !v.required.includes(id));
return missing ? `the install verdict certified for=${v.for}, which does not cover ${missing} that ${skill} needs` : '';
}
/**
* The admission decision for `skill` run from the gstack install at
* `gstackRoot` (the directory holding bin/). `bunVersion` defaults to the Bun
* running this process; `env` to process.env.
*/
export function checkInstallAdmission(opts: { skill: string; gstackRoot: string; env?: Env; bunVersion?: string }): InstallAdmission {
const env = opts.env ?? process.env;
const kind = env.GSTACK_SESSION_KIND === 'unattended' || env.GSTACK_SESSION_KIND === 'spawned' ? env.GSTACK_SESSION_KIND
: env.OPENCLAW_SESSION ? 'spawned' : 'other';
const none: InstallAdmission = { applies: false, refuse: false, warn: false, overridden: false, status: 'ok', detail: '', line: '' };
if (kind === 'other') return none;
const capy = env.GSTACK_CAPY === '1';
const hard = kind === 'unattended' || (env.GSTACK_SESSION_KIND === 'spawned' && capy);
const minimum = supportedBun(opts.gstackRoot);
const bun = opts.bunVersion ?? process.versions.bun ?? '0.0.0';
let status: InstallAdmission['status'] = 'ok', detail = '', fix = '', code: ResultCodeName | undefined;
if (below(bun, minimum)) {
status = 'runtime'; code = 'RUNTIME_BELOW_MINIMUM';
detail = `bun ${bun} is below the supported minimum ${minimum}`;
fix = `run the Capy skill installer (scripts/install.sh --for ${opts.skill}), then launch with its GSTACK_LAUNCH line`;
} else if (capy) {
detail = verdictProblem(opts.skill, opts.gstackRoot, env);
if (detail) {
status = 'verdict'; code = 'INSTALL_UNVERIFIED';
fix = `run the Capy skill installer (scripts/install.sh --for ${opts.skill}) on this machine, then launch with the GSTACK_LAUNCH line it prints`;
}
}
const base = { ...none, applies: true, status, detail, code };
if (status === 'ok') return base;
if (env.GSTACK_ALLOW_UNSUPPORTED_RUNTIME === '1') return { ...base, overridden: true, line: `INSTALL_VERDICT: overridden (GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1): ${detail}` };
const line = `INSTALL: ${hard ? 'fail' : 'warn'} ${status} — ${detail}; fix: ${fix} (${code})`;
return { ...base, refuse: hard, warn: !hard, line };
}
+26
View File
@@ -466,6 +466,32 @@ export const RESULT_CODES = {
summary: 'a `draft direction stands until the owner decides` line names a decision that is still pending',
fix: 'answer the gate (`gstack-autoplan answer`) before closing; the close rewrites only decided lines',
},
// Field wave lane A (items 1-4): gstack-upgrade, the Capy skill bootstrapper, install admission.
UPGRADE_REFUSED: {
anchor: 'upgrade-refused',
summary: 'bin/gstack-upgrade changed nothing: the checkout is dirty, diverged, pinned (detached or off the remote default branch), not a gstack checkout, or the incoming release holds',
fix: 'resolve the named cause (commit or move the listed changes, check out the default branch), then re-run bin/gstack-upgrade; it never stashes or resets',
},
UPGRADE_FETCH_FAILED: {
anchor: 'upgrade-fetch-failed',
summary: 'bin/gstack-upgrade could not fetch the remote default branch, so it cannot tell whether the checkout is current',
fix: 'check network access to the checkout\'s origin (git -C <checkout> remote -v), then re-run bin/gstack-upgrade',
},
REVISION_BELOW_MINIMUM: {
anchor: 'revision-below-minimum',
summary: 'the installed gstack revision is below the supported minimum declared in capy/support.env on the remote default branch',
fix: 'run the Capy skill installer without --check (it fast-forwards a clean, unpinned checkout), or bin/gstack-upgrade; a pinned, dirty or diverged checkout is named in the row',
},
CAPY_WRAPPER_STALE: {
anchor: 'capy-wrapper-stale',
summary: 'the Capy skill copy that ran is older than the checkout\'s GSTACK_CAPY_MIN_WRAPPER',
fix: 'ask the owner to copy <checkout>/capy/skill/gstack/ over the Drive skill folder; the checkout wrapper keeps working meanwhile',
},
INSTALL_UNVERIFIED: {
anchor: 'install-unverified',
summary: 'a Capy session (GSTACK_CAPY=1) has no passing install verdict for this checkout, HEAD and workflow, so the workflow refused to start',
fix: 'run the Capy skill installer with --for <workflow> on this machine and launch with the GSTACK_LAUNCH line it prints',
},
} as const satisfies Record<string, ResultCode>;
export type ResultCodeName = keyof typeof RESULT_CODES;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.95",
"version": "1.91.96",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -16,7 +16,7 @@
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":4},
"scripts/lib/shard-engine.ts": {"blob":"3cd0b1aaa84018b00bf9ea0ce48093f3c8492f66","since":4},
"scripts/test-paid-shards.ts": {"blob":"8ddee5f11ed7856876135520dd8025ab989e2db9","since":4},
"scripts/test-pr-profile.ts": {"blob":"4ae0a4525f1855c331f4a6e6333c72e24e172654","since":4,"nonBehavioral":"FREE_ONLY_PR_FILES gains .gstack/ship-policy.json (selection only: paid cases run in fixture repos and read the policy from origin/<base> of the repo they run in; no verdict rule changed)"},
"scripts/test-pr-profile.ts": {"blob":"d95cffbf216a49d412824e2f518efc34cae015b4","since":4,"nonBehavioral":"PR-lane selection only: free-only files (the vendored Capy skill) skip the paid prompt-coverage rule; no case's verdict or inputs change"},
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":4},
"test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":4},
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":4},
+2
View File
@@ -210,6 +210,7 @@ export const FREE_ONLY_PR_FILES = [
'scripts/free-test-durations.json',
'scripts/paid-test-durations.json',
'scripts/ubicloud/**',
'capy/skill/**', // The vendored Capy skill (Drive bootstrapper): run by Capy agents, read by no paid case; test/capy-skill-wrapper.test.ts covers it.
'tsconfig.test.json', // Read only by `tsc -p tsconfig.test.json` (typecheck:test); Bun's runtime reads tsconfig.json.
'.gitignore', // Changes which untracked files git reports, never tracked content a case reads.
'.gstack/generated.json', // gstack's own regen registry: gstack-regen/gstack-pregate read it from origin/<base> of the repo they run in; paid cases run in fixture repos.
@@ -314,6 +315,7 @@ export function packageChangeOnlyVersion(before: string, after: string): boolean
}
function isPromptFile(file: string): boolean {
if (FREE_ONLY_PR_FILES.some(pattern => matchGlob(file, pattern))) return false;
return file.endsWith('.tmpl') || /(?:^|\/)SKILL\.md$/.test(file)
|| /^[^/]+\/sections\/.+\.md$/.test(file);
}
+244
View File
@@ -0,0 +1,244 @@
/**
* capy/skill/gstack/: the vendored Capy skill. scripts/install.sh is the
* frozen bootstrapper the Drive copy holds: it prints its wrapper revision
* first, learns the supported minimum from origin's default branch
* (`git show FETCH_HEAD:capy/support.env`), fast-forwards a clean, unpinned
* checkout below that minimum in install mode only (--check never moves the
* checkout), and execs the checkout's own full wrapper, bin/gstack-capy-session,
* handing over the held install lock (GSTACK_CAPY_LOCK_HELD=1). Pinned on the
* machine lines (`gstack-capy-skill: wrapper|upgraded|owner action`, the
* `gstack:` trailer as the last line), exit codes and HEAD before/after.
* Fixture: a bare remote whose main moves from A (1.0.0, no capy/ directory:
* an old checkout) to B (1.0.1, capy/support.env minimum 1.0.1, the vendored
* scripts and a stub bin/gstack-capy-install that logs argv and env), a
* depth-1 file:// clone at A, on the simulated Capy host.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { RESULT_CODES } from '../lib/result-codes';
import { capyHost, type CapyHost } from './helpers/capy-host';
const REPO = path.resolve(import.meta.dir, '..');
const SKILL = path.join(REPO, 'capy/skill/gstack');
const BOOT = path.join(SKILL, 'scripts/install.sh');
const hosts: CapyHost[] = [];
afterEach(() => { for (const h of hosts.splice(0)) h.cleanup(); });
const write = (file: string, text: string, mode = 0o644) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, text, { mode });
};
const envValue = (text: string, key: string) => text.match(new RegExp(`^${key}=([0-9.]+)`, 'm'))?.[1] ?? '';
const below = (a: string, b: string) => {
const x = a.split('.').map(Number), y = b.split('.').map(Number);
for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
return false;
};
const STUB_INSTALLER = [
'#!/usr/bin/env bash',
'echo "installer $* lock=${GSTACK_CAPY_LOCK_HELD:-} rev=${GSTACK_CAPY_WRAPPER_REV:-} min=${GSTACK_CAPY_REMOTE_MIN:-} from=${GSTACK_CAPY_UPGRADED_FROM:-}" >> "$WRAP_LOG"',
'cd "$(dirname "$0")/.."',
'if [ "${1:-}" != --check ] && [ -f "$WRAP_FAIL_ONCE" ]; then rm -f "$WRAP_FAIL_ONCE"; echo "gstack: fail claude"; exit 1; fi',
'echo "GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_CAPY=1"',
'echo "gstack: ok $(cat VERSION) for=autoplan"',
].join('\n') + '\n';
function fixture(opts: { minWrapper?: number } = {}) {
const host = capyHost({ systemBun: '1.4.2' });
hosts.push(host);
const git = (args: string[], cwd: string) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const log = path.join(host.base, 'wrap.log');
const seed = path.join(host.base, 'seed');
const bare = path.join(host.base, 'remote.git');
fs.mkdirSync(seed, { recursive: true });
git(['init', '-q', '-b', 'main'], seed);
write(path.join(seed, 'VERSION'), '1.0.0\n');
write(path.join(seed, 'README.md'), 'old gstack\n');
write(path.join(seed, 'setup'), '#!/usr/bin/env bash\nexit 0\n', 0o755);
git(['add', '-A'], seed);
git(['commit', '-qm', 'A'], seed);
const a = git(['rev-parse', 'HEAD'], seed);
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
git(['push', '-q', bare, 'main'], seed);
const root = path.join(host.base, 'gstack');
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
write(path.join(seed, 'VERSION'), '1.0.1\n');
write(path.join(seed, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.1\nGSTACK_CAPY_MIN_WRAPPER=${opts.minWrapper ?? 1}\n`);
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh']) {
if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
}
write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755);
git(['add', '-A'], seed);
git(['commit', '-qm', 'B'], seed);
const b = git(['rev-parse', 'HEAD'], seed);
git(['push', '-q', bare, 'main'], seed);
const env = { ...host.env, GSTACK_CAPY_ROOT: root, GSTACK_CAPY_REPO: `file://${bare}`, WRAP_LOG: log, WRAP_FAIL_ONCE: path.join(host.base, 'fail-once') };
const run = (args: string[] = [], extra: Record<string, string> = {}) => {
const r = spawnSync('bash', [BOOT, ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 120_000 });
const lines = r.stdout.trim().split('\n');
return { ...r, lines, last: lines.at(-1) ?? '' };
};
return {
host, root, bare, a, b, run, git: (args: string[]) => git(args, root),
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
};
}
describe('capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)', () => {
test('the first line names the wrapper revision and path; bash -n passes for the bootstrapper and the full wrapper', () => {
for (const file of [BOOT, path.join(REPO, 'bin/gstack-capy-session')]) {
expect(fs.existsSync(file), file).toBe(true);
const r = spawnSync('bash', ['-n', file], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, `${file}: ${r.stderr}`).toBe(0);
}
const f = fixture();
const r = f.run(['--check', '--for', 'autoplan']);
expect(r.lines[0]).toMatch(new RegExp(`^gstack-capy-skill: wrapper \\d+ \\(${BOOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)$`));
});
test('below the remote minimum: --check is read-only and names the fast-forwarding command; install fast-forwards, installs and ends ok; the next session start is ok without moving', () => {
const f = fixture();
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
const check = f.run(['--check', '--for', 'autoplan']);
expect(check.status, check.stdout + check.stderr).toBe(1);
expect(check.last).toBe(`gstack: fail revision — installed 1.0.0 is below the supported minimum 1.0.1 (fast-forward available); fix: bash ${BOOT} --for autoplan (fast-forwards) (REVISION_BELOW_MINIMUM)`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
expect(f.calls()).toEqual([]);
const install = f.run(['--for', 'autoplan']);
expect(install.status, install.stdout + install.stderr).toBe(0);
expect(install.stdout).toContain('gstack-capy-skill: upgraded 1.0.0 -> 1.0.1');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan');
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=1.0.0`]);
const next = f.run(['--check', '--for', 'autoplan']);
expect(next.status, next.stdout + next.stderr).toBe(0);
expect(next.stdout).not.toContain('upgraded');
expect(next.last).toBe('gstack: ok 1.0.1 for=autoplan');
expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=1 min=1.0.1 from=`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
test('dirty, pinned, diverged or auto-upgrade disabled: no change, fail revision names the cause and the exact commands', () => {
const f = fixture();
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
const dirty = f.run(['--for', 'autoplan']);
expect(dirty.status).toBe(1);
expect(dirty.last).toMatch(/^gstack: fail revision — installed 1\.0\.0 is below the supported minimum 1\.0\.1 \(tracked files changed: README\.md\); fix: .*git -C \S+ status --short.*\(REVISION_BELOW_MINIMUM\)$/);
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
f.git(['checkout', '-q', '--', 'README.md']);
const pinned = f.run(['--for', 'autoplan', '--revision', f.a]);
expect(pinned.status).toBe(1);
expect(pinned.last).toContain('(pinned by --revision');
f.git(['checkout', '-q', '--detach', 'HEAD']);
const detached = f.run(['--for', 'autoplan']);
expect(detached.last).toContain('(pinned: HEAD is detached');
f.git(['checkout', '-q', 'main']);
const disabled = f.run(['--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' });
expect(disabled.status).toBe(1);
expect(disabled.last).toContain('(auto-upgrade disabled)');
expect(disabled.last).toContain('GSTACK_CAPY_AUTO_UPGRADE');
fs.writeFileSync(path.join(f.root, 'README.md'), 'local commit\n');
f.git(['commit', '-qam', 'local']);
const head = f.git(['rev-parse', 'HEAD']);
const diverged = f.run(['--for', 'autoplan']);
expect(diverged.status).toBe(1);
expect(diverged.last).toContain('(diverged: ');
expect(f.git(['rev-parse', 'HEAD'])).toBe(head);
expect(f.git(['stash', 'list'])).toBe('');
expect(f.calls()).toEqual([]);
});
test('an interrupted install after the fast-forward leaves the trailer fail; the next install run completes it', () => {
const f = fixture();
write(path.join(f.host.base, 'fail-once'), '1');
const first = f.run(['--for', 'autoplan']);
expect(first.status).toBe(1);
expect(first.last).toBe('gstack: fail claude');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
const second = f.run(['--for', 'autoplan']);
expect(second.status, second.stdout + second.stderr).toBe(0);
expect(second.last).toBe('gstack: ok 1.0.1 for=autoplan');
});
test('offline: the remote minimum is unknown and said so; an old checkout is never ok', () => {
const f = fixture();
fs.renameSync(f.bare, `${f.bare}.gone`);
const old = f.run(['--check', '--for', 'autoplan']);
expect(old.status).toBe(1);
expect(old.stdout).toContain('remote minimum unknown (fetch failed)');
expect(old.last).toMatch(/^gstack: fail /);
fs.renameSync(`${f.bare}.gone`, f.bare);
expect(f.run(['--for', 'autoplan']).status).toBe(0);
fs.renameSync(f.bare, `${f.bare}.gone`);
const current = f.run(['--check', '--for', 'autoplan']);
expect(current.stdout).toContain('remote minimum unknown (fetch failed)');
expect(f.calls().at(-1)).toContain('min=unknown');
});
test('a stale bootstrapper still runs the checkout wrapper and names the owner action', () => {
const f = fixture({ minWrapper: 99 });
const r = f.run(['--for', 'autoplan']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toMatch(new RegExp(`^gstack-capy-skill: owner action — Drive copy is wrapper \\d+, minimum 99; copy ${f.root}/capy/skill/gstack/ \\(CAPY_WRAPPER_STALE\\)$`, 'm'));
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
});
test('no checkout: --check records the not-installed trailer; install clones and runs the checkout wrapper', () => {
const f = fixture();
fs.rmSync(f.root, { recursive: true, force: true });
const check = f.run(['--check', '--for', 'autoplan']);
expect(check.status).toBe(1);
expect(check.last).toBe(`gstack: fail not-installed — no gstack checkout at ${f.root}; fix: bash ${BOOT} --for autoplan (installs)`);
const install = f.run(['--for', 'autoplan']);
expect(install.status, install.stdout + install.stderr).toBe(0);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan');
});
});
describe('capy skill: vendored text and support file', () => {
const support = fs.existsSync(path.join(REPO, 'capy/support.env')) ? fs.readFileSync(path.join(REPO, 'capy/support.env'), 'utf8') : '';
test('the wrapper revision meets the checkout minimum; GSTACK_MIN_REVISION <= VERSION', () => {
const wrapper = Number(fs.readFileSync(BOOT, 'utf8').match(/^GSTACK_CAPY_WRAPPER=(\d+)$/m)?.[1] ?? 0);
expect(wrapper).toBeGreaterThanOrEqual(Number(envValue(support, 'GSTACK_CAPY_MIN_WRAPPER') || 999));
const min = envValue(support, 'GSTACK_MIN_REVISION');
expect(min).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(below(fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(), min)).toBe(false);
});
test('SKILL.md: frontmatter, line cap, the session protocol tokens, and every command it names exists', () => {
const text = fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8');
expect(text).toMatch(/^---\nname: gstack\ndescription: .+\n---\n/);
expect(text.split('\n').length).toBeLessThanOrEqual(90);
for (const token of ['--check', 'GSTACK_LAUNCH', '^gstack: ok', 'gstack: fail not-installed', 'gstack-pregate sealed', 'autoplan/DRIVER.md', 'first report']) {
expect(text, token).toContain(token);
}
const named = [...text.matchAll(/\b(?:bin\/)?(gstack-[a-z0-9-]+)\b/g)].map(m => m[1]).filter(n => n !== 'gstack-capy-skill');
const missing = [...new Set(named)].filter(n => !fs.existsSync(path.join(REPO, 'bin', n)) && n !== 'gstack-pregate');
expect(missing).toEqual([]);
expect(fs.existsSync(path.join(REPO, 'bin/gstack-pregate'))).toBe(true);
});
test('every (CODE) the install-side bash bins print resolves to a troubleshooting anchor', () => {
const bins = ['bin/gstack-capy-install', 'bin/gstack-upgrade', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', 'capy/skill/gstack/scripts/install.sh'];
const codes = new Set<string>();
for (const rel of bins) for (const m of fs.readFileSync(path.join(REPO, rel), 'utf8').matchAll(/\(([A-Z][A-Z0-9]+_[A-Z0-9_]+)[;)]/g)) codes.add(m[1]);
for (const m of fs.readFileSync(path.join(REPO, 'bin/gstack-install-verdict.sh'), 'utf8').matchAll(/code=([A-Z][A-Z0-9_]+)/g)) codes.add(m[1]);
expect([...codes].sort()).toEqual(expect.arrayContaining(['CAPY_WRAPPER_STALE', 'INSTALL_UNVERIFIED', 'REVISION_BELOW_MINIMUM', 'RUNTIME_BELOW_MINIMUM', 'UPGRADE_FETCH_FAILED', 'UPGRADE_REFUSED']));
expect([...codes].filter(c => !(c in RESULT_CODES))).toEqual([]);
});
});
+1
View File
@@ -8,6 +8,7 @@
"browse": 8961,
"browser-skills/hackernews-frontpage": 371,
"canary": 13520,
"capy/skill/gstack": 2225,
"careful": 1328,
"codex": 15598,
"context-restore": 10995,
+138 -2
View File
@@ -48,7 +48,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
const log = path.join(base, 'calls.log');
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(stub, { recursive: true });
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh']) {
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh']) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.copyFileSync(path.join(REPO, rel), path.join(root, rel));
fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode);
@@ -160,6 +160,31 @@ describe('gstack-capy-install', () => {
expect(conflictRange.stderr).toContain('pins >=2.0.0');
});
test('an exact project pin above the minimum with a verified archive is the Bun installed; an unverified one stops naming the verified versions', () => {
const f = fixture({ bun: '1.3.14' });
const project = path.join(f.base, 'project');
write(path.join(project, '.bun-version'), '1.4.3\n');
sh('git init -q', project);
const zip = path.join(f.base, 'bad.zip');
fs.mkdirSync(path.join(f.base, 'z/bun-linux-x64'), { recursive: true });
script(path.join(f.base, 'z/bun-linux-x64/bun'), ['echo 1.4.3']);
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-linux-x64`).status).toBe(0);
const adopted = run(f, ['--project', project], { STUB_CURL_ZIP: zip });
expect(adopted.stdout).toContain('installing bun 1.4.3 from its release archive');
expect(adopted.stdout).not.toContain("gstack's minimum 1.4.3");
expect(f.calls().filter(l => l.startsWith('curl '))).toEqual([expect.stringContaining('https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux')]);
expect(adopted.status).toBe(3);
expect(adopted.stderr).toContain('SHA-256 mismatch');
const g = fixture({ bun: '1.3.14' });
write(path.join(project, '.bun-version'), '1.4.9\n');
const unverified = run(g, ['--project', project]);
expect(unverified.status).toBe(3);
expect(unverified.stderr).toContain(`${project}/.bun-version pins exactly 1.4.9`);
expect(unverified.stderr).toContain('verified archives: 1.4.2 1.4.3');
expect(unverified.stderr).toContain('(RUNTIME_PIN_CONFLICT; ');
expect(g.calls().some(l => l.startsWith('curl ') || l.startsWith('setup '))).toBe(false);
});
test('Bun below the minimum: the exact release archive is downloaded and SHA-256 verified; a mismatch installs nothing', () => {
const f = fixture({ bun: '1.3.14' });
const zip = path.join(f.base, 'bad.zip');
@@ -200,7 +225,7 @@ describe('gstack-capy-install', () => {
const before = sh('git rev-parse HEAD', f.root).stdout.trim();
const r = run(f, ['--revision', 'v9.9.9.9']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(lastLine(r.stdout)).toBe('gstack: ok 1.2.3 for=autoplan (requested v9.9.9.9; run gstack-upgrade to move)');
expect(lastLine(r.stdout)).toBe(`gstack: ok 1.2.3 for=autoplan (requested v9.9.9.9; run ${f.root}/bin/gstack-upgrade to move)`);
expect(sh('git rev-parse HEAD', f.root).stdout.trim()).toBe(before);
expect(run(f, ['--revision', '1.2.3']).stdout).not.toContain('requested');
expect(lastLine(run(f, ['--revision', before]).stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
@@ -221,6 +246,117 @@ describe('gstack-capy-install', () => {
});
});
// Field wave lane A (items 3 and 5): the launch line carries the verified Bun
// the trailer certified, an installed Bun is reused, a project's exact CI pin
// is installed alongside a different PATH Bun, ./setup is skipped when this
// HEAD was already set up, and a verdict file records what was certified.
describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verdict', () => {
const zipBun = (f: ReturnType<typeof fixture>, version: string, runsTs = false) => {
const target = sh('grep -q avx2 /proc/cpuinfo && echo linux-x64 || echo linux-x64-baseline').stdout.trim();
const zip = path.join(f.base, `bun-${version}.zip`);
fs.mkdirSync(path.join(f.base, `z/bun-${target}`), { recursive: true });
script(path.join(f.base, `z/bun-${target}/bun`), runsTs
? [`[ "$1" = --version ] && { echo ${version}; exit 0; }`, `exec "${process.execPath}" "$@"`]
: [`echo ${version}`]);
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-${target}`).status).toBe(0);
const sha = sh(`sha256sum "${zip}" | cut -d' ' -f1`).stdout.trim();
const versions = path.join(f.root, 'bin/gstack-bun-version.sh');
fs.writeFileSync(versions, fs.readFileSync(versions, 'utf8').replace(new RegExp(`(\\s*${target}\\) echo )[0-9a-f]{64}`), `$1${sha}`));
sh('git add -A && git -c user.email=t@t -c user.name=t commit -qm sha', f.root);
return zip;
};
const verdicts = (f: ReturnType<typeof fixture>) => {
const dir = path.join(f.home, '.gstack-state/install-verdicts');
return fs.existsSync(dir) ? fs.readdirSync(dir).map(n => path.join(dir, n)) : [];
};
test.skipIf(process.platform !== 'linux')('after installing Bun the launch line carries its PATH; a second run reuses it (one download) and skips setup on the same HEAD', () => {
const f = fixture({ bun: '1.3.14' });
const zip = zipBun(f, '1.4.2');
const bunDir = path.join(f.home, '.bun/bin');
const first = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
expect(first.status, first.stdout + first.stderr).toBe(0);
expect(first.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=${path.join(f.home, '.gstack-state')} GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=`);
expect(first.stdout).toContain(`GSTACK_LAUNCH_PATH: ${bunDir}`);
const second = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
expect(second.status, second.stdout + second.stderr).toBe(0);
expect(second.stdout).toContain(`bun 1.4.2 at ${bunDir}/bun`);
expect(second.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH `);
expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(2);
expect(second.stdout).toContain('setup skipped');
expect(lastLine(second.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
});
test('setup runs only on the first of two runs on one HEAD; a new HEAD, a failed doctor or --with-browser runs it again', () => {
const f = fixture();
expect(run(f).status).toBe(0);
expect(run(f).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(2);
expect(run(f, [], { STUB_DOCTOR_FAIL: '1' }).status).toBe(1);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(4);
write(path.join(f.root, 'NEW'), 'x\n');
sh('git add NEW && git -c user.email=t@t -c user.name=t commit -qm next', f.root);
expect(run(f).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(6);
expect(run(f, ['--with-browser']).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(8);
});
test('a setup that fails once leaves the trailer fail and records nothing; the next run sets up again and ends ok', () => {
const f = fixture();
const failed = run(f, [], { STUB_SETUP_EXIT: '1' });
expect(failed.status).toBe(1);
expect(lastLine(failed.stdout)).toMatch(/^gstack: fail setup/);
expect(run(f).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(3);
});
test.skipIf(process.platform !== 'linux')('PATH Bun 1.4.3 and a CI pin of 1.4.2: the pinned verified Bun is installed alongside and launched with its PATH', () => {
const f = fixture({ bun: '1.4.3' });
script(path.join(f.stub, 'bun'), ['[ "$1" = --version ] && { echo 1.4.3; exit 0; }', `exec "${process.execPath}" "$@"`]);
for (const rel of ['bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts']) write(path.join(f.root, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
const zip = zipBun(f, '1.4.2', true);
const project = path.join(f.base, 'project');
write(path.join(project, 'package.json'), '{ "engines": { "bun": ">=1.4.0" } }\n');
write(path.join(project, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.2\n');
const r = run(f, ['--for', 'review', '--project', project], { STUB_CURL_ZIP: zip });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1);
expect(f.calls().find(l => l.startsWith('curl '))).toContain('bun-v1.4.2/bun-');
expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(true);
expect(r.stdout).toContain(`GSTACK_LAUNCH: PATH=${path.join(f.home, '.bun/bin')}:$PATH `);
const other = fixture({ bun: '1.4.3' });
script(path.join(other.stub, 'bun'), ['[ "$1" = --version ] && { echo 1.4.3; exit 0; }', `exec "${process.execPath}" "$@"`]);
for (const rel of ['bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts']) write(path.join(other.root, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
write(path.join(project, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.1\n');
const conflict = run(other, ['--project', project]);
expect(conflict.status).toBe(3);
expect(conflict.stderr).toContain('pins exactly 1.4.1');
expect(conflict.stderr).toContain('(RUNTIME_PIN_CONFLICT; ');
});
test('the verdict file records checkout, HEAD, workflow and trailer, keyed by the checkout; --check certifies without setup or downloads', () => {
const f = fixture();
const r = run(f, ['--for', 'autoplan']);
expect(r.status, r.stdout + r.stderr).toBe(0);
const files = verdicts(f);
expect(files).toHaveLength(1);
expect(r.stdout).toContain(`GSTACK_INSTALL_VERDICT=${files[0]}`);
const v = JSON.parse(fs.readFileSync(files[0], 'utf8'));
expect(v).toMatchObject({ schema_version: 1, checkout: f.root, head: sh('git rev-parse HEAD', f.root).stdout.trim(), for: 'autoplan', ok: true, trailer: 'gstack: ok 1.2.3 for=autoplan' });
const setups = f.calls().filter(l => l.startsWith('setup ')).length;
const c = run(f, ['--check', '--for', 'autoplan']);
expect(c.status, c.stdout + c.stderr).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup ')).length).toBe(setups);
expect(c.stdout).toContain('GSTACK_LAUNCH: ');
expect(lastLine(c.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
const failed = run(f, ['--check', '--for', 'autoplan'], { STUB_DOCTOR_FAIL: '1' });
expect(failed.status).toBe(1);
expect(JSON.parse(fs.readFileSync(files[0], 'utf8'))).toMatchObject({ ok: false, trailer: 'gstack: fail codex' });
});
});
function run(f: ReturnType<typeof fixture>, args: string[] = [], extra: Record<string, string> = {}, cwd?: string) {
return f.run(args, extra, cwd);
}
+67
View File
@@ -701,3 +701,70 @@ describe('gstack-doctor --check', () => {
expect(doctor(f, ['--help']).out).toContain('--check');
});
});
// Field wave lane A: the trailer agrees with the Bun workflows run (A3), a
// remote minimum fails the revision row (A2), and planning-only workflows do
// not fail on a repo test-pin mismatch (A3 amendment).
describe('gstack-doctor --check: launch Bun, supported minimum, planning-only pins', () => {
const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim();
const check = (f: Fixture, args: string[], extra: Record<string, string> = {}) => {
const r = doctor(f, ['--check', ...args], extra);
const lines = r.out.split('\n');
const rows = Object.fromEntries(lines.filter(l => /^(PASS|FAIL|SKIP) /.test(l)).map(l => {
const m = l.match(/^(PASS|FAIL|SKIP) ([a-z-]+)(?:\s(.*))?$/)!;
return [m[2], { status: m[1], rest: m[3] ?? '' }];
}));
return { status: r.status, out: r.out, rows, trailer: lines.filter(l => l.startsWith('gstack: ')).at(-1) ?? '' };
};
/** A verified Bun in $HOME/.bun/bin (off PATH) that answers --version and runs TypeScript. */
const verifiedBun = (f: Fixture, version = '1.4.2') =>
script(path.join(f.home, '.bun/bin/bun'), [`[ "$1" = --version ] && { echo "${version}"; exit 0; }`, `exec "${process.execPath}" "$@"`]);
test('PATH Bun 1.3.14 with a verified 1.4.2 off PATH: runtime FAILs naming where it was found; with the launch PATH it PASSes', () => {
const f = makeFixture();
verifiedBun(f);
const fresh = check(f, [], { STUB_BUN_VERSION: '1.3.14' });
expect(fresh.rows.runtime.status).toBe('FAIL');
const home = bashPath(f.home);
expect(fresh.rows.runtime.rest).toContain(`bun 1.4.2 found at ${home}/.bun/bin (not on PATH)`);
expect(fresh.rows.runtime.rest).toContain(`fix: export PATH="${home}/.bun/bin:$PATH" or prefix commands with the GSTACK_LAUNCH line`);
expect(fresh.rows.runtime.rest).toContain('(RUNTIME_BELOW_MINIMUM; ');
const launched = check(f, [], { STUB_BUN_VERSION: '1.3.14', PATH: `${f.home}/.bun/bin${path.delimiter}${f.env.PATH}` });
expect(launched.rows.runtime.status, launched.out).toBe('PASS');
expect(launched.rows.runtime.rest).toContain(`at ${home}/.bun/bin/bun`);
});
test('--min-revision: below FAILs with REVISION_BELOW_MINIMUM naming the upgrade command; at or above PASSes; unknown says so and never claims the minimum', () => {
const f = makeFixture();
const below = check(f, ['--min-revision', '99.0.0.0']);
expect(below.status).toBe(1);
expect(below.rows.revision.status).toBe('FAIL');
expect(below.rows.revision.rest).toContain(`installed ${VERSION}`);
expect(below.rows.revision.rest).toContain('below the supported minimum 99.0.0.0');
expect(below.rows.revision.rest).toContain(`${bashPath(f.root)}/bin/gstack-upgrade`);
expect(below.rows.revision.rest).toContain('(REVISION_BELOW_MINIMUM; ');
expect(below.trailer).toBe('gstack: fail revision');
const wrapped = check(f, ['--min-revision', '99.0.0.0'], { GSTACK_CAPY_WRAPPER_PATH: '/drive/skills/gstack/scripts/install.sh' });
expect(wrapped.rows.revision.rest).toContain('fix: bash /drive/skills/gstack/scripts/install.sh (fast-forwards)');
const at = check(f, ['--min-revision', VERSION]);
expect(at.rows.revision.status, at.out).toBe('PASS');
expect(at.rows.revision.rest).toContain(`>= supported minimum ${VERSION}`);
const unknown = check(f, ['--min-revision', 'unknown']);
expect(unknown.rows.revision.status).toBe('PASS');
expect(unknown.rows.revision.rest).toContain('remote minimum unknown (fetch failed)');
expect(unknown.rows.revision.rest).not.toContain('>= supported minimum');
});
test('planning-only workflows SKIP a repo test-pin mismatch; test-running workflows and --require keep pins required', () => {
const f = makeFixture();
const project = path.join(f.base, 'project');
write(path.join(project, '.bun-version'), '1.4.2\n');
const planning = check(f, ['--for', 'autoplan', '--project', project], { STUB_BUN_VERSION: '1.4.3' });
expect(planning.rows.pins.status, planning.out).toBe('SKIP');
expect(planning.rows.pins.rest).toMatch(/^\(planning-only; repo pins .*1\.4\.2.*, launch bun 1\.4\.3\)$/);
for (const args of [['--for', 'review'], ['--for', 'ship'], ['--require', 'claude']]) {
const tests = check(f, [...args, '--project', project], { STUB_BUN_VERSION: '1.4.3' });
expect(tests.rows.pins.status, args.join(' ')).toBe('FAIL');
}
});
});
+108
View File
@@ -394,6 +394,7 @@ describe('gstack-skill-start behavior', () => {
expect(out).not.toContain('UPGRADE_AVAILABLE');
const endOut = end({ GSTACK_SESSION_KIND: 'unattended' });
expect(endOut).toMatch(/^UPGRADE_AVAILABLE: 9\.9\.9; run \/gstack-upgrade$/m);
expect(endOut).toMatch(/^UPGRADE_COMMAND: \/.*\/bin\/gstack-upgrade$/m);
expect(endOut).toMatch(/^ARTIFACTS_SYNC: skipped \(unattended session\)$/m);
expect(fs.existsSync(log)).toBe(false);
expect(fs.existsSync(path.join(gh, 'analytics', 'skill-usage.jsonl'))).toBe(false);
@@ -764,3 +765,110 @@ describe('gstack-skill-end', () => {
expect(fs.existsSync(pending)).toBe(false);
});
});
// Field wave lane A (item 2): an unattended or Capy-spawned workflow refuses
// to start on an unsupported PATH Bun, and on a Capy machine (GSTACK_CAPY=1)
// without a matching install verdict for this checkout, HEAD and workflow.
describe('gstack-skill-start install admission', () => {
const { spawnSync } = require('child_process') as typeof import('child_process');
const { createHash } = require('crypto') as typeof import('crypto');
const { writeBunStub } = require('./helpers/capy-host') as typeof import('./helpers/capy-host');
const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
const real = fs.realpathSync(ROOT);
const scratch = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-admit-'));
fs.writeFileSync(path.join(dir, 'config.yaml'), 'update_check: false\n');
return dir;
};
const stubDir = (version: string) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-bun-'));
writeBunStub(path.join(dir, 'bun'), version);
return dir;
};
const verdict = (gh: string, over: Record<string, unknown> = {}, file?: string) => {
const target = file ?? path.join(gh, 'install-verdicts', `${createHash('sha256').update(real).digest('hex')}.json`);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, JSON.stringify({
schema_version: 1, checkout: real, head, for: 'autoplan',
required: ['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision'],
pins_planning_only: false, ok: true, trailer: 'gstack: ok 1.91.95.0 for=autoplan', min_revision: '1.91.95.0', launch_path: '', time: '2026-10-12T00:00:00Z', ...over,
}, null, 1) + '\n');
return target;
};
const start = (gh: string, skill: string, env: Record<string, string>, bin = START) => spawnSync(bin, ['--skill', skill], {
timeout: 30_000, encoding: 'utf-8', cwd: tmpHome,
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: gh, ...env },
});
test('unattended with Bun 1.3.14 first on PATH: the runtime row with its fix, status=refused, exit 3; interactive keeps running', () => {
const gh = scratch();
const bun = stubDir('1.3.14');
const PATH = `${bun}${path.delimiter}${process.env.PATH}`;
const r = start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'unattended', PATH });
expect(r.status, r.stdout + r.stderr).toBe(3);
expect(r.stdout).toMatch(/^SESSION_KIND: unattended$/m);
expect(r.stdout).toContain(`INSTALL: fail runtime — bun 1.3.14 at ${bun}/bun is below the supported minimum 1.4.2`);
expect(r.stdout).toContain('(RUNTIME_BELOW_MINIMUM)');
expect(r.stdout.trim().split('\n').at(-1)).toBe('GSTACK_RESULT: skill=autoplan status=refused run=-');
const human = start(gh, 'autoplan', { PATH });
expect(human.status).toBe(0);
expect(human.stdout).not.toContain('status=refused');
const openclaw = start(gh, 'autoplan', { OPENCLAW_SESSION: '1', PATH });
expect(openclaw.status).toBe(0);
expect(openclaw.stdout).toContain('INSTALL: warn runtime');
const overridden = start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'unattended', PATH, GSTACK_ALLOW_UNSUPPORTED_RUNTIME: '1' });
expect(overridden.status).toBe(0);
expect(overridden.stdout).toContain('INSTALL_VERDICT: overridden (GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1)');
});
test('GSTACK_CAPY=1: a missing, failing, other-HEAD or narrower verdict refuses; a matching one admits; other unattended hosts need no verdict', () => {
const gh = scratch();
const capy = { GSTACK_SESSION_KIND: 'unattended', GSTACK_CAPY: '1' };
const missing = start(gh, 'autoplan', capy);
expect(missing.status, missing.stdout + missing.stderr).toBe(3);
expect(missing.stdout).toContain('INSTALL: fail verdict — no install verdict');
expect(missing.stdout).toContain('(INSTALL_UNVERIFIED)');
expect(start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0);
verdict(gh);
const ok = start(gh, 'autoplan', capy);
expect(ok.status, ok.stdout + ok.stderr).toBe(0);
expect(ok.stdout).toMatch(/^INSTALL_VERDICT: ok gstack: ok 1\.91\.95\.0 for=autoplan$/m);
const spawned = start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'spawned', GSTACK_CAPY: '1' });
expect(spawned.status).toBe(0);
verdict(gh, { ok: false, trailer: 'gstack: fail runtime' });
const failed = start(gh, 'autoplan', capy);
expect(failed.status).toBe(3);
expect(failed.stdout).toContain('gstack: fail runtime');
verdict(gh, { head: '0'.repeat(40) });
const moved = start(gh, 'autoplan', capy);
expect(moved.status).toBe(3);
expect(moved.stdout).toContain('another HEAD');
verdict(gh, { pins_planning_only: true });
expect(start(gh, 'autoplan', capy).status).toBe(0);
const tests = start(gh, 'review', capy);
expect(tests.status).toBe(3);
expect(tests.stdout).toContain('planning-only');
verdict(gh);
const browser = start(gh, 'qa', capy);
expect(browser.status).toBe(3);
expect(browser.stdout).toContain('browse-bundle');
const elsewhere = verdict(gh, {}, path.join(gh, 'named.json'));
fs.rmSync(path.join(gh, 'install-verdicts'), { recursive: true, force: true });
expect(start(gh, 'autoplan', { ...capy, GSTACK_INSTALL_VERDICT: elsewhere }).status).toBe(0);
});
test('an installed runtime root follows its .source-path to the source checkout for the verdict and HEAD', () => {
const gh = scratch();
const runtime = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-runtime-'));
fs.symlinkSync(path.join(ROOT, 'bin'), path.join(runtime, 'bin'));
fs.writeFileSync(path.join(runtime, '.source-path'), `${ROOT}\n`);
fs.copyFileSync(path.join(ROOT, 'VERSION'), path.join(runtime, 'VERSION'));
verdict(gh);
const r = start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'unattended', GSTACK_CAPY: '1' }, path.join(runtime, 'bin/gstack-skill-start'));
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('INSTALL_VERDICT: ok');
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* bin/gstack-upgrade: the deterministic, unattended upgrade every fix line
* names. Pinned on its machine lines (`gstack-upgrade: current|behind|upgraded`),
* exit codes (0 ok, 1 behind on --check, 3 refused with nothing changed) and
* what it refuses: a dirty tracked byte, a diverged local commit, a pinned
* (detached or off-default) checkout, an unreachable remote. Never stash,
* never reset: HEAD, file bytes and the reflog are unchanged on refusal.
* Fixture: a bare remote with commits A (1.0.0) and B (1.0.1), a depth-1
* file:// clone at A, ./setup and bin/gstack-capy-install stubs that log argv,
* on the simulated Capy host (test/helpers/capy-host.ts).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { capyHost, type CapyHost } from './helpers/capy-host';
const REPO = path.resolve(import.meta.dir, '..');
const DEPS = [
'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh',
'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts',
];
const hosts: CapyHost[] = [];
afterEach(() => { for (const h of hosts.splice(0)) h.cleanup(); });
const write = (file: string, text: string, mode = 0o644) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, text, { mode });
};
function fixture() {
const host = capyHost({ systemBun: '1.4.2' });
hosts.push(host);
const git = (args: string[], cwd: string) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const log = path.join(host.base, 'calls.log');
const seed = path.join(host.base, 'seed');
const bare = path.join(host.base, 'remote.git');
fs.mkdirSync(seed, { recursive: true });
git(['init', '-q', '-b', 'main'], seed);
for (const rel of DEPS) {
if (!fs.existsSync(path.join(REPO, rel))) continue;
fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true });
fs.copyFileSync(path.join(REPO, rel), path.join(seed, rel));
fs.chmodSync(path.join(seed, rel), fs.statSync(path.join(REPO, rel)).mode);
}
write(path.join(seed, 'VERSION'), '1.0.0\n');
write(path.join(seed, 'README.md'), 'gstack fixture\n');
write(path.join(seed, 'bin/gstack-config'), '#!/usr/bin/env bash\nexit 0\n', 0o755);
write(path.join(seed, 'setup'), '#!/usr/bin/env bash\necho "setup $*" >> "$UPGRADE_LOG"\nexit "${STUB_SETUP_EXIT:-0}"\n', 0o755);
git(['add', '-A'], seed);
git(['commit', '-qm', 'A'], seed);
const a = git(['rev-parse', 'HEAD'], seed);
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
git(['push', '-q', bare, 'main'], seed);
const root = path.join(host.base, 'gstack');
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
write(path.join(seed, 'VERSION'), '1.0.1\n');
git(['commit', '-qam', 'B'], seed);
const b = git(['rev-parse', 'HEAD'], seed);
git(['push', '-q', bare, 'main'], seed);
const env = { ...host.env, UPGRADE_LOG: log };
const run = (args: string[] = [], extra: Record<string, string> = {}) =>
spawnSync('bash', [path.join(root, 'bin/gstack-upgrade'), ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 60_000 });
return {
host, root, bare, a, b, run, git: (args: string[]) => git(args, root),
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
};
}
describe('gstack-upgrade', () => {
test('--check: behind prints both versions and the commit count, exit 1, writes nothing', () => {
const f = fixture();
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
const r = f.run(['--check']);
expect(r.status, r.stdout + r.stderr).toBe(1);
expect(r.stdout).toContain('gstack-upgrade: behind 1.0.0 -> 1.0.1 (1 commits)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
expect(f.calls()).toEqual([]);
});
test('default: fast-forwards to the remote default branch and runs setup --refresh-registered; --check is then current', () => {
const f = fixture();
const r = f.run();
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(r.stdout).toContain('gstack-upgrade: upgraded 1.0.0 -> 1.0.1');
expect(f.calls()).toEqual(['setup --refresh-registered']);
const again = f.run(['--check']);
expect(again.status).toBe(0);
expect(again.stdout).toContain('gstack-upgrade: current 1.0.1');
});
test('a reinstall that fails after the fast-forward exits 1; the next run completes it instead of saying current', () => {
const f = fixture();
const failed = f.run([], { STUB_SETUP_EXIT: '1' });
expect(failed.status).toBe(1);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(failed.stderr).toContain('reinstall failed');
expect(f.run(['--check']).status).toBe(1);
const retry = f.run();
expect(retry.status, retry.stdout + retry.stderr).toBe(0);
expect(retry.stdout).toContain('reinstall pending from 1.0.0');
expect(f.calls()).toEqual(['setup --refresh-registered', 'setup --refresh-registered']);
expect(f.run().stdout).toContain('gstack-upgrade: current 1.0.1');
});
test('a Capy install (the installer marker) runs bin/gstack-capy-install instead of setup', () => {
const f = fixture();
write(path.join(f.root, 'bin/gstack-capy-install'), '#!/usr/bin/env bash\necho "capy-install $*" >> "$UPGRADE_LOG"\necho "gstack: ok $(cat VERSION) for=autoplan"\n', 0o755);
write(path.join(f.root, '.git/gstack-capy-install.state'), `head=${f.a}\n`);
const r = f.run();
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(f.calls()).toEqual(['capy-install']);
expect(r.stdout.trim().split('\n').at(-1)).toBe('gstack: ok 1.0.1 for=autoplan');
});
test('a dirty tracked byte refuses with UPGRADE_REFUSED: exit 3, HEAD, bytes and reflog unchanged', () => {
const f = fixture();
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
const reflog = f.git(['reflog']);
const r = f.run();
expect(r.status).toBe(3);
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
expect(r.stderr).toContain('README.md');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
expect(f.git(['reflog'])).toBe(reflog);
expect(f.git(['stash', 'list'])).toBe('');
expect(f.calls()).toEqual([]);
});
test('a diverged local commit refuses with UPGRADE_REFUSED: never stash, never reset', () => {
const f = fixture();
fs.writeFileSync(path.join(f.root, 'README.md'), 'local commit\n');
f.git(['commit', '-qam', 'local']);
const head = f.git(['rev-parse', 'HEAD']);
const reflog = f.git(['reflog']);
const r = f.run();
expect(r.status).toBe(3);
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(head);
expect(f.git(['reflog'])).toBe(reflog);
expect(f.calls()).toEqual([]);
});
test('a detached (pinned) checkout refuses; an unreachable remote is UPGRADE_FETCH_FAILED (exit 3) for --check and default', () => {
const f = fixture();
f.git(['checkout', '-q', '--detach', 'HEAD']);
const pinned = f.run();
expect(pinned.status).toBe(3);
expect(pinned.stderr).toContain('(UPGRADE_REFUSED)');
expect(pinned.stderr).toContain('detached');
f.git(['checkout', '-q', 'main']);
fs.rmSync(f.bare, { recursive: true, force: true });
for (const args of [['--check'], []]) {
const offline = f.run(args);
expect(offline.status).toBe(3);
expect(offline.stderr).toContain('(UPGRADE_FETCH_FAILED)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
}
});
test('every bin a trailer or fix line names exists in the repo', () => {
const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md',
'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'];
const named = new Set<string>();
for (const rel of sources) {
expect(fs.existsSync(path.join(REPO, rel)), rel).toBe(true);
const lines = fs.readFileSync(path.join(REPO, rel), 'utf8').split('\n').filter(l => /fix:|\brun |gstack: (ok|fail)/.test(l));
for (const m of lines.join('\n').matchAll(/(?:bin\/|\brun |\bfix: )(gstack-[a-z0-9-]+(?:\.sh|\.ts)?)\b/g)) named.add(m[1]);
}
expect(named.has('gstack-upgrade')).toBe(true);
const missing = [...named].filter(n => !fs.existsSync(path.join(REPO, 'bin', n)));
expect(missing).toEqual([]);
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* capy-host — one simulated Capy cloud host for every test that exercises
* install, upgrade, runtime or unattended-workflow behavior. The shape is the
* field host: Bun 1.3.14 first on PATH (the image's /usr/local/bin/bun), no
* Codex CLI, an ephemeral home, and optionally a verified Bun 1.4.2 in
* `$HOME/.bun/bin` that is NOT on PATH (what gstack-capy-install leaves
* behind). The stub Bun answers `--version` with the simulated version and
* forwards everything else to the Bun running the test, so TypeScript bins
* still run. Real git, jq, unzip, sha256sum and coreutils stay reachable.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
export interface CapyHost {
base: string; home: string; stateRoot: string; systemBin: string; bunInstallBin: string;
env: Record<string, string>;
/** PATH with the verified Bun first, the way a GSTACK_LAUNCH-prefixed command sees it. */
launchPath: string;
cleanup(): void;
}
export interface CapyHostOptions {
/** Version the PATH Bun reports (the image's /usr/local/bin/bun). Default 1.3.14. */
systemBun?: string;
/** Install a verified Bun reporting this version in $HOME/.bun/bin (off PATH). Default none. */
verifiedBun?: string;
/** Extra tool names to hide from the inherited PATH (bun and codex are always hidden). */
hide?: string[];
}
const writeScript = (file: string, lines: string[]) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, ['#!/usr/bin/env bash', ...lines].join('\n') + '\n', { mode: 0o755 });
};
/** The inherited PATH with every directory that holds a hidden tool replaced by a symlink farm without it. */
export function pathWithout(base: string, hide: string[]): string {
return (process.env.PATH ?? '').split(path.delimiter).filter(Boolean).map((dir, i) => {
if (!hide.some(n => fs.existsSync(path.join(dir, n)))) return dir;
const shim = path.join(base, `host-path-${i}`);
fs.mkdirSync(shim, { recursive: true });
for (const name of fs.readdirSync(dir)) {
if (hide.includes(name)) continue;
try { fs.symlinkSync(path.join(dir, name), path.join(shim, name)); } catch { /* unreadable entry */ }
}
return shim;
}).join(path.delimiter);
}
/** A Bun stand-in: `--version` prints `version`; anything else runs the real Bun under test. */
export function writeBunStub(file: string, version: string): void {
writeScript(file, [`[ "\${1:-}" = --version ] && { echo "${version}"; exit 0; }`, `exec "${process.execPath}" "$@"`]);
}
export function capyHost(opts: CapyHostOptions = {}): CapyHost {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'capy-host-')));
const home = path.join(base, 'home');
const stateRoot = path.join(home, '.gstack');
const systemBin = path.join(base, 'usr-local-bin');
const bunInstallBin = path.join(home, '.bun', 'bin');
fs.mkdirSync(home, { recursive: true });
writeBunStub(path.join(systemBin, 'bun'), opts.systemBun ?? '1.3.14');
if (opts.verifiedBun) writeBunStub(path.join(bunInstallBin, 'bun'), opts.verifiedBun);
const inherited = pathWithout(base, ['bun', 'codex', ...(opts.hide ?? [])]);
const env: Record<string, string> = {
PATH: [systemBin, inherited].join(path.delimiter),
HOME: home, TMPDIR: base, BUN_INSTALL: path.join(home, '.bun'),
GSTACK_HOME: stateRoot, GSTACK_STATE_ROOT: stateRoot, GSTACK_EPHEMERAL: '1',
GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1',
GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.test',
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.test',
};
return {
base, home, stateRoot, systemBin, bunInstallBin, env,
launchPath: [bunInstallBin, env.PATH].join(path.delimiter),
cleanup: () => fs.rmSync(base, { recursive: true, force: true }),
};
}
+81
View File
@@ -0,0 +1,81 @@
/**
* lib/install-verdict.ts and bin/gstack-install-verdict.sh: the one reader of
* the install verdict gstack-capy-install writes, shared by gstack-skill-start
* (Bash) and gstack-autoplan's mutating verbs (TypeScript). Pinned on the
* admission decision (who must hold a verdict, what a verdict admits), the
* verdict path keyed by the source checkout's realpath, and the source-root
* resolution through an installed root's .source-path.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { execFileSync, spawnSync } from 'child_process';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { checkInstallAdmission, installVerdictPath, readInstallVerdict, sourceRoot } from '../lib/install-verdict';
const ROOT = path.resolve(import.meta.dir, '..');
const REAL = fs.realpathSync(ROOT);
const HEAD = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 30_000 }).trim();
const dirs: string[] = [];
afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); });
const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'install-verdict-')); dirs.push(d); return d; };
const writeVerdict = (stateRoot: string, over: Record<string, unknown> = {}) => {
const file = installVerdictPath(REAL, stateRoot);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({
schema_version: 1, checkout: REAL, head: HEAD, for: 'autoplan',
required: ['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision'],
pins_planning_only: false, ok: true, trailer: 'gstack: ok 9.9.9.9 for=autoplan', min_revision: 'unknown', launch_path: '', time: '2026-10-12T00:00:00Z', ...over,
}));
return file;
};
describe('install verdict (TypeScript)', () => {
test('the verdict path is keyed by sha256 of the checkout realpath under <state root>/install-verdicts', () => {
const sr = tmp();
expect(installVerdictPath(REAL, sr)).toBe(path.join(sr, 'install-verdicts', `${createHash('sha256').update(REAL).digest('hex')}.json`));
expect(readInstallVerdict(path.join(sr, 'nope.json'))).toBeNull();
expect(readInstallVerdict(writeVerdict(sr))?.head).toBe(HEAD);
});
test('admission: only unattended and Capy-spawned sessions apply; a runtime below the minimum refuses; GSTACK_CAPY=1 needs a matching verdict', () => {
const sr = tmp();
const base = { skill: 'autoplan', gstackRoot: ROOT, bunVersion: '1.4.2' };
expect(checkInstallAdmission({ ...base, env: {} }).applies).toBe(false);
const unattended = { GSTACK_SESSION_KIND: 'unattended', GSTACK_STATE_ROOT: sr };
expect(checkInstallAdmission({ ...base, env: unattended })).toMatchObject({ applies: true, refuse: false });
const oldBun = checkInstallAdmission({ ...base, env: unattended, bunVersion: '1.3.14' });
expect(oldBun).toMatchObject({ refuse: true, status: 'runtime', code: 'RUNTIME_BELOW_MINIMUM' });
expect(oldBun.line).toMatch(/^INSTALL: fail runtime — bun 1\.3\.14 .*below the supported minimum 1\.4\.2; fix: .+ \(RUNTIME_BELOW_MINIMUM\)$/);
const capy = { ...unattended, GSTACK_CAPY: '1' };
expect(checkInstallAdmission({ ...base, env: capy })).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED' });
writeVerdict(sr);
expect(checkInstallAdmission({ ...base, env: capy })).toMatchObject({ refuse: false, status: 'ok' });
expect(checkInstallAdmission({ ...base, skill: 'qa', env: capy })).toMatchObject({ refuse: true, status: 'verdict' });
writeVerdict(sr, { pins_planning_only: true });
expect(checkInstallAdmission({ ...base, skill: 'ship', env: capy }).refuse).toBe(true);
writeVerdict(sr, { head: 'f'.repeat(40) });
expect(checkInstallAdmission({ ...base, env: capy }).detail).toContain('another HEAD');
const over = checkInstallAdmission({ ...base, env: { ...capy, GSTACK_ALLOW_UNSUPPORTED_RUNTIME: '1' } });
expect(over).toMatchObject({ refuse: false, overridden: true });
expect(checkInstallAdmission({ ...base, env: { GSTACK_SESSION_KIND: 'spawned', GSTACK_CAPY: '1', GSTACK_STATE_ROOT: sr } }).refuse).toBe(true);
expect(checkInstallAdmission({ ...base, env: { OPENCLAW_SESSION: '1', GSTACK_STATE_ROOT: sr }, bunVersion: '1.3.14' })).toMatchObject({ applies: true, refuse: false, warn: true });
});
});
describe('source root and support file (one helper, direct and symlinked layouts)', () => {
const bash = (bin: string, fn: string) => spawnSync('bash', ['-c', `. "${path.join(ROOT, 'bin/gstack-install-verdict.sh')}" && ${fn}`, '_', bin], { encoding: 'utf8', timeout: 30_000 });
test('a checkout bin resolves to the checkout; an installed root follows .source-path; both read capy/support.env', () => {
const runtime = tmp();
fs.symlinkSync(path.join(ROOT, 'bin'), path.join(runtime, 'bin'));
fs.writeFileSync(path.join(runtime, '.source-path'), `${ROOT}\n`);
const linked = path.join(tmp(), 'gstack');
fs.symlinkSync(ROOT, linked);
for (const bin of [path.join(ROOT, 'bin'), path.join(runtime, 'bin'), path.join(linked, 'bin')]) {
expect(bash(bin, 'gstack_source_root "$1"').stdout.trim(), bin).toBe(REAL);
expect(sourceRoot(path.dirname(bin))).toBe(REAL);
expect(bash(bin, 'gstack_support_env_get "$(gstack_source_root "$1")" GSTACK_MIN_REVISION').stdout.trim()).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
}
});
});
+10
View File
@@ -51,6 +51,16 @@ describe('fast PR coverage policy', () => {
expect(result.needsFullValidation).toBe(false);
});
test('the vendored Capy skill (capy/skill/**) is free-only: no paid case reads it, so it needs no full validation', () => {
const result = select({ selectedE2E: [], selectedJudges: [], changedFiles: ['capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'], derived: null });
expect(result.needsFullValidation).toBe(false);
expect(result.missingCoverage).toEqual([]);
expect(result.mode).toBe('pr');
expect(result.e2e).toEqual([]);
const prompt = select({ selectedE2E: [], selectedJudges: [], changedFiles: ['plan-ceo-review/SKILL.md.tmpl'], derived: null });
expect(prompt.needsFullValidation || prompt.deferredPromptFiles.length > 0 || prompt.e2e.length > 0).toBe(true);
});
test('does not add unrelated short probes or drop selected quality judges', () => {
const result = select({ selectedJudges: ['browse quality', 'plan-ceo-review/SKILL.md modes'] });
expect(result.e2e).not.toContain('browse-smoke');