diff --git a/CHANGELOG.md b/CHANGELOG.md index c1daf97de..73bba91f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ **The session check stops failing for things that are not failures, and a consuming repository can name the gstack it wants without broadcasting to every thread.** Five Captains running gstack on Capy hit the same install friction this week: eight crew-wide "upgrade to " messages since Oct 16, `gstack: fail not-installed` on coordinator machines that run no workflow, `fail pins` from the image's Node while the tests run on the pinned one, reviewers told to "reinstall" when asked to run a workflow, and an environment dump from a broken `/cso` shell line. Each of these is now a product behaviour instead of a crew rule. ### Added -- **Project floor** (`floor` row in `gstack-doctor --check`, `lib/doctor-components.ts`, `bin/gstack-doctor-check.sh`): a consuming repository declares the oldest gstack it wants its crew on as `min_gstack_version` in its `.gstack/ship-policy.json` (the key `gstack-ship-queue` already validates; a file holding only that key is valid). The doctor reads it from the project the check runs in: `PASS floor declared by `, `SKIP floor (... declares none)`, or `WARN floor — installed is below the floor ...; fix: ` (`PROJECT_FLOOR_UNMET`). The Capy installer adds `floor= ()` and a `fix:` note to its ok trailer. One commit in the project replaces a message to every thread; the fix on Capy is the install command, which fast-forwards a clean checkout to origin in one step. +- **Project floor** (`floor` row in `gstack-doctor --check`, `lib/doctor-components.ts`, `bin/gstack-doctor-check.sh`): a consuming repository declares the oldest gstack it wants its crew on as `min_gstack_version` in its `.gstack/ship-policy.json` (the field `gstack-ship-queue` already validates; a file holding only that field is valid). The doctor reads it from the project the check runs in: `PASS floor declared by `, `SKIP floor (... declares none)`, or `WARN floor — installed is below the floor ...; fix: ` (`PROJECT_FLOOR_UNMET`). The Capy installer adds `floor= ()` and a `fix:` note to its ok trailer. One commit in the project replaces a message to every thread; the fix on Capy is the install command, which fast-forwards a clean checkout to origin in one step. - **`--for none`** (`capy/skill/gstack/scripts/install.sh` wrapper 3, `bin/gstack-capy-install`): a machine that runs no gstack workflow (a coordinator, a liaison, a Captain) prints `gstack: ok not-needed for=none ()` and exits 0, installing, moving and certifying nothing, with or without a checkout. The bootstrapper answers it itself, so the line is honest for any checkout revision. That ok admits no workflow: no install verdict is written, so a workflow started there is refused with the usual `--for ` fix. `GSTACK_CAPY_MIN_WRAPPER` is 3; older Drive copies keep working and print the owner action. - **Review-voice machines upgrade in place** (`bin/gstack-capy-install`): running `--for ` on a machine installed `--for review-voice` says `upgrading the review-voice install in place to --for (same checkout ...; no second clone)`, applies the project's pins, runs `./setup` and both host profiles, and writes the workflow's verdict over the reviewer's. The admission refusal's fix now says so (`upgrades this checkout in place: ./setup and host profiles, no second clone`) instead of reading like a reinstall. @@ -13,6 +13,8 @@ - **A gap above the supported minimum is never a hard fail** (`bin/gstack-capy-install`): an install-mode session whose session-start fast-forward was skipped (dirty, diverged or pinned checkout, a live lease, `gstack-upgrade` missing) prints `WARN behind — ... and the session-start upgrade was skipped: ; fix: (UPGRADE_BUSY|UPGRADE_REFUSED|UPGRADE_SKIPPED)` and keeps its ok trailer with `behind= (latest: ); upgrade skipped: (); fix: ` notes, exit 0. It used to be `gstack: fail behind`, exit 1. Below the supported minimum the bootstrapper still fails `revision` (`REVISION_BELOW_MINIMUM`). - **A Node-only pin mismatch is advisory** (`bin/gstack-doctor-check.sh`): when the project's Node pin is unmet by the Node on PATH while every Bun pin is met, `pins` is `WARN pins — ... (node pin only; bun pins pass); fix: ... (PROJECT_NODE_PIN_UNMET)` and the trailer stays ok. gstack runs on Bun and the project's tests choose their Node (nvm, the CI matrix). A Bun pin that is unmet stays `FAIL pins` (`PROJECT_PIN_MISMATCH`). - **WARN rows print their action as given** (`_dc_row`): `owner action: ...` for the Capy skill copy, `fix: ...` for the floor, a skipped upgrade and a Node pin, so a lane knows which WARNs it can clear itself. +- **A Drive copy below the wrapper minimum is a WARN in a direct run too** (`bin/gstack-doctor-check.sh`): with no bootstrapper provenance (`bin/gstack-upgrade`'s installer run, a direct `gstack-doctor --check`), every Drive copy at a bootstrapper revision below `GSTACK_CAPY_MIN_WRAPPER` is `WARN wrapper — every Capy skill copy is below the minimum wrapper 3 (it still works): ...` with the owner action and an ok trailer; only a pre-bootstrapper copy (no `GSTACK_CAPY_WRAPPER=` line) still fails. Found by /ship's red team: raising the minimum to 3 would otherwise have failed every direct run on a machine whose Drive copy is the deployed wrapper 2 until the owner recopied the skill. +- **A `--for none` session never self-upgrades** (`bin/gstack-capy-session`): an older bootstrapper (wrapper 1 or 2) hands `--for none` to the session in install mode; the session now treats it like `--check` (reports `behind=`, moves nothing) before the installer prints `ok not-needed`. Wrapper 3 answers `--for none` before the session. The session's header comment describes the `WARN behind` outcome (it said `fail behind`). - **/cso** (`cso/SKILL.md.tmpl`): the always-loaded startup block gains two rules. Environment hygiene: prerequisite checks read a variable as set or unset only, and `env`, `printenv`, `set -x`, a bare `set` or `export`, `export $(...)` and `echo "$NAME"` never appear in a tool fence. Tooling errors fail loudly: a non-zero exit with no helper status is retried at most twice (5 s, 30 s) and then ends **not assessed** for whatever the helper never recorded, quoting the command, exit code and stderr tail; never a quiet partial, never another review offered as the missing coverage. - **Capy skill and docs** (`capy/skill/gstack/SKILL.md`, `docs/capy-install.md`): the session-start steps cover `--for none`, `floor=`, `behind=` as informational, the Node WARN and the in-place review-voice upgrade; run directories and scratch on Capy live under `~/.capy/work/`, never `/tmp`, which Capy wipes when a machine sleeps. - **Result codes** (`lib/result-codes-capy.ts`): `PROJECT_FLOOR_UNMET`, `PROJECT_NODE_PIN_UNMET` and `UPGRADE_SKIPPED`, each with a `docs/troubleshooting.md` anchor. @@ -20,7 +22,8 @@ ### Tests - `test/gstack-doctor.test.ts`: the `floor` row's four outcomes (absent file, null key, met, unmet with the wrapper fix and without it) and the Node-only WARN beside a Bun-plus-Node FAIL; every required-row list gains `floor`. - `test/gstack-capy-install.test.ts`: a skipped session-start upgrade is an ok trailer with the `upgrade skipped` note and a `WARN behind` row (replacing the `fail behind` pin); a `WARN floor` row becomes the `floor=` and `fix:` notes, with one `fix:` when `behind=` is also present; `--for none` with and without a checkout; a review-voice verdict upgraded in place by `--for autoplan` (setup runs, the verdict becomes the workflow's). -- `test/capy-skill-wrapper.test.ts`: `--for none` through the bootstrapper (checkout present, install mode, no checkout), wrapper 3 in the handover. +- `test/capy-skill-wrapper.test.ts`: `--for none` through the bootstrapper (checkout present, install mode, no checkout), wrapper 3 in the handover; a wrapper-2 handover of `--for none` in install mode leaves HEAD where it was. +- `test/gstack-doctor.test.ts`: a direct run with every Drive copy a bootstrapper below the minimum is a WARN with an ok trailer, and a pre-bootstrapper copy beside it keeps the FAIL. `test/gstack-capy-install.test.ts`: an unknown skip code collapses to `UPGRADE_SKIPPED` with its anchor; the in-place upgrade message is silent for a same-profile rerun, `--check` and a second review-voice install. - `test/helpers/carve-guards.ts`: the /cso skeleton pin moves to 18,900 (measured 18,843) for the two startup paragraphs; `test/fixtures/context-budget.json` pins `capy/skill/gstack` and `cso` at their new eager sizes. ### Gate integrity diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index 017af4685..4296bdec8 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -35,8 +35,10 @@ # "; " (the note separator): after a refused self-upgrade it is bin/gstack-upgrade, # which prints its own refusal and fix. A skipped self-upgrade (refused, busy, # or gstack-upgrade missing) also exports GSTACK_CAPY_BEHIND_BLOCKED=, and the installer turns an ok trailer into `gstack: fail behind`: a -# session that meant to upgrade and could not is never certified ok. +# (CODE)>, and the installer adds a `WARN behind` row with that reason and code: +# the trailer stays ok (the installed revision is above the supported minimum), +# and the note names the fix. A `--for none` session certifies nothing and moves +# nothing, so it never self-upgrades (it behaves like --check). # # Usage: gstack-capy-session [--check] --root [gstack-capy-install arguments] # --check certify only (bin/gstack-capy-install --check: the doctor with the @@ -53,6 +55,7 @@ for a in "$@"; do [ "$a" = --check ] && CHECK=1 prev="$a" done +[ "$SKILL" = none ] && CHECK=1 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)" diff --git a/bin/gstack-doctor-check.sh b/bin/gstack-doctor-check.sh index 4dc7849e0..1054a3c84 100644 --- a/bin/gstack-doctor-check.sh +++ b/bin/gstack-doctor-check.sh @@ -527,7 +527,7 @@ _dc_wrapper_rev() { # ~/.capy/drive/*/skills/gstack/scripts/install.sh, failing only when each is # stale. The workaround runs the checkout's own copy, which certifies. _dc_check_wrapper() { - local min copy rev stale="" current="" active="${GSTACK_CAPY_WRAPPER_PATH:-}" workaround owner + local min copy rev stale="" current="" pre="" active="${GSTACK_CAPY_WRAPPER_PATH:-}" workaround owner min=$(sed -n 's/^GSTACK_CAPY_MIN_WRAPPER=\([0-9][0-9]*\)[[:space:]]*$/\1/p' "$SRC/capy/support.env" 2>/dev/null | head -1); min="${min:-1}" workaround="bash $SRC/capy/skill/gstack/scripts/install.sh --check${CHECK_FOR:+ --for $CHECK_FOR} (this session)" owner="owner action: copy $SRC/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes" @@ -536,10 +536,12 @@ _dc_check_wrapper() { [ -n "${GSTACK_CAPY_WRAPPER_REV:-}" ] && [ "$copy" = "$active" ] && continue [ -z "${GSTACK_CAPY_WRAPPER_REV:-}" ] && [ -n "$active" ] && [ -f "$active" ] && [ "$copy" != "$active" ] && continue rev=$(_dc_wrapper_rev "$copy") + [ "$rev" -eq 0 ] 2>/dev/null && pre=1 if [ "$rev" -lt "$min" ] 2>/dev/null; then stale="${stale:+$stale, }$copy (wrapper $rev)"; else current="${current:+$current, }$copy (wrapper $rev)"; fi done if [ -z "${GSTACK_CAPY_WRAPPER_REV:-}" ] && [ -n "$active" ] && [ -f "$active" ] && [ -z "$stale$current" ]; then rev=$(_dc_wrapper_rev "$active") + [ "$rev" -eq 0 ] 2>/dev/null && pre=1 if [ "$rev" -lt "$min" ] 2>/dev/null; then stale="$active (wrapper $rev)"; else current="$active (wrapper $rev)"; fi fi if [ -n "${GSTACK_CAPY_WRAPPER_REV:-}" ]; then @@ -552,6 +554,10 @@ _dc_check_wrapper() { fi elif [ -n "$current" ]; then _dc_row PASS wrapper "Drive copy $current (minimum $min)${stale:+; stale copies: $stale}" + elif [ -n "$stale" ] && [ -z "$pre" ]; then + # Every copy is a bootstrapper below the minimum: it still works (a direct + # run has no provenance to say so), so the row is the owner action, not a FAIL. + _dc_row WARN wrapper "every Capy skill copy is below the minimum wrapper $min (it still works): $stale" "$owner" CAPY_WRAPPER_STALE elif [ -n "$stale" ]; then _dc_row FAIL wrapper "every Capy skill copy is stale (minimum wrapper $min): $stale" "$workaround; owner: ${owner#owner action: }" CAPY_WRAPPER_STALE else diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index e8b0352f9..e92d2d095 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -9,7 +9,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` never fails the run: it names an owner action or a fix you can run), 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. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none ()`, exit 0, nothing installed or certified, no workflow admitted. Record the line; install `--for ` before the first workflow, if one comes. +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` never fails the run: it names an owner action or a fix you can run), 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. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none ()`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok for=`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for ` before the first workflow, if one comes. 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 `floor= ()` or `behind= (latest: )` with a `fix: ` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row): run the fix before the first workflow, every time. `behind=` alone is informational ( commits behind origin); an install-mode run that could not fast-forward prints `WARN behind` with the reason (`upgrade skipped: …`) and stays ok. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim, `behind=` and `floor=` included. 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: the trailer names the cause and the exact commands (above the floor it stays ok with `WARN behind` after `SELF_UPGRADE: skipped ()`, carrying `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `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; …)`, `SKIP pins (planning-only; …)`, `SKIP pins (helper runtime; …)` and `SKIP floor (…)` are not failures. `WARN pins … (node pin only; bun pins pass)` is not one either: the Node on PATH misses the project's pin while Bun is right; run its fix only when this machine will run the project's tests. diff --git a/docs/capy-install.md b/docs/capy-install.md index a8561a0a8..1c3be0700 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -506,8 +506,10 @@ floor is not what keeps machines current). It never exceeds VERSION changes in a way older copies cannot work with; older Drive copies keep working and print an owner action ([CAPY_WRAPPER_STALE](troubleshooting.md#capy-wrapper-stale)), and the -doctor's `wrapper` row warns (an active copy below the minimum is a WARN, -never a FAIL; `bin/gstack-upgrade` never leaves a pending reinstall for it). +doctor's `wrapper` row warns (a bootstrapper copy below the minimum is a WARN, +active or found in the Drive by a direct run, never a FAIL; only a +pre-bootstrapper copy with no `GSTACK_CAPY_WRAPPER=` line fails; +`bin/gstack-upgrade` never leaves a pending reinstall for it). Wrapper 2 only added optional exports (the trailer's `latest:` note and the `cloned` line). Wrapper 3 answers `--check --for none` itself (a copy at 1 or 2 prints `fail not-installed` for a coordinator machine and still works for every diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3668c2dd7..16d938c66 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1574,8 +1574,10 @@ every lease with its age and `alive|dead|stale`; a holder is named as When you know they are idle, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade` proceeds. At a Capy session start the same refusal appears as `SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`, -and the trailer is `gstack: fail behind — … (UPGRADE_BUSY)`: a session that -could not upgrade is never certified ok. +the doctor adds `WARN behind — … (UPGRADE_BUSY)` and the trailer stays +`gstack: ok … (behind= (latest: ); upgrade skipped: … (UPGRADE_BUSY); fix: …)`: +the installed revision is above the supported minimum, so the session is +certified and the note names the fix. ### `gstack-upgrade: could not reach origin (...) (UPGRADE_FETCH_FAILED)` @@ -1618,9 +1620,12 @@ action that never fails the run. Without provenance (a direct `gstack-doctor --check`, or a pre-bootstrapper copy with no `GSTACK_CAPY_WRAPPER=` line, which reads as wrapper 0) it reads the Drive copies (`~/.capy/drive/*/skills/gstack/scripts/install.sh`, or only -`GSTACK_CAPY_WRAPPER_PATH` when set) and fails only when every copy is stale, -listing each with its revision. A stale active wrapper is a `WARN` too: an -older copy still works (`capy/support.env`). `bin/gstack-upgrade` runs the +`GSTACK_CAPY_WRAPPER_PATH` when set), listing each with its revision: every +copy a bootstrapper below the minimum is `WARN wrapper — every Capy skill copy +is below the minimum wrapper (it still works): ...` with the owner action, +and it fails only when a copy is pre-bootstrapper (wrapper 0), which cannot +hand over at all. A stale active wrapper is a `WARN` too: an older copy still +works (`capy/support.env`). `bin/gstack-upgrade` runs the installer with the checkout's own copy as the active wrapper and never leaves a pending reinstall when the wrapper row is the only failure. diff --git a/lib/doctor-components.ts b/lib/doctor-components.ts index a5435980e..dfa5cde38 100644 --- a/lib/doctor-components.ts +++ b/lib/doctor-components.ts @@ -41,6 +41,7 @@ */ import { RESULT_CODES } from './result-codes'; +import { SHIP_POLICY_PATH } from './ship-policy'; export type ComponentKind = 'core' | 'host' | 'component' | 'optional'; @@ -100,7 +101,7 @@ export const NOT_NEEDED_PROFILE = 'none'; export const NOT_NEEDED_REVISION = 'not-needed'; /** The project file and key the `floor` row reads (the same key gstack-ship-queue validates). */ -export const PROJECT_FLOOR_FILE = '.gstack/ship-policy.json'; +export const PROJECT_FLOOR_FILE = SHIP_POLICY_PATH; export const PROJECT_FLOOR_FIELD = 'min_gstack_version'; /** The read-only helpers the `helpers` row launches (` --help` loads each module graph with the launch Bun). */ diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index 5bf22a4f1..a2f9d0506 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -393,6 +393,18 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () => } }); + test('an older bootstrapper handing --for none to the session in install mode: the session certifies nothing and moves nothing (no self-upgrade, HEAD unchanged), and reports behind like --check', () => { + 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']); + f.git(['fetch', '-q', 'origin', 'main']); + const r = spawnSync('bash', [path.join(f.root, 'bin/gstack-capy-session'), '--root', f.root, '--for', 'none'], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, GSTACK_CAPY_REMOTE_MIN: '1.0.1', GSTACK_CAPY_WRAPPER_REV: '2', GSTACK_CAPY_WRAPPER_PATH: BOOT }, timeout: 120_000 }); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).not.toMatch(/^SELF_UPGRADE:/m); + expect(r.stdout.trim().split('\n').at(-1)).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=bash ${BOOT} --for none`); + 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']); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index fc9415ab4..a04a1a2f8 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -293,6 +293,11 @@ describe('gstack-capy-install', () => { const bare = run(f, [], { ...busy, GSTACK_CAPY_BEHIND_BLOCKED: 'gstack-upgrade exit 9' }); expect(bare.status).toBe(0); expect(lastLine(bare.stdout)).toBe(`gstack: ok 1.2.3 for=autoplan (behind=3 (latest: 1.2.6); upgrade skipped: gstack-upgrade exit 9 (UPGRADE_REFUSED); fix: ${f.root}/bin/gstack-upgrade)`); + // A code the installer does not know collapses to UPGRADE_SKIPPED, whose anchor exists; the reason and fix are kept. + const other = run(f, [], { ...busy, GSTACK_CAPY_BEHIND_BLOCKED: 'origin unreachable; fix: retry when online (UPGRADE_FETCH_FAILED)' }); + expect(other.status).toBe(0); + expect(lastLine(other.stdout)).toBe('gstack: ok 1.2.3 for=autoplan (behind=3 (latest: 1.2.6); upgrade skipped: origin unreachable (UPGRADE_SKIPPED); fix: retry when online)'); + expect(other.stdout).toMatch(/^WARN behind — .*; fix: retry when online \(UPGRADE_SKIPPED; .*#upgrade-skipped\)$/m); expect(lastLine(run(f, [], { ...busy, STUB_DOCTOR_FAIL: '1' }).stdout)).toBe('gstack: fail codex'); }); @@ -338,6 +343,11 @@ describe('gstack-capy-install', () => { const verdict = full.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1]!; expect(JSON.parse(fs.readFileSync(verdict, 'utf8'))).toMatchObject({ for: 'autoplan', ok: true }); expect(lastLine(full.stdout)).toBe('gstack: ok 1.2.3 for=autoplan'); + // The message is for the review-voice -> workflow step only: a rerun of the same profile, a --check and a second review-voice install stay silent. + const upgrading = /upgrading the review-voice install in place/; + expect(run(f, ['--for', 'autoplan', '--project', project]).stdout).not.toMatch(upgrading); + expect(run(f, ['--check', '--for', 'autoplan', '--project', project]).stdout).not.toMatch(upgrading); + expect(run(f, ['--for', 'review-voice', '--project', project]).stdout).not.toMatch(upgrading); }); test('--root clones when the directory is absent and refuses a non-gstack directory', () => { diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index eef3e53c7..836d74e77 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -885,6 +885,22 @@ describe('gstack-doctor --check: wrapper and runtime drift', () => { expect(fresh.status, fresh.out).toBe(0); }); + posixPathTest('direct doctor run: a bootstrapper Drive copy below the minimum still works, so it is an owner-action WARN with an ok trailer (only a pre-bootstrapper copy fails)', () => { + const f = makeFixture(); + withSupport(f, Number(currentRev) + 1); + const old = drive(f, 'user-garry-tan', current); + const r = parse(f, []); + expect(r.status, r.out).toBe(0); + expect(r.trailer).toMatch(/^gstack: ok /); + expect(r.line('wrapper')).toBe(`WARN wrapper — every Capy skill copy is below the minimum wrapper ${Number(currentRev) + 1} (it still works): ${old} (wrapper ${currentRev}); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); + const json = JSON.parse(doctor(f, ['--check', '--json']).out); + expect(json.ok).toBe(true); + expect(json.rows.find((x: { id: string }) => x.id === 'wrapper')).toMatchObject({ status: 'WARN', code: 'CAPY_WRAPPER_STALE' }); + // A pre-bootstrapper copy beside it keeps the FAIL: that copy cannot hand over at all. + drive(f, 'org', PRE_BOOTSTRAPPER); + expect(parse(f, []).trailer).toBe('gstack: fail wrapper'); + }); + posixPathTest('two Drive copies, one current: PASS naming the stale one; GSTACK_CAPY_WRAPPER_PATH alone decides when set without a revision', () => { const f = makeFixture(); withSupport(f);