Merge PR A (install) under the autoplan wave; gstack-autoplan admission uses lib/install-verdict.ts (INSTALL_UNVERIFIED)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-13 01:37:10 +00:00
commit 79ee56af91
34 files changed
+2067 -214

No files matched your search

+18
View File
@@ -1,5 +1,23 @@
# 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.
### 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,
+26
View File
@@ -88,3 +88,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
}
+162 -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,153 @@ 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") || 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
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
printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN: bun \([^ ]*\) exact \([^ ]*\) lane=[a-z]*$/\1 \2/p' | while read -r _c _src; do
[ "$_c" = "$WANT" ] || stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT (its verified archive is $WANT), $PROJECT/${_src%%:*} pins exactly $_c (${_src})" "raise the project's pin to $WANT, or install gstack on a machine the project accepts"
done || exit 3
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 (or gstack's minimum $WANT)"; 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=""
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]*) [ "$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
if [ "$HAVE_OK" -eq 1 ]; then say "bun $HAVE satisfies gstack's supported minimum $WANT${PIN:+ and the project pin $PIN}"
elif [ -n "$LD_BIN" ]; then reuse_ld
elif [ -z "$LAUNCH_DIR" ]; then install_bun "bun ${HAVE:-absent} is below gstack's supported minimum $WANT${PIN:+ (project pin $PIN agrees)}"
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 -1
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
@@ -169,8 +177,12 @@ gstack_result_anchor() {
PLAN_SECTIONS_INVALID) printf '%s' 'plan-sections-invalid' ;;
PLAN_AMEND_PENDING) printf '%s' 'plan-amend-pending' ;;
GSTACK_VERSION_CHANGED) printf '%s' 'gstack-version-changed' ;;
INSTALL_VERDICT_FAILED) printf '%s' 'install-verdict-failed' ;;
ARTIFACT_GATE_UNSETTLED) printf '%s' 'artifact-gate-unsettled' ;;
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)
+146 -30
View File
@@ -1,47 +1,110 @@
# 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, and every exact pin equals the
version gstack carries a verified archive for (1.4.2), the installer
downloads that 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). Any other
exact pin, 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 +115,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 +142,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 |
+77 -16
View File
@@ -1126,10 +1126,11 @@ 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; ...)`
@@ -1170,6 +1171,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
@@ -2508,20 +2583,6 @@ older runner cannot know the newer state.
**Fix.** Nothing for the notice. For the refusal, upgrade gstack
(`bin/gstack-upgrade`) on this machine and rerun the command.
<a id="install-verdict-failed"></a>
### `INSTALL_VERDICT_FAILED`
**Meaning.** On a Capy host (`GSTACK_CAPY=1`) `gstack-autoplan`'s mutating
verbs (`next`, `submit`, `answer`, `revise`, `export`) need the install verdict
the Capy wrapper writes (`GSTACK_INSTALL_VERDICT`) to say `gstack: ok` for this
checkout and HEAD. Missing, failing, or written for another checkout or HEAD
refuses before anything is written. `status` and `resume` stay available.
**Fix.** Run the Capy gstack skill wrapper until its trailer is `gstack: ok`,
then launch commands with its `GSTACK_LAUNCH` line. To proceed anyway, set
`GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1`; the override is recorded in
`run.json.deviations` and printed as `ADMISSION: override`.
<a id="artifact-gate-unsettled"></a>
### `ARTIFACT_GATE_UNSETTLED`
+17 -3
View File
@@ -278,9 +278,10 @@ by its runner. Result codes: `EXECUTION_UNKNOWN`, `RUN_LOCKED`,
the missing heading added. `resume` completes an interrupted revise.
- **Admission and upgrades.** `next`, `submit`, `answer`, `revise`, `export`
and `run` refuse on a Capy host (`GSTACK_CAPY=1`) without an ok install
verdict for this checkout at `$GSTACK_INSTALL_VERDICT`
(`INSTALL_VERDICT_FAILED`), and in any unattended session when the Bun on
PATH is below `engines.bun` (`RUNTIME_BELOW_MINIMUM`);
verdict for this checkout, HEAD and workflow at `$GSTACK_INSTALL_VERDICT`
(`INSTALL_UNVERIFIED`), and in any unattended session when the running Bun
is below gstack's supported minimum (`RUNTIME_BELOW_MINIMUM`); the decision
is the same one `gstack-skill-start` makes (see Install admission);
`GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` overrides and records a run deviation.
`status` and `resume` are never gated. A run directory written by an older
gstack prints `GSTACK_VERSION_CHANGED` once and moves forward (legacy
@@ -328,3 +329,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).
+15 -58
View File
@@ -1,70 +1,27 @@
/**
* autoplan-admission — who may run `gstack-autoplan`'s mutating verbs (plan
* Lane A amendments as corrected by the amendment verification). On a Capy
* host (GSTACK_CAPY=1) the install verdict the Capy wrapper writes must say
* `gstack: ok` for this checkout and HEAD; any unattended or Capy session
* also needs the PATH Bun at gstack's supported minimum (package.json
* engines.bun). GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits anyway and the
* caller records the returned deviation. Interactive sessions are not gated.
*
* Integration seam: `readInstallVerdict` below is a local shim of lane A's
* lib/install-verdict.ts. Replace its body with an import of A's reader; the
* rest of this module only needs { trailer, head, checkout } back.
* autoplan-admission — who may run `gstack-autoplan`'s mutating verbs. The
* decision is lib/install-verdict.ts `checkInstallAdmission` (the same one
* `gstack-skill-start` makes): on a Capy host (GSTACK_CAPY=1) the install
* verdict the Capy wrapper wrote must pass for this checkout, HEAD and
* workflow; any unattended session also needs the PATH Bun (the one every
* `#!/usr/bin/env bun` child runs) at gstack's supported minimum. GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits anyway and the
* caller records the returned deviation. Interactive sessions, and other
* hosts' ambient spawned sessions (a warning line), are not refused.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { RunError } from './autoplan-state';
import { satisfies } from './runtime-pins';
import { resolveStateRoot } from './state-root';
import { checkInstallAdmission } from './install-verdict';
const ROOT = path.resolve(import.meta.dir, '..');
export interface InstallVerdict { trailer: string; head?: string; checkout?: string }
/** Shim of lane A's reader: JSON, or `key: value` / `key=value` lines. Undefined when the file is absent or unreadable. */
export function readInstallVerdict(file: string): InstallVerdict | undefined {
let text: string;
try { text = fs.readFileSync(file, 'utf8'); } catch { return undefined; }
let fields: Record<string, string> = {};
try { fields = JSON.parse(text); } catch {
for (const line of text.split('\n')) { const m = /^\s*([a-z_]+)\s*[:=]\s*(.*)$/.exec(line); if (m) fields[m[1]!] = m[2]!.trim(); }
}
return typeof fields.trailer === 'string' ? { trailer: fields.trailer, head: fields.head, checkout: fields.checkout } : undefined;
}
function verdictProblem(env: NodeJS.ProcessEnv): string | undefined {
const file = env.GSTACK_INSTALL_VERDICT || path.join(resolveStateRoot(env), 'install-verdict');
const v = readInstallVerdict(file);
if (!v) return `install verdict ${file} not found or unreadable`;
if (!/^gstack: ok\b/.test(v.trailer)) return `install verdict says ${JSON.stringify(v.trailer)}`;
const head = spawnSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim();
if (v.head && head && v.head !== head) return `install verdict is for another HEAD (${v.head.slice(0, 12)}, this checkout is at ${head.slice(0, 12)})`;
if (v.checkout && v.checkout !== fs.realpathSync(ROOT)) return `install verdict is for another checkout (${v.checkout})`;
return undefined;
}
function bunProblem(env: NodeJS.ProcessEnv): string | undefined {
const minimum = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).engines?.bun as string | undefined;
if (!minimum) return undefined;
const r = spawnSync('bun', ['--version'], { encoding: 'utf8', timeout: 30_000, env });
const version = r.stdout?.trim();
if (!version) return 'no bun on PATH';
return satisfies(version, minimum) === false ? `PATH runs bun ${version}, below gstack's supported minimum ${minimum}` : undefined;
}
export interface Admission { lines: string[]; deviation?: string }
/** Throws RunError (exit 3) unless the session may write; an override returns the line to print and the deviation to record. */
/** Throws RunError (exit 3) unless the session may write; an override or a warning returns the line to print. */
export function admit(env: NodeJS.ProcessEnv = process.env): Admission {
const capy = env.GSTACK_CAPY === '1';
if (!capy && env.GSTACK_SESSION_KIND !== 'unattended') return { lines: [] };
const verdict = capy ? verdictProblem(env) : undefined;
const bun = bunProblem(env);
const problem = verdict ?? bun;
if (!problem) return { lines: [] };
if (env.GSTACK_ALLOW_UNSUPPORTED_RUNTIME === '1') {
return { lines: [`ADMISSION: override GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 (${problem})`], deviation: `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1: admitted despite ${problem}` };
}
throw new RunError(verdict ? 'INSTALL_VERDICT_FAILED' : 'RUNTIME_BELOW_MINIMUM', problem, 3);
const bunVersion = spawnSync('bun', ['--version'], { encoding: 'utf8', timeout: 30_000, env }).stdout?.trim() || '0.0.0';
const a = checkInstallAdmission({ skill: 'autoplan', gstackRoot: ROOT, env, bunVersion });
if (a.overridden) return { lines: [`ADMISSION: override GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 (${a.detail})`], deviation: `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1: admitted despite ${a.detail}` };
if (a.refuse) throw new RunError(a.code ?? 'INSTALL_UNVERIFIED', a.detail, 3);
return { lines: a.warn ? [a.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 -5
View File
@@ -481,16 +481,37 @@ export const RESULT_CODES = {
summary: 'the run directory was written by a different gstack version',
fix: 'nothing to do for an older run (journaled forward migrations ran); a run written by a newer gstack needs that gstack: upgrade with bin/gstack-upgrade',
},
INSTALL_VERDICT_FAILED: {
anchor: 'install-verdict-failed',
summary: 'on a Capy host (GSTACK_CAPY=1) the install verdict is missing, failing, or for another checkout or HEAD',
fix: 'run the Capy gstack skill wrapper (`install.sh --for <skill>`) until its trailer is `gstack: ok`, then launch with its GSTACK_LAUNCH line; GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 overrides and is recorded as a run deviation',
},
ARTIFACT_GATE_UNSETTLED: {
anchor: 'artifact-gate-unsettled',
summary: 'the run says complete while an approval item of its last gate revision or an accepted plan amendment is still pending',
fix: 'answer the gate until every approval settles and revise (or withdraw) every accepted change with `gstack-autoplan`; never hand-edit status',
},
// 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",
+8 -8
View File
@@ -28,19 +28,19 @@ describe('admission', () => {
const r = make({ verdict: null });
const { out, res } = first(r, 'none');
expect(res.status).toBe(3);
expect(res.stderr).toMatch(/install verdict.*not found.*\(INSTALL_VERDICT_FAILED\)$/m);
expect(res.stderr).toMatch(/no install verdict at .*\(INSTALL_UNVERIFIED\)$/m);
expect(res.stdout).toContain('GSTACK_RESULT: skill=autoplan status=refused');
expect(fs.existsSync(path.join(out, 'run.json'))).toBe(false);
});
test('a failing verdict, or one for another HEAD or checkout, refuses with the trailer', () => {
const r = make();
r.writeVerdict({ trailer: 'gstack: fail runtime', head: HEAD, checkout: fs.realpathSync(ROOT) });
expect(first(r, 'fail').res.stderr).toMatch(/gstack: fail runtime.*\(INSTALL_VERDICT_FAILED\)$/m);
r.writeVerdict({ trailer: 'gstack: ok 1.91.95.0 for=autoplan', head: '0'.repeat(40), checkout: fs.realpathSync(ROOT) });
expect(first(r, 'head').res.stderr).toMatch(/another HEAD.*\(INSTALL_VERDICT_FAILED\)$/m);
r.writeVerdict({ trailer: 'gstack: ok 1.91.95.0 for=autoplan', head: HEAD, checkout: '/elsewhere/gstack' });
expect(first(r, 'checkout').res.stderr).toMatch(/another checkout.*\(INSTALL_VERDICT_FAILED\)$/m);
r.writeVerdict({ trailer: 'gstack: fail runtime' });
expect(first(r, 'fail').res.stderr).toMatch(/gstack: fail runtime.*\(INSTALL_UNVERIFIED\)$/m);
r.writeVerdict({ head: '0'.repeat(40) });
expect(first(r, 'head').res.stderr).toMatch(/another HEAD.*\(INSTALL_UNVERIFIED\)$/m);
r.writeVerdict({ checkout: '/elsewhere/gstack' });
expect(first(r, 'checkout').res.stderr).toMatch(/is for checkout \/elsewhere\/gstack.*\(INSTALL_UNVERIFIED\)$/m);
});
test('an ok verdict admits; status and resume need none; every mutating verb is gated', () => {
@@ -53,7 +53,7 @@ describe('admission', () => {
for (const args of [['next'], ['submit', '--phase', 'ceo', '--voice', 'outside', '--unavailable', 'x'], ['answer', '--gate-rev', '1', '--reply', 'all'], ['revise', '--plan', path.join(r.host.base, 'ok', 'plan.md')], ['export']]) {
const res2 = r.ap([args[0]!, '--out', out, ...args.slice(1)]);
expect(res2.status, args[0]).toBe(3);
expect(res2.stderr, args[0]).toMatch(/\(INSTALL_VERDICT_FAILED\)$/m);
expect(res2.stderr, args[0]).toMatch(/\(INSTALL_UNVERIFIED\)$/m);
}
});
+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' });
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,
+113 -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);
@@ -200,7 +200,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 +221,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);
}
+66
View File
@@ -701,3 +701,69 @@ 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');
expect(fresh.rows.runtime.rest).toContain(`bun 1.4.2 found at ${f.home}/.bun/bin (not on PATH)`);
expect(fresh.rows.runtime.rest).toContain(`fix: export PATH="${f.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 ${f.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, '.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 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([]);
});
});
+13 -5
View File
@@ -9,6 +9,8 @@ import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { capyHost, type CapyHost } from './capy-host';
import { SKILL_REQUIREMENTS } from '../../lib/doctor-components';
import type { InstallVerdict } from '../../lib/install-verdict';
import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN } from './autoplan-run-fixture';
const SESSION_KIND_VAR = ['GSTACK', 'SESSION', 'KIND'].join('_');
@@ -20,23 +22,29 @@ export interface CapyRunner {
ap(args: string[], extra?: Record<string, string>): SpawnSyncReturns<string>;
review(prompt: string, extra?: Record<string, string>): SpawnSyncReturns<string>;
validate(out: string): SpawnSyncReturns<string>;
writeVerdict(fields: Record<string, string>): void;
writeVerdict(fields: Partial<InstallVerdict>): void;
cleanup(): void;
}
/** A launched command on the Capy host: GSTACK_LAUNCH PATH, unattended, GSTACK_CAPY=1, an ok verdict for this HEAD. */
export function capyRunner(o: { launched?: boolean; verdict?: Record<string, string> | null } = {}): CapyRunner {
export function capyRunner(o: { launched?: boolean; verdict?: Partial<InstallVerdict> | null } = {}): CapyRunner {
const host = capyHost({ verifiedBun: '1.4.2' });
const verdict = path.join(host.stateRoot, 'install-verdict');
const env: Record<string, string> = {
...host.env, PATH: o.launched === false ? host.env.PATH! : host.launchPath,
[SESSION_KIND_VAR]: RUNNER_KIND, GSTACK_CAPY: '1', GSTACK_INSTALL_VERDICT: verdict,
};
const writeVerdict = (fields: Record<string, string>) => {
/** lib/install-verdict.ts schema 1, an ok autoplan verdict for this checkout and HEAD unless overridden. */
const writeVerdict = (fields: Partial<InstallVerdict>) => {
const trailer = fields.trailer ?? 'gstack: ok 1.91.96.0 for=autoplan';
const full: InstallVerdict = {
schema_version: 1, checkout: fs.realpathSync(ROOT), head: HEAD, for: 'autoplan', required: [...(SKILL_REQUIREMENTS.autoplan?.requires ?? [])],
pins_planning_only: false, ok: trailer.startsWith('gstack: ok'), trailer, min_revision: '1.91.96.0', launch_path: host.bunInstallBin, time: new Date().toISOString(), ...fields,
};
fs.mkdirSync(path.dirname(verdict), { recursive: true });
fs.writeFileSync(verdict, JSON.stringify(fields) + '\n');
fs.writeFileSync(verdict, JSON.stringify(full) + '\n');
};
if (o.verdict !== null) writeVerdict(o.verdict ?? { trailer: 'gstack: ok 1.91.95.0 for=autoplan', head: HEAD, checkout: fs.realpathSync(ROOT), at: new Date().toISOString() });
if (o.verdict !== null) writeVerdict(o.verdict ?? {});
const run = (args: string[], extra: Record<string, string> = {}) =>
spawnSync('bun', args, { cwd: ROOT, env: { ...env, ...extra }, encoding: 'utf8', timeout: 120_000 });
return {
+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' }).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' });
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+$/);
}
});
});