diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 4f112da2f..ab1cb2c7b 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -465,11 +465,15 @@ EOF_SHIM # 4. Certify. The trailer is the contract, in one grammar: # gstack: ok for=[ (requested: …; pins: skipped (planning-only|no project); latest: available)] +# gstack: ok for= ([requested: …; ][pins: …; ]behind= (latest: ); fix: ) # Notes, in that fixed order, ride only on an ok trailer: `requested` when the # existing checkout is not at --revision (it is never moved here), `pins` when -# the doctor skipped the project pins, `latest` when the bootstrapper read a -# newer VERSION from origin in this run, exported as GSTACK_CAPY_REMOTE_VERSION. The -# failure head (`gstack: fail `) is unchanged. +# the doctor skipped the project pins, then either `behind= (latest: )` +# and `fix: ` when the session ends behind origin's default branch +# (bin/gstack-capy-session exports GSTACK_CAPY_BEHIND, _BEHIND_TO, _BEHIND_FIX), or +# `latest` when the bootstrapper read a newer VERSION from origin in this run, +# exported as GSTACK_CAPY_REMOTE_VERSION. The failure head (`gstack: fail `) +# is unchanged. INSTALLED=$(head -1 "$ROOT/VERSION") TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)" NOTES="" @@ -482,7 +486,10 @@ if printf '%s\n' "$OUT" | grep -q '^SKIP pins (planning-only'; then note "pins: elif printf '%s\n' "$OUT" | grep -Eq '^SKIP pins \((no project|project directory )'; then note "pins: skipped (no project)" fi REMOTE_VERSION="${GSTACK_CAPY_REMOTE_VERSION:-}" -if [ -n "$REMOTE_VERSION" ] && [ "$REMOTE_VERSION" != "$INSTALLED" ] \ +if [ -n "${GSTACK_CAPY_BEHIND:-}" ]; then + note "behind=$GSTACK_CAPY_BEHIND (latest: ${GSTACK_CAPY_BEHIND_TO:-${REMOTE_VERSION:-unknown}})" + note "fix: ${GSTACK_CAPY_BEHIND_FIX:-bash /scripts/install.sh}" +elif [ -n "$REMOTE_VERSION" ] && [ "$REMOTE_VERSION" != "$INSTALLED" ] \ && [ "$(printf '%s\n%s\n' "$INSTALLED" "$REMOTE_VERSION" | sort -V | head -1)" = "$INSTALLED" ]; then note "latest: $REMOTE_VERSION available" fi diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index ac04de7ac..434f81094 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -28,6 +28,12 @@ # GSTACK_CAPY_AUTO_UPGRADE=0>)`. # --check never moves the checkout; behind origin it prints # `gstack-capy-session: behind origin/: -> ( commits); fix: ...`. +# Whenever the session ends behind origin (--check, or a self-upgrade that was +# skipped or disabled) it exports GSTACK_CAPY_BEHIND=, GSTACK_CAPY_BEHIND_TO= and GSTACK_CAPY_BEHIND_FIX= for the installer's trailer notes +# `behind= (latest: ); fix: `. The command never contains +# "; " (the note separator): after a refused self-upgrade it is bin/gstack-upgrade, +# which prints its own refusal and fix. # # Usage: gstack-capy-session [--check] --root [gstack-capy-install arguments] # --check certify only (bin/gstack-capy-install --check: the doctor with the @@ -65,18 +71,35 @@ behind() { git -C "$ROOT" rev-parse -q --verify FETCH_HEAD >/dev/null 2>&1 || return 1 ! git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null } -disabled() { echo "SELF_UPGRADE: disabled ($1)"; } # VAR — the switch that turned the fast-forward off +disabled() { echo "SELF_UPGRADE: disabled ($1)"; behind && report_behind "unset $1, then $RERUN"; } # VAR — the switch that turned the fast-forward off +# report_behind FIX — hand the installer the gap and the command that closes +# it, for the trailer's `behind=` note (lanes relay only the trailer). +report_behind() { + export GSTACK_CAPY_BEHIND="$(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?')" \ + GSTACK_CAPY_BEHIND_TO="$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1)" GSTACK_CAPY_BEHIND_FIX="$1" +} +unset GSTACK_CAPY_BEHIND GSTACK_CAPY_BEHIND_TO GSTACK_CAPY_BEHIND_FIX +RERUN="bash ${GSTACK_CAPY_WRAPPER_PATH:-/scripts/install.sh}${SKILL:+ --for $SKILL}" if [ "$CHECK" -eq 1 ]; then if behind; then + # The rerun fast-forwards only what gstack-upgrade would move; otherwise + # gstack-upgrade itself names the refusal and its fix. + if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN" + elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then report_behind "unset GSTACK_CAPY_AUTO_UPGRADE, then $RERUN" + elif [ "$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)" != "$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null)/FETCH_HEAD" 2>/dev/null)" ] \ + || [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] \ + || ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then report_behind "$ROOT/bin/gstack-upgrade" + else report_behind "$RERUN" + fi BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null || echo HEAD) - echo "gstack-capy-session: behind origin/$BRANCH: $(head -1 "$ROOT/VERSION" 2>/dev/null) -> $(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1) ($(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?') commits); fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-/scripts/install.sh}${SKILL:+ --for $SKILL} (fast-forwards)" + echo "gstack-capy-session: behind origin/$BRANCH: $(head -1 "$ROOT/VERSION" 2>/dev/null) -> $GSTACK_CAPY_BEHIND_TO ($GSTACK_CAPY_BEHIND commits); fix: $GSTACK_CAPY_BEHIND_FIX$([ "$GSTACK_CAPY_BEHIND_FIX" = "$RERUN" ] && echo ' (fast-forwards)')" fi elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then disabled GSTACK_CAPY_SELF_UPGRADE elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE elif [ -z "${GSTACK_CAPY_REMOTE_MIN:-}" ] || [ "$GSTACK_CAPY_REMOTE_MIN" = unknown ]; then echo "SELF_UPGRADE: none (remote minimum unknown)" elif ! behind; then echo "SELF_UPGRADE: none (current)" - elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)" + elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN" else OLD=$(head -1 "$ROOT/VERSION" 2>/dev/null) UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$? @@ -89,6 +112,7 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: \(.*\); nothing was changed; fix: /\1; fix: /p' | tail -1) [ -n "$REASON" ] || REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: //p' | tail -1) echo "SELF_UPGRADE: skipped (${REASON:-gstack-upgrade exit $UP_RC})" + report_behind "$ROOT/bin/gstack-upgrade" fi fi exec "$INSTALLER" "$@" diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 746664a77..80a9a6056 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -10,7 +10,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta 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|WARN ` row per component (`WARN` is an owner action), 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 names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or the output carries a `gstack-capy-session: behind origin/: …` line, run the same command without `--check` (900-second timeout). For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded -> `; above it by the checkout, `SELF_UPGRADE: done -> `), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: below the floor the trailer names the cause and the exact commands; above it the run prints `SELF_UPGRADE: skipped ()` and continues on the installed revision. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. +2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `behind= (latest: ); fix: ` (the checkout is commits behind origin; the output also has a `gstack-capy-session: behind origin/: …` line), run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. Report the trailer verbatim, `behind=` included: a lane that is behind says so. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded -> `; above it by the checkout, `SELF_UPGRADE: done -> `), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: below the floor the trailer names the cause and the exact commands; above it the run prints `SELF_UPGRADE: skipped ()` and continues on the installed revision. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. 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, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun). 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. diff --git a/docs/capy-install.md b/docs/capy-install.md index c37b1ae61..f424e1cf0 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -49,7 +49,11 @@ git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.lo `SELF_UPGRADE: disabled ()`. `--check` never moves the checkout; behind origin it prints `gstack-capy-session: behind origin/: -> ( commits); fix: bash /scripts/install.sh - [--for ] (fast-forwards)` and the agent runs that fix. + [--for ] (fast-forwards)` and the agent runs that fix. Whenever the + session ends behind origin (`--check`, or a self-upgrade that was skipped or + disabled), the ok trailer also says so, because lanes relay only the + trailer: `behind= (latest: ); fix: ` (see the trailer + grammar below). 4. When the fetch fails (offline), the bootstrapper says `remote minimum unknown (fetch failed)` and checks the local install only; it never claims the minimum, and nothing is fast-forwarded. @@ -192,13 +196,18 @@ The last line is the trailer: gstack: ok 1.91.96.0 for=autoplan gstack: ok 1.91.96.0 for=review (requested: v1.91.90.0 (run /bin/gstack-upgrade to move); latest: 1.91.101.0 available) gstack: ok 1.91.96.0 for=autoplan (pins: skipped (planning-only)) +gstack: ok 1.91.101.0 for=autoplan (behind=12 (latest: 1.91.107.0); fix: bash /scripts/install.sh --for autoplan) gstack: fail runtime,pins ``` An ok trailer carries at most one parenthesised note group, `; `-separated, in this order: `requested: …` (an existing checkout at another revision than -`--revision`), `pins: skipped (planning-only|no project)`, and `latest: -available` (origin's default branch has a newer VERSION; the bootstrapper +`--revision`), `pins: skipped (planning-only|no project)`, then either +`behind= (latest: )` followed by `fix: ` (the session ended + commits behind origin's default branch after a fetch that succeeded in this +run; `bin/gstack-capy-session` exports `GSTACK_CAPY_BEHIND`, `_BEHIND_TO` and +`_BEHIND_FIX`, so this works with every bootstrapper revision), or `latest: + available` (origin's default branch has a newer VERSION; the bootstrapper exports it as `GSTACK_CAPY_REMOTE_VERSION` only after a fetch that succeeded in this run). A fail trailer never carries notes. Match `^gstack: ok ` or `^gstack: fail `; never parse past the first space after the version. diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index b7f8b6c10..12164f48d 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -308,6 +308,60 @@ describe('capy skill bootstrapper: GSTACK_CAPY_REMOTE_VERSION', () => { }); }); +// Field list 2 item 13: lanes relay only the trailer, so a session that ends +// behind origin hands the installer the gap and the fix for its trailer note. +describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () => { + const ENV_INSTALLER = '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan behind=${GSTACK_CAPY_BEHIND:-none} to=${GSTACK_CAPY_BEHIND_TO:-none} fix=${GSTACK_CAPY_BEHIND_FIX:-none}"\n'; + const rerun = `bash ${BOOT} --for autoplan`; + + test('--check behind origin: the commit count, origin\'s VERSION and the fast-forwarding command; a checkout the rerun would not move points at gstack-upgrade', () => { + const f = fixture({ cloneAt: 'B' }); + write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755); + f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']); + const r = f.run(['--check', '--for', 'autoplan']); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${rerun}`); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n'); + expect(f.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`); + f.git(['checkout', '-q', '--', 'README.md']); + f.git(['checkout', '-q', '--detach']); + expect(f.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`); + f.git(['checkout', '-q', '-b', 'topic']); + expect(f.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`); + f.git(['checkout', '-q', 'main']); + expect(f.run(['--check', '--for', 'autoplan']).stdout).toMatch(/^gstack-capy-session: behind origin\/main: 1\.0\.1 -> 1\.0\.2 \(1 commits\); fix: bash .+ --for autoplan \(fast-forwards\)$/m); + expect(f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' }).last).toContain('fix=unset GSTACK_CAPY_AUTO_UPGRADE, then '); + }); + + test('install mode: a skipped self-upgrade points at gstack-upgrade (which names its refusal); a disabled one names the variable to unset; no fix carries the "; " note separator', () => { + const f = fixture({ cloneAt: 'B' }); + write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755); + const skipped = f.run(['--for', 'autoplan']); + expect(skipped.stdout).toMatch(/^SELF_UPGRADE: skipped \(tracked files changed: bin\/gstack-capy-install; fix: .+\)$/m); + expect(skipped.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`); + const disabled = f.run(['--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off' }); + expect(disabled.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=unset GSTACK_CAPY_SELF_UPGRADE, then ${rerun}`); + for (const r of [skipped, disabled]) expect(r.last.split(' fix=')[1]).not.toContain('; '); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + }); + + test('current, or the fetch failed: nothing is exported, and values inherited from a parent session are dropped', () => { + const f = fixture({ cloneAt: 'B' }); + f.git(['fetch', '-q', 'origin', 'main']); + f.git(['merge', '--ff-only', '-q', 'FETCH_HEAD']); + write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755); + const inherited = { GSTACK_CAPY_BEHIND: '9', GSTACK_CAPY_BEHIND_TO: '9.9.9', GSTACK_CAPY_BEHIND_FIX: 'stale' }; + expect(f.run(['--check', '--for', 'autoplan'], inherited).last).toBe('gstack: ok 1.0.2 for=autoplan behind=none to=none fix=none'); + f.git(['reset', '-q', '--hard', f.b]); + write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755); + f.git(['remote', 'set-url', 'origin', `file://${f.host.base}/missing.git`]); + const offline = f.run(['--check', '--for', 'autoplan'], inherited); + expect(offline.stdout).toContain('remote minimum unknown (fetch failed)'); + expect(offline.last).toBe('gstack: ok 1.0.1 for=autoplan behind=none to=none fix=none'); + }); +}); + describe('capy skill: vendored text and support file', () => { const support = fs.existsSync(path.join(REPO, 'capy/support.env')) ? fs.readFileSync(path.join(REPO, 'capy/support.env'), 'utf8') : ''; test('the wrapper revision meets the checkout minimum; GSTACK_MIN_REVISION <= VERSION', () => { diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 8da8b78f6..729f3fb3e 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -269,6 +269,16 @@ describe('gstack-capy-install', () => { expect(lastLine(failed.stdout)).toBe('gstack: fail codex'); }); + test('trailer grammar: behind= and its fix replace latest, after requested and pins; never on a fail trailer', () => { + const f = fixture(); + const behind = { GSTACK_CAPY_BEHIND: '5', GSTACK_CAPY_BEHIND_TO: '1.2.8', GSTACK_CAPY_BEHIND_FIX: 'bash /s/install.sh --for autoplan', GSTACK_CAPY_REMOTE_VERSION: '1.2.8' }; + expect(lastLine(run(f, [], behind).stdout)).toBe('gstack: ok 1.2.3 for=autoplan (behind=5 (latest: 1.2.8); fix: bash /s/install.sh --for autoplan)'); + const all = run(f, ['--revision', 'v9.9.9.9'], { ...behind, STUB_PINS_SKIP: 'planning-only; repo pins differ: x' }); + expect(all.status, all.stdout + all.stderr).toBe(0); + expect(lastLine(all.stdout)).toBe(`gstack: ok 1.2.3 for=autoplan (requested: v9.9.9.9 (run ${f.root}/bin/gstack-upgrade to move); pins: skipped (planning-only); behind=5 (latest: 1.2.8); fix: bash /s/install.sh --for autoplan)`); + expect(lastLine(run(f, [], { ...behind, STUB_DOCTOR_FAIL: '1' }).stdout)).toBe('gstack: fail codex'); + }); + test('--root clones when the directory is absent and refuses a non-gstack directory', () => { const f = fixture(); const clone = path.join(f.base, 'clone');