diff --git a/CHANGELOG.md b/CHANGELOG.md index 532e29785..a9e5247ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.91.96.0] - 2026-10-13 + +**On a Capy machine, gstack now tells the truth about itself and fixes the common cases on its own. An old checkout upgrades itself at session start, the install check agrees with the Bun your workflows run, and every fix line names a command that exists.** + +### What this means for you + +- **`bin/gstack-upgrade` exists.** It fast-forwards the checkout to the remote default branch and reinstalls (on Capy it runs the installer, so the Bun preflight comes first; elsewhere `./setup --refresh-registered`, which runs migrations). It never stashes, resets or discards anything. A dirty, diverged, pinned or locked checkout is refused with `UPGRADE_REFUSED`, and an unreachable remote with `UPGRADE_FETCH_FAILED`. `--check` prints `current` or `behind -> ` 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=:$PATH`, `GSTACK_CAPY=1` and `GSTACK_INSTALL_VERDICT`, and the wrapper prints it on `--check` as well as install. A bare doctor run in a fresh shell names the off-PATH Bun (`bun 1.4.2 found at ~/.bun/bin (not on PATH)`) with the exact fix. The installer reuses a verified Bun instead of downloading it again, and installs the version a project's CI pins next to a different system Bun. Planning-only checks (`--for autoplan`, the plan reviews, office-hours) report a project test-pin mismatch as `SKIP pins (planning-only …)`; workflows that run the project's tests still require it. +- **A stale copy of the Capy skill gets an owner action instead of a silent pass.** The canonical skill now lives in the repo at `capy/skill/gstack/`. The Drive copy is a small frozen bootstrapper that hands off to the checkout's full wrapper (`bin/gstack-capy-session`). An older copy keeps working and prints `CAPY_WRAPPER_STALE` with the copy command. +- **Machines that never run a workflow don't pay for the install.** A warm install on the same HEAD skips setup (about 1 s). The skill installs before the first workflow rather than at every session start. `docs/capy-install.md` explains how to bake gstack into a Capy snapshot. +- **The installed Bun follows the project's exact pin.** When the target repo pins one Bun version exactly (a CI `bun-version:`, `.bun-version`, engines) above gstack's minimum and gstack carries its verified archive (now 1.4.2 and 1.4.3), that archive is installed, so a repo moving its CI to 1.4.3 does not leave Capy machines on 1.4.2 failing `pins`. An exact pin without a verified archive stops with `RUNTIME_PIN_CONFLICT` and lists the verified versions. + +### For contributors + +- New: `bin/gstack-upgrade`, `bin/gstack-capy-session`, `bin/gstack-install-verdict.sh`, `lib/install-verdict.ts`, `capy/support.env`, `capy/skill/gstack/`. Tests `test/gstack-upgrade-bin.test.ts`, `test/capy-skill-wrapper.test.ts`, `test/install-verdict.test.ts`, and the shared simulated Capy host `test/helpers/capy-host.ts` (Bun 1.3.14 first on PATH, no Codex CLI). +- `GSTACK_MIN_REVISION` is raised only for install-honesty or security fixes, at stamp time, never above VERSION (pinned by test). + ## [1.91.95.0] - 2026-10-12 **A reopened `/autoplan` gate now exports a run that validates.** diff --git a/VERSION b/VERSION index 48be7cfa3..821746e64 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.95.0 +1.91.96.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 768aac98a..02a666044 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/bin/gstack-bun-version.sh b/bin/gstack-bun-version.sh index 2d5f6fa78..900644f55 100644 --- a/bin/gstack-bun-version.sh +++ b/bin/gstack-bun-version.sh @@ -16,17 +16,29 @@ GSTACK_BUN_FLOOR="1.3.3" GSTACK_BUN_TESTED="1.4.2" -# gstack_bun_archive_sha256 TARGET — the SHA-256 of -# https://github.com/oven-sh/bun/releases/download/bun-v$GSTACK_BUN_TESTED/bun-TARGET.zip -# from that release's SHASUMS256.txt, for the targets bin/gstack-capy-install -# installs on Capy cloud machines (the same exact-archive recipe as -# .github/docker/Dockerfile.ci; test/bun-version-drift.test.ts keeps them -# equal). Prints nothing and returns 1 for a target without a verified archive. +# gstack_bun_archive_sha256 TARGET [VERSION] — the SHA-256 of +# https://github.com/oven-sh/bun/releases/download/bun-v$VERSION/bun-TARGET.zip +# (VERSION defaults to $GSTACK_BUN_TESTED) from that release's SHASUMS256.txt, +# for the targets bin/gstack-capy-install installs on Capy cloud machines (the +# same exact-archive recipe as .github/docker/Dockerfile.ci; +# test/bun-version-drift.test.ts keeps the $GSTACK_BUN_TESTED rows equal). A +# newer version is listed so the installer can follow a project's exact pin +# above the minimum. Prints nothing and returns 1 without a verified archive. +GSTACK_BUN_VERIFIED="1.4.2 1.4.3" gstack_bun_archive_sha256() { - case "$1" in - linux-x64) echo 36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;; - linux-x64-baseline) echo c678040f14fe0440eb839d37cbd0ce4c051a32da72806ac97de6a6aab6bf728f ;; - linux-aarch64) echo 54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 ;; + case "${2:-$GSTACK_BUN_TESTED}" in + 1.4.2) case "$1" in + linux-x64) echo 36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;; + linux-x64-baseline) echo c678040f14fe0440eb839d37cbd0ce4c051a32da72806ac97de6a6aab6bf728f ;; + linux-aarch64) echo 54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 ;; + *) return 1 ;; + esac ;; + 1.4.3) case "$1" in + linux-x64) echo 3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18 ;; + linux-x64-baseline) echo 1fc2edac843102909e3a1be1d8d9802cc6071cf074e67e88231f4ffe0f8b397b ;; + linux-aarch64) echo efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21 ;; + *) return 1 ;; + esac ;; *) return 1 ;; esac } @@ -88,3 +100,29 @@ gstack_bun_incoming_hold() { _gb_ver=$(git -C "$1" show "$2:VERSION" 2>/dev/null | head -1) echo "bun-too-old: found Bun $_gb_found at $(command -v bun); gstack ${_gb_ver:-update} needs $_gb_floor or newer" } + +# gstack_bun_launch_dir [exact] — the directory to put first on PATH so +# gstack's `#!/usr/bin/env bun` bins run a supported Bun: the first of +# $BUN_INSTALL/bin and $HOME/.bun/bin whose bun is ok (and equals +# when given), unless the bun already first on PATH qualifies. Prints the +# directory (callers read its version with "/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 | 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 | 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 +} diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index e4ab20855..78a0bd854 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -5,16 +5,23 @@ # Usage: # gstack-capy-install [--for | --only | --require ] [--with-browser] # [--revision ] [--project ] [--root ] +# [--check] [--min-revision |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 " say "cloning $REPO into $ROOT${REVISION:+ at $REVISION}" if [ -n "$REVISION" ] && git clone --quiet --single-branch --depth 1 --branch "$REVISION" "$REPO" "$ROOT" 2>/dev/null; then : @@ -69,79 +88,163 @@ else say "reusing $ROOT ($(head -1 "$ROOT/VERSION")); never reset or pulled" fi . "$ROOT/bin/gstack-bun-version.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-bun-version.sh is missing" "re-clone gstack" +. "$ROOT/bin/gstack-install-verdict.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-install-verdict.sh is missing" "re-clone gstack" -# 2. Preflight: tools, then Bun against gstack's minimum and the project's pin. +# 2. Preflight: tools, then Bun against gstack's minimum and the project's pins. +# The launch directory is where the Bun the trailer certifies lives when it is +# not first on PATH; the GSTACK_LAUNCH line carries it to every later command. +LAUNCH_DIR="" WANT="$GSTACK_BUN_TESTED" +BUN_DIR="${BUN_INSTALL:-$HOME/.bun}/bin" +launch_with() { LAUNCH_DIR="$1"; export PATH="$1:$PATH"; } +install_bun() { # REASON — download, verify and install Bun $WANT into $BUN_DIR + say "$1; installing bun $WANT from its release archive (SHA-256 verified)" + case "$(uname -ms)" in + 'Linux x86_64') TARGET=linux-x64; grep -q avx2 /proc/cpuinfo 2>/dev/null || TARGET=linux-x64-baseline ;; + 'Linux aarch64'|'Linux arm64') TARGET=linux-aarch64 ;; + *) stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $(uname -ms) (this installer is for Capy cloud Ubuntu)" "install Bun $WANT by hand (https://bun.sh), then re-run" ;; + esac + SHA=$(gstack_bun_archive_sha256 "$TARGET" "$WANT") || stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $TARGET" "install Bun $WANT by hand, then re-run" + TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-bun.XXXXXX") || stop INSTALL_PREFLIGHT_FAILED "cannot create a temp dir" "point TMPDIR at a writable directory" + curl --retry 3 -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v$WANT/bun-$TARGET.zip" -o "$TMP/bun.zip" || stop INSTALL_PREFLIGHT_FAILED "could not download bun-v$WANT/bun-$TARGET.zip" "check network access to github.com, then re-run" + echo "$SHA $TMP/bun.zip" | sha256sum -c - >/dev/null 2>&1 || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "bun-$TARGET.zip SHA-256 mismatch (expected $SHA); nothing installed" "update gstack_bun_archive_sha256 in bin/gstack-bun-version.sh only after checking the release's SHASUMS256.txt"; } + mkdir -p "$BUN_DIR" && (cd "$TMP" && unzip -qo bun.zip) && install -m 0755 "$TMP/bun-$TARGET/bun" "$BUN_DIR/bun" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not unpack bun into $BUN_DIR" "install unzip, or set BUN_INSTALL to a writable prefix"; } + rm -rf "$TMP" + LD_BIN="$BUN_DIR/bun" LD_VER=$("$BUN_DIR/bun" --version 2>/dev/null | head -1) + [ "$LD_VER" = "$WANT" ] || stop INSTALL_PREFLIGHT_FAILED "installed bun reports $LD_VER, expected $WANT" "check $BUN_DIR/bun, then re-run" + launch_with "$BUN_DIR" + say "bun $WANT installed at $BUN_DIR/bun (the GSTACK_LAUNCH line puts $BUN_DIR first on PATH)" +} +if [ "$CHECK_ONLY" -eq 1 ]; then + LAUNCH_DIR=$(gstack_bun_launch_dir) && export PATH="$LAUNCH_DIR:$PATH" +else for tool in curl node jq; do command -v "$tool" >/dev/null 2>&1 || stop INSTALL_PREFLIGHT_FAILED "preflight: $tool not found" "install $tool (apt-get install -y jq; Node from https://nodejs.org or nvm), then re-run" done [ -n "$PROJECT" ] || { PROJECT=$(git rev-parse --show-toplevel 2>/dev/null || true); [ "$(cd "${PROJECT:-/nonexistent}" 2>/dev/null && pwd -P)" = "$ROOT" ] && PROJECT=""; } -PIN="" PIN_SRC="" -if [ -n "$PROJECT" ] && [ -f "$PROJECT/package.json" ]; then - PIN=$(jq -r '.engines.bun // (.packageManager // "" | select(startswith("bun@")) | ltrimstr("bun@")) // empty' "$PROJECT/package.json" 2>/dev/null | head -1) - [ -n "$PIN" ] && PIN_SRC="$PROJECT/package.json" +HAVE_BIN=$(command -v bun 2>/dev/null || true) +HAVE=$([ -n "$HAVE_BIN" ] && bun --version 2>/dev/null | head -1) +HAVE_OK=0; [ "$(gstack_bun_status "${HAVE:-0.0.0}")" = ok ] && HAVE_OK=1 +LD_BIN="" LD_VER="" +for _d in "$BUN_DIR" "$HOME/.bun/bin"; do + [ -x "$_d/bun" ] && [ "$_d/bun" != "$HAVE_BIN" ] || continue + _v=$("$_d/bun" --version 2>/dev/null | head -1) + [ "$(gstack_bun_status "${_v:-0.0.0}")" = ok ] && { LD_BIN="$_d/bun" LD_VER="$_v"; break; } +done +# follow_pin VERSION SOURCE — an exact project pin becomes the Bun to install +# when it is at or above gstack's minimum and has a verified archive; anything +# else is a conflict naming both constraints and the verified versions. +follow_pin() { + [ "$1" = "$WANT" ] && return 0 + if [ "$WANT" = "$GSTACK_BUN_TESTED" ] && [ "$(gstack_bun_status "$1")" = ok ] && gstack_bun_archive_sha256 linux-x64 "$1" >/dev/null; then WANT="$1"; return 0; fi + stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$GSTACK_BUN_TESTED (verified archives: $GSTACK_BUN_VERIFIED), $2 pins exactly $1" "raise the project's pin to a verified version, or install gstack on a machine the project already provisions" +} +reuse_ld() { launch_with "$(dirname "$LD_BIN")"; say "reusing bun $LD_VER at $LD_BIN (not first on PATH; the GSTACK_LAUNCH line puts it first)"; } +# No Bun at or above the security floor anywhere: install the verified one +# first, so the project's pins can be read with it. +case "$(gstack_bun_status "${HAVE:-0.0.0}")" in too-old|malformed) [ -z "$LD_BIN" ] && install_bun "bun ${HAVE:-absent} cannot read the project's pins" ;; esac +RUNNER="$HAVE_BIN"; case "$(gstack_bun_status "${HAVE:-0.0.0}")" in too-old|malformed) RUNNER="$LD_BIN" ;; esac +PINS_OUT="" +[ -n "$PROJECT" ] && [ -n "$RUNNER" ] && [ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && PINS_OUT=$("$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null) +pins_ok() { "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" 2>/dev/null | grep -Eq '^PINS_VERDICT: (pass|none)$'; } +if printf '%s\n' "$PINS_OUT" | grep -q '^PINS_VERDICT: '; then + # The project's pins, read by the doctor's own parser (lib/runtime-pins.ts): + # engines, packageManager, .tool-versions, .bun-version and CI lanes. + if [ "$HAVE_OK" -eq 1 ] && pins_ok "$HAVE"; then say "bun $HAVE satisfies gstack's supported minimum $WANT and the project pins" + elif [ -n "$LD_BIN" ] && pins_ok "$LD_VER"; then reuse_ld + else + while read -r _c _src; do + [ -z "$_c" ] || follow_pin "$_c" "$PROJECT/${_src%%:*}" + done <=$WANT, $PROJECT pins $(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_SUMMARY: //p' | grep '^bun' | tr '\n' ';' | sed 's/;$//')" "raise the project's pin to $WANT or newer, or install gstack on a machine the project accepts" + if [ "$LD_VER" = "$WANT" ]; then reuse_ld; else install_bun "bun ${HAVE:-absent} does not satisfy the project pins (bun $WANT) or gstack's minimum $GSTACK_BUN_TESTED"; fi + fi +else + # No pin report (no project, or a Bun that cannot run the parser): the + # package.json / .bun-version subset, read with jq. + PIN="" PIN_SRC="" EXACT=0 + if [ -n "$PROJECT" ] && [ -f "$PROJECT/package.json" ]; then + PIN=$(jq -r '.engines.bun // (.packageManager // "" | select(startswith("bun@")) | ltrimstr("bun@")) // empty' "$PROJECT/package.json" 2>/dev/null | head -1) + [ -n "$PIN" ] && PIN_SRC="$PROJECT/package.json" + fi + if [ -z "$PIN" ] && [ -n "$PROJECT" ] && [ -s "$PROJECT/.bun-version" ]; then PIN=$(head -1 "$PROJECT/.bun-version" | tr -d ' v'); PIN_SRC="$PROJECT/.bun-version"; fi + case "$PIN" in + '') ;; + '>='*) _gstack_bun_below "$WANT" "${PIN#>=}" && stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PIN_SRC pins $PIN" "raise the project's pin to $WANT or newer, or install gstack on a machine the project accepts" ;; + [0-9]*.[0-9]*.[0-9]*) follow_pin "$PIN" "$PIN_SRC"; EXACT=1 ;; + *) say "note: project pin '$PIN' ($PIN_SRC) is not evaluated here; gstack-doctor's pins row checks it after install" ;; + esac + if [ "$HAVE_OK" -eq 1 ] && { [ "$EXACT" -eq 0 ] || [ "$HAVE" = "$WANT" ]; }; then say "bun $HAVE satisfies gstack's supported minimum $GSTACK_BUN_TESTED${PIN:+ and the project pin $PIN}" + elif [ -n "$LD_BIN" ] && { [ "$EXACT" -eq 0 ] || [ "$LD_VER" = "$WANT" ]; }; then reuse_ld + elif [ -z "$LAUNCH_DIR" ] || [ "$LD_VER" != "$WANT" ]; then install_bun "bun ${HAVE:-absent} does not satisfy gstack's supported minimum $GSTACK_BUN_TESTED${PIN:+ and the project pin $PIN}" + fi +fi fi -if [ -z "$PIN" ] && [ -n "$PROJECT" ] && [ -s "$PROJECT/.bun-version" ]; then PIN=$(head -1 "$PROJECT/.bun-version" | tr -d ' v'); PIN_SRC="$PROJECT/.bun-version"; fi -WANT="$GSTACK_BUN_TESTED" -case "$PIN" in - '') ;; - '>='*) _gstack_bun_below "$WANT" "${PIN#>=}" && stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PIN_SRC pins $PIN" "raise the project's pin to $WANT or newer, or install gstack on a machine the project accepts" ;; - [0-9]*.[0-9]*.[0-9]*) [ "$PIN" = "$WANT" ] || stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PIN_SRC pins exactly $PIN" "raise the project's pin to $WANT, or install gstack on a machine the project accepts" ;; - *) say "note: project pin '$PIN' ($PIN_SRC) is not evaluated here; gstack-doctor's pins row checks it after install" ;; -esac -HAVE=$(bun --version 2>/dev/null | head -1 || true) -case "$(gstack_bun_status "${HAVE:-0.0.0}")" in - ok) say "bun $HAVE satisfies gstack's supported minimum $WANT${PIN:+ and the project pin $PIN}" ;; - *) - say "bun ${HAVE:-absent} is below gstack's supported minimum $WANT${PIN:+ (project pin $PIN agrees)}; installing bun $WANT from its release archive (SHA-256 verified)" - case "$(uname -ms)" in - 'Linux x86_64') TARGET=linux-x64; grep -q avx2 /proc/cpuinfo 2>/dev/null || TARGET=linux-x64-baseline ;; - 'Linux aarch64'|'Linux arm64') TARGET=linux-aarch64 ;; - *) stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $(uname -ms) (this installer is for Capy cloud Ubuntu)" "install Bun $WANT by hand (https://bun.sh), then re-run" ;; - esac - SHA=$(gstack_bun_archive_sha256 "$TARGET") || stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $TARGET" "install Bun $WANT by hand, then re-run" - TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-bun.XXXXXX") || stop INSTALL_PREFLIGHT_FAILED "cannot create a temp dir" "point TMPDIR at a writable directory" - curl --retry 3 -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v$WANT/bun-$TARGET.zip" -o "$TMP/bun.zip" || stop INSTALL_PREFLIGHT_FAILED "could not download bun-v$WANT/bun-$TARGET.zip" "check network access to github.com, then re-run" - echo "$SHA $TMP/bun.zip" | sha256sum -c - >/dev/null 2>&1 || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "bun-$TARGET.zip SHA-256 mismatch (expected $SHA); nothing installed" "update gstack_bun_archive_sha256 in bin/gstack-bun-version.sh only after checking the release's SHASUMS256.txt"; } - BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" - mkdir -p "$BUN_INSTALL/bin" && (cd "$TMP" && unzip -qo bun.zip) && install -m 0755 "$TMP/bun-$TARGET/bun" "$BUN_INSTALL/bin/bun" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not unpack bun into $BUN_INSTALL/bin" "install unzip, or set BUN_INSTALL to a writable prefix"; } - rm -rf "$TMP" - export PATH="$BUN_INSTALL/bin:$PATH" - HAVE=$(bun --version 2>/dev/null | head -1) - [ "$HAVE" = "$WANT" ] || stop INSTALL_PREFLIGHT_FAILED "installed bun reports $HAVE, expected $WANT" "check PATH ($BUN_INSTALL/bin must come first), then re-run" - say "bun $WANT installed at $BUN_INSTALL/bin/bun (prepend $BUN_INSTALL/bin to PATH in later shells)" ;; -esac -# 3. Install both host profiles; Chromium lazy unless --with-browser. +# 3. Install both host profiles; Chromium lazy unless --with-browser. Skipped +# when this exact HEAD was set up with the same hosts, model and browser flag +# (recorded in the git dir after a successful setup, never keyed on VERSION) +# and the check passes; never after a fast-forward. cd "$ROOT" || exit 1 export GSTACK_SKIP_COREUTILS="${GSTACK_SKIP_COREUTILS:-1}" GSTACK_SKIP_ASIDE="${GSTACK_SKIP_ASIDE:-1}" BROWSER_FLAG="--no-browser"; [ "$WITH_BROWSER" -eq 1 ] && BROWSER_FLAG="" -T1=$(now) -./setup --host claude --no-prefix $BROWSER_FLAG &2; exit 1; } -./setup --host codex --model gpt-6-astra $BROWSER_FLAG &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 || 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 "$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" ; ...)` 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" diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session new file mode 100755 index 000000000..427452b1d --- /dev/null +++ b/bin/gstack-capy-session @@ -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 [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:-/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" "$@" diff --git a/bin/gstack-doctor b/bin/gstack-doctor index 23769c0ce..c0648255d 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -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 | --require ] [--project ] -# [--revision ] [--json] [--live] +# [--revision ] [--min-revision |unknown] [--json] [--live] # one PASS | FAIL | SKIP row per component, then one # trailer: `gstack: ok for=` (exit 0) # or `gstack: fail ` (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 diff --git a/bin/gstack-doctor-check.sh b/bin/gstack-doctor-check.sh index a622ba92d..617946137 100644 --- a/bin/gstack-doctor-check.sh +++ b/bin/gstack-doctor-check.sh @@ -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 | 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 | 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 | 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 diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index cd35ad99b..5bb253501 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -75,6 +75,14 @@ gstack_doctor_skill_optional() { esac } +# gstack_doctor_skill_pins_advisory SKILL — true for a planning-only workflow (a repo test-pin mismatch SKIPs pins). +gstack_doctor_skill_pins_advisory() { + case "$1" in + autoplan|plan-ceo-review|plan-eng-review|plan-design-review|plan-devex-review|office-hours) return 0 ;; + *) return 1 ;; + esac +} + # gstack_result_anchor CODE — the docs/troubleshooting.md anchor for a lib/result-codes.ts code; returns 1 for an unknown code. gstack_result_anchor() { case "$1" in @@ -166,6 +174,11 @@ gstack_result_anchor() { CONTRIBUTOR_SOURCE_UNAVAILABLE) printf '%s' 'contributor-source-unavailable' ;; BRIEF_INPUT_MISSING) printf '%s' 'brief-input-missing' ;; DRAFT_DIRECTION_UNRESOLVED) printf '%s' 'draft-direction-unresolved' ;; + UPGRADE_REFUSED) printf '%s' 'upgrade-refused' ;; + UPGRADE_FETCH_FAILED) printf '%s' 'upgrade-fetch-failed' ;; + REVISION_BELOW_MINIMUM) printf '%s' 'revision-below-minimum' ;; + CAPY_WRAPPER_STALE) printf '%s' 'capy-wrapper-stale' ;; + INSTALL_UNVERIFIED) printf '%s' 'install-unverified' ;; *) return 1 ;; esac } diff --git a/bin/gstack-install-verdict.sh b/bin/gstack-install-verdict.sh new file mode 100644 index 000000000..239ddef9b --- /dev/null +++ b/bin/gstack-install-verdict.sh @@ -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: +# /install-verdicts/.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=. +# +# 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 | 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 +} diff --git a/bin/gstack-skill-end b/bin/gstack-skill-end index f64e79fb1..797a268af 100755 --- a/bin/gstack-skill-end +++ b/bin/gstack-skill-end @@ -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 diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 2daa76687..8b013784d 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -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 diff --git a/bin/gstack-upgrade b/bin/gstack-upgrade new file mode 100755 index 000000000..c4e5c770b --- /dev/null +++ b/bin/gstack-upgrade @@ -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 ` +# (exit 0) or `gstack-upgrade: behind -> +# ( 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 -> ` +# 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 , 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" &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" diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md new file mode 100644 index 000000000..55a13c4d5 --- /dev/null +++ b/capy/skill/gstack/SKILL.md @@ -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 ()` 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 ` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP ` row per component, a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok for=` (exit 0) or `gstack: fail …` (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 -> `), 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 ` (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 , minimum ; copy /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=:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=` (the `PATH=` part only when the verified Bun is not first on PATH) and, then, `GSTACK_LAUNCH_PATH: ` 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= 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 ` 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.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.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=`. `~/.local/share/gstack/bin/gstack-artifact validate /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. diff --git a/capy/skill/gstack/scripts/install.sh b/capy/skill/gstack/scripts/install.sh new file mode 100755 index 000000000..b420e7c31 --- /dev/null +++ b/capy/skill/gstack/scripts/install.sh @@ -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 | --require ] [--project ] +# bash install.sh [--for | --require ] [--with-browser] [--revision ] [--project ] +# 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[@]}"} diff --git a/capy/support.env b/capy/support.env new file mode 100644 index 000000000..9aa097cca --- /dev/null +++ b/capy/support.env @@ -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= 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 diff --git a/docs/CHANGELOG_STYLE.md b/docs/CHANGELOG_STYLE.md index aeabb2e09..6dc733e93 100644 --- a/docs/CHANGELOG_STYLE.md +++ b/docs/CHANGELOG_STYLE.md @@ -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) diff --git a/docs/capy-install.md b/docs/capy-install.md index 208e7ab9f..880b8ed60 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -1,47 +1,114 @@ # Installing gstack on a Capy cloud machine -`bin/gstack-capy-install` installs gstack on a fresh Capy cloud machine -(Ubuntu) and ends with one line a parent agent greps. It is the repo-owned -installer the user-scope Capy skill runs after cloning; nothing in the skill -decides what "installed" means. +Three layers, each owned by gstack: + +| Layer | Where | Job | +|---|---|---| +| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env`; in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper | +| Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies, otherwise install or repair | +| Installer | `bin/gstack-capy-install` | Bun, both host profiles, the doctor, the install verdict, the launch line, the trailer | + +The vendored skill (`capy/skill/gstack/SKILL.md`) tells a Capy agent how to use +them. The Drive folder is a mirror: the owner copies `capy/skill/gstack/` there +when asked; nothing writes the Drive automatically. + +Direct use, without the skill: ```bash git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.local/share/gstack ~/.local/share/gstack/bin/gstack-capy-install --for autoplan ``` -What it does, in order: +## Session start -1. Reuses the checkout it lives in (or clones into `--root `), under a - lock. It never resets or pulls an existing checkout. `--revision ` - pins a fresh clone; an existing checkout at another revision is reused and - the trailer says `(requested ; 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 `, 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 /scripts/install.sh --check --for ` (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 -> `), 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 ` or `behind + -> ( 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 `), under the + lock. It never resets or pulls; `--revision ` pins a fresh clone, and an + existing checkout at another revision is reused with the trailer note + `(requested ; run /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 `, default the git toplevel of the current directory) are + read with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines, + packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes + for this platform). When neither the Bun on PATH nor the installed one + satisfies gstack's minimum and the pins, the installer downloads one exact + release archive, verifies its SHA-256 against `bin/gstack-bun-version.sh` + and installs it into `$BUN_INSTALL/bin`, beside any different system Bun (no + symlink into a shared bin directory). That archive is the supported minimum + (1.4.2), or the project's exact pin when it is above the minimum and gstack + carries a verified archive for it (`GSTACK_BUN_VERIFIED`: 1.4.2, 1.4.3), so a + repo whose CI pins `bun-version: 1.4.3` gets 1.4.3. With an exact pin, only + that version satisfies it. Any other exact pin (below the minimum, two + different ones, or one without a verified archive), or a range gstack's + minimum does not satisfy, stops with both + constraints ([RUNTIME_PIN_CONFLICT](troubleshooting.md#capy-install-runtime-conflict), + exit 3) before `./setup`. With no Bun at or above the security floor, the + verified one is installed first so the pins can be read. 3. `./setup --host claude --no-prefix`, then `./setup --host codex --model - gpt-6-astra`, both with `--no-browser`: Chromium is lazy by default. + gpt-6-astra`, both with `--no-browser`. Skipped when this exact HEAD was set + up with the same hosts, model and browser flag (recorded in the git dir + after a successful setup) and `gstack-doctor --check` passes; a warm rerun + takes about a second. 4. `gstack-doctor --check` for the requested workflow (`--for `, - `--only `, or `--require `; default `claude,codex,patch`). + `--only `, or `--require `; default `claude,codex,patch`), + run with the launch PATH, so the trailer certifies the Bun workflows run. +5. The install verdict, written atomically to + `/install-verdicts/.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 /scripts/install.sh --check --for autoplan 2>&1); rc=$? printf '%s\n' "$out" | grep '^gstack: ' [ "$rc" -eq 0 ] ``` @@ -52,7 +119,13 @@ the incident this installer exists to end. Every `FAIL ` 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 )` is not a +failure: planning workflows (`--for autoplan`, the plan reviews, +`office-hours`) never run the target repo's tests. Workflows that do (`review`, +`ship`, `qa`) and `--require` keep `pins` required. When a lane reports to another thread (a handoff, a `gstack-lane-check` result, a coordinator contract), store and use the full thread id (`jam_…`) @@ -73,23 +146,70 @@ time separately. ## The launch line -The installer's last lines include: +Every successful run, `--check` included, prints before the trailer: ``` -GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 +GSTACK_LAUNCH: PATH=:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT= +GSTACK_LAUNCH_PATH: ``` -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=:$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= 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 | diff --git a/docs/designs/GSTACK_FIELD_WAVE_2026_10_12.md b/docs/designs/GSTACK_FIELD_WAVE_2026_10_12.md new file mode 100644 index 000000000..05719098a --- /dev/null +++ b/docs/designs/GSTACK_FIELD_WAVE_2026_10_12.md @@ -0,0 +1,283 @@ +# gstack field wave (October 12, 2026): honest upgrades, closable unattended runs, plan-to-gate obligations + +## URGENT, outside this plan + +None known at plan time. The reviewers fill this block. + +## Implementation plan + +### Context + +Base: origin/main `dbf400e` (v1.91.95.0). Input: field evidence from the GBRA threads that ran gstack on Capy cloud machines during gbrain fix waves 15 and 16 (2026-10-10..12, gstack 1.91.22–1.91.94), plus a 12-machine survey. Owner approval to run the wave: Garry, 2026-10-12 ("start it"), relayed by the gbrain coordinator thread. Garry merges gstack; this wave opens one PR and does not merge it. + +Every item was reproduced on 1.91.95.0 before this plan was written, on a simulated Capy host (this machine: Ubuntu 24.04, `/usr/local/bin/bun` 1.3.14, no Codex CLI, a verified Bun 1.4.2 in `~/.bun/bin` off PATH). Receipts live in four investigation reports (lanes A, B1, B2, CD); the load-bearing ones are quoted inline below. + +Already fixed on main and only verified here: item 6 for new runs (#3160, `decisionId` mints `-r-