diff --git a/bin/gstack-bun-version.sh b/bin/gstack-bun-version.sh index 2d5f6fa78..e085d6fcf 100644 --- a/bin/gstack-bun-version.sh +++ b/bin/gstack-bun-version.sh @@ -88,3 +88,29 @@ gstack_bun_incoming_hold() { _gb_ver=$(git -C "$1" show "$2:VERSION" 2>/dev/null | head -1) echo "bun-too-old: found Bun $_gb_found at $(command -v bun); gstack ${_gb_ver:-update} needs $_gb_floor or newer" } + +# gstack_bun_launch_dir [exact] — the directory to put first on PATH so +# gstack's `#!/usr/bin/env bun` bins run a supported Bun: the first of +# $BUN_INSTALL/bin and $HOME/.bun/bin whose bun is ok (and equals +# 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-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/docs/troubleshooting.md b/docs/troubleshooting.md index 54e28a2a9..0424ba275 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1126,10 +1126,11 @@ constraint with its source), or change the project's pin. revision (a VERSION string, tag, branch or commit) and the checkout is at another. An unmet revision is never `ok`. -**Fix.** Move the checkout: `/gstack-upgrade`, or +**Fix.** Move the checkout: `/bin/gstack-upgrade` (the tip of the default +branch; `/gstack-upgrade` interactively), or `git -C fetch origin && git -C checkout && ./setup`. `bin/gstack-capy-install --revision ` never moves an existing checkout; it -reports `gstack: ok (requested ; run gstack-upgrade to move)`. +reports `gstack: ok (requested ; run /bin/gstack-upgrade to move)`. ### `gstack-capy-install: Bun conflict: gstack needs >=, pins (RUNTIME_PIN_CONFLICT; ...)` @@ -1170,6 +1171,80 @@ or nvm), then re-run `bin/gstack-capy-install`. A checksum mismatch means the upstream installer changed: update `GSTACK_BUN_INSTALL_SHA` only after reviewing the new script. + +### `gstack-upgrade: ; nothing was changed; fix: (UPGRADE_REFUSED)` + +**Meaning.** `bin/gstack-upgrade` moves a checkout only by fast-forward to the +tip of its remote default branch. It refused before changing anything: the +directory is not a gstack checkout, HEAD is detached or on another branch (a +pin), a tracked file is modified, a local commit is not on the remote branch, +the incoming release's Bun floor is above the Bun on PATH, or another updater +holds `/.setup-lock`. It never stashes, resets or discards. + +**Fix.** Resolve the named cause with the printed command (commit or move the +listed changes, check out the default branch, move local commits to another +branch), then re-run `bin/gstack-upgrade`. A reinstall that failed after the +fast-forward is completed by the next run (`reinstall pending from `). + + +### `gstack-upgrade: could not reach origin (...) (UPGRADE_FETCH_FAILED)` + +**Meaning.** The remote default branch could not be listed or fetched (offline, +proxy, credentials), so `--check` cannot say whether the checkout is current. +It never reports `current` without the fetch. + +**Fix.** Check network access to the checkout's origin +(`git -C remote -v`), then re-run. + + +### `gstack: fail revision — installed is below the supported minimum (); fix: ... (REVISION_BELOW_MINIMUM)` + +**Meaning.** `capy/support.env` on the remote default branch declares the +oldest revision whose installer, trailer and fix lines are honest +(`GSTACK_MIN_REVISION`), and this checkout is older. The Capy skill +bootstrapper reads it from `FETCH_HEAD`, so even a checkout that predates the +file learns it. `--check` only reports (`fast-forward available`); install +mode fast-forwards a clean, unpinned checkout, reinstalls and rechecks under +one lock. The cause names why nothing moved: `pinned by --revision`, `pinned: +HEAD is detached`, `tracked files changed: `, `diverged: ...` or +`auto-upgrade disabled` (`GSTACK_CAPY_AUTO_UPGRADE=0`). `gstack-doctor --check +--min-revision ` fails the revision row the same way. + +**Fix.** Run the fix clause: the bootstrapper without `--check`, or +`/bin/gstack-upgrade`. For a pin, drop `--revision` or pin a +revision at or above the minimum. An interrupted install after the +fast-forward leaves the trailer `fail`; the next install run completes it. + + +### `gstack-capy-skill: owner action — Drive copy is wrapper , minimum ; copy /capy/skill/gstack/ (CAPY_WRAPPER_STALE)` + +**Meaning.** The Capy skill folder that ran (the bootstrapper's +`GSTACK_CAPY_WRAPPER`) is older than the checkout's `GSTACK_CAPY_MIN_WRAPPER`. +The run continues through the checkout's own wrapper (`bin/gstack-capy-session`); +only a checkout with no such wrapper refuses (`gstack: fail wrapper`). + +**Fix.** The owner copies `/capy/skill/gstack/` over the skill folder +(on Capy, the user-scope Drive `skills/gstack/`). Agents never write it. + + +### `INSTALL: fail verdict — ; fix: ... (INSTALL_UNVERIFIED)` + +**Meaning.** On a Capy machine (`GSTACK_CAPY=1`, set by the `GSTACK_LAUNCH` +line), an unattended or spawned workflow starts only with a passing install +verdict (`/install-verdicts/.json`, +named as `GSTACK_INSTALL_VERDICT`) for this checkout, its current HEAD and a +workflow at least as wide as the one starting: a verdict for `--for autoplan` +does not cover `qa`'s browser, and a planning-only pins SKIP does not cover a +workflow that runs the repo's tests. `gstack-skill-start` printed this row and +`GSTACK_RESULT: skill= status=refused run=-`, exit 3. The same start refuses +an unattended run whose Bun on PATH is below the supported minimum +(`INSTALL: fail runtime ... (RUNTIME_BELOW_MINIMUM)`) on any host. + +**Fix.** Run the Capy skill installer with `--for ` on this machine +and launch with the `GSTACK_LAUNCH` line it prints. +`GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (printed as +`INSTALL_VERDICT: overridden`); record it as a deviation. + --- ## Browser diff --git a/lib/doctor-components.ts b/lib/doctor-components.ts index c2bfeb792..da28cd071 100644 --- a/lib/doctor-components.ts +++ b/lib/doctor-components.ts @@ -132,6 +132,17 @@ export const SKILL_REQUIREMENTS: Readonly> = { unfreeze: CLAUDE_ONLY, }; +/** + * Planning-only workflows: they read and review a plan but never run the + * target repository's tests, so a repo test-pin mismatch is reported as + * `SKIP pins (planning-only; ...)` for `--for ` instead of failing. + * Workflows that run the repo's tests (ship, review, qa, pregate) keep `pins` + * required. + */ +export const PINS_ADVISORY_SKILLS: readonly string[] = [ + 'autoplan', 'plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'plan-devex-review', 'office-hours', +]; + /** Rows always evaluated and required, in print order. */ export const CORE_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'core').map(c => c.id); export const OPTIONAL_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'optional').map(c => c.id); @@ -201,6 +212,14 @@ export function renderDoctorComponentsSh(): string { ' esac', '}', '', + '# gstack_doctor_skill_pins_advisory SKILL — true for a planning-only workflow (a repo test-pin mismatch SKIPs pins).', + 'gstack_doctor_skill_pins_advisory() {', + ' case "$1" in', + caseArm(PINS_ADVISORY_SKILLS, 'return 0'), + ' *) return 1 ;;', + ' esac', + '}', + '', '# gstack_result_anchor CODE — the docs/troubleshooting.md anchor for a lib/result-codes.ts code; returns 1 for an unknown code.', 'gstack_result_anchor() {', ' case "$1" in', diff --git a/lib/result-codes.ts b/lib/result-codes.ts index d73b7c304..eb27f9353 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -466,6 +466,32 @@ export const RESULT_CODES = { summary: 'a `draft direction stands until the owner decides` line names a decision that is still pending', fix: 'answer the gate (`gstack-autoplan answer`) before closing; the close rewrites only decided lines', }, + // Field wave lane A (items 1-4): gstack-upgrade, the Capy skill bootstrapper, install admission. + UPGRADE_REFUSED: { + anchor: 'upgrade-refused', + summary: 'bin/gstack-upgrade changed nothing: the checkout is dirty, diverged, pinned (detached or off the remote default branch), not a gstack checkout, or the incoming release holds', + fix: 'resolve the named cause (commit or move the listed changes, check out the default branch), then re-run bin/gstack-upgrade; it never stashes or resets', + }, + UPGRADE_FETCH_FAILED: { + anchor: 'upgrade-fetch-failed', + summary: 'bin/gstack-upgrade could not fetch the remote default branch, so it cannot tell whether the checkout is current', + fix: 'check network access to the checkout\'s origin (git -C remote -v), then re-run bin/gstack-upgrade', + }, + REVISION_BELOW_MINIMUM: { + anchor: 'revision-below-minimum', + summary: 'the installed gstack revision is below the supported minimum declared in capy/support.env on the remote default branch', + fix: 'run the Capy skill installer without --check (it fast-forwards a clean, unpinned checkout), or bin/gstack-upgrade; a pinned, dirty or diverged checkout is named in the row', + }, + CAPY_WRAPPER_STALE: { + anchor: 'capy-wrapper-stale', + summary: 'the Capy skill copy that ran is older than the checkout\'s GSTACK_CAPY_MIN_WRAPPER', + fix: 'ask the owner to copy /capy/skill/gstack/ over the Drive skill folder; the checkout wrapper keeps working meanwhile', + }, + INSTALL_UNVERIFIED: { + anchor: 'install-unverified', + summary: 'a Capy session (GSTACK_CAPY=1) has no passing install verdict for this checkout, HEAD and workflow, so the workflow refused to start', + fix: 'run the Capy skill installer with --for on this machine and launch with the GSTACK_LAUNCH line it prints', + }, } as const satisfies Record; export type ResultCodeName = keyof typeof RESULT_CODES; diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index f5652f722..56929b802 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -701,3 +701,69 @@ describe('gstack-doctor --check', () => { expect(doctor(f, ['--help']).out).toContain('--check'); }); }); + +// Field wave lane A: the trailer agrees with the Bun workflows run (A3), a +// remote minimum fails the revision row (A2), and planning-only workflows do +// not fail on a repo test-pin mismatch (A3 amendment). +describe('gstack-doctor --check: launch Bun, supported minimum, planning-only pins', () => { + const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(); + const check = (f: Fixture, args: string[], extra: Record = {}) => { + const r = doctor(f, ['--check', ...args], extra); + const lines = r.out.split('\n'); + const rows = Object.fromEntries(lines.filter(l => /^(PASS|FAIL|SKIP) /.test(l)).map(l => { + const m = l.match(/^(PASS|FAIL|SKIP) ([a-z-]+)(?:\s(.*))?$/)!; + return [m[2], { status: m[1], rest: m[3] ?? '' }]; + })); + return { status: r.status, out: r.out, rows, trailer: lines.filter(l => l.startsWith('gstack: ')).at(-1) ?? '' }; + }; + /** A verified Bun in $HOME/.bun/bin (off PATH) that answers --version and runs TypeScript. */ + const verifiedBun = (f: Fixture, version = '1.4.2') => + script(path.join(f.home, '.bun/bin/bun'), [`[ "$1" = --version ] && { echo "${version}"; exit 0; }`, `exec "${process.execPath}" "$@"`]); + + test('PATH Bun 1.3.14 with a verified 1.4.2 off PATH: runtime FAILs naming where it was found; with the launch PATH it PASSes', () => { + const f = makeFixture(); + verifiedBun(f); + const fresh = check(f, [], { STUB_BUN_VERSION: '1.3.14' }); + expect(fresh.rows.runtime.status).toBe('FAIL'); + expect(fresh.rows.runtime.rest).toContain(`bun 1.4.2 found at ${f.home}/.bun/bin (not on PATH)`); + expect(fresh.rows.runtime.rest).toContain(`fix: export PATH="${f.home}/.bun/bin:$PATH" or prefix commands with the GSTACK_LAUNCH line`); + expect(fresh.rows.runtime.rest).toContain('(RUNTIME_BELOW_MINIMUM; '); + const launched = check(f, [], { STUB_BUN_VERSION: '1.3.14', PATH: `${f.home}/.bun/bin${path.delimiter}${f.env.PATH}` }); + expect(launched.rows.runtime.status, launched.out).toBe('PASS'); + expect(launched.rows.runtime.rest).toContain(`at ${f.home}/.bun/bin/bun`); + }); + + test('--min-revision: below FAILs with REVISION_BELOW_MINIMUM naming the upgrade command; at or above PASSes; unknown says so and never claims the minimum', () => { + const f = makeFixture(); + const below = check(f, ['--min-revision', '99.0.0.0']); + expect(below.status).toBe(1); + expect(below.rows.revision.status).toBe('FAIL'); + expect(below.rows.revision.rest).toContain(`installed ${VERSION}`); + expect(below.rows.revision.rest).toContain('below the supported minimum 99.0.0.0'); + expect(below.rows.revision.rest).toContain(`${bashPath(f.root)}/bin/gstack-upgrade`); + expect(below.rows.revision.rest).toContain('(REVISION_BELOW_MINIMUM; '); + expect(below.trailer).toBe('gstack: fail revision'); + const wrapped = check(f, ['--min-revision', '99.0.0.0'], { GSTACK_CAPY_WRAPPER_PATH: '/drive/skills/gstack/scripts/install.sh' }); + expect(wrapped.rows.revision.rest).toContain('fix: bash /drive/skills/gstack/scripts/install.sh (fast-forwards)'); + const at = check(f, ['--min-revision', VERSION]); + expect(at.rows.revision.status, at.out).toBe('PASS'); + expect(at.rows.revision.rest).toContain(`>= supported minimum ${VERSION}`); + const unknown = check(f, ['--min-revision', 'unknown']); + expect(unknown.rows.revision.status).toBe('PASS'); + expect(unknown.rows.revision.rest).toContain('remote minimum unknown (fetch failed)'); + expect(unknown.rows.revision.rest).not.toContain('>= supported minimum'); + }); + + test('planning-only workflows SKIP a repo test-pin mismatch; test-running workflows and --require keep pins required', () => { + const f = makeFixture(); + const project = path.join(f.base, 'project'); + write(path.join(project, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.2\n'); + const planning = check(f, ['--for', 'autoplan', '--project', project], { STUB_BUN_VERSION: '1.4.3' }); + expect(planning.rows.pins.status, planning.out).toBe('SKIP'); + expect(planning.rows.pins.rest).toMatch(/^\(planning-only; repo pins .*1\.4\.2.*, launch bun 1\.4\.3\)$/); + for (const args of [['--for', 'review'], ['--for', 'ship'], ['--require', 'claude']]) { + const tests = check(f, [...args, '--project', project], { STUB_BUN_VERSION: '1.4.3' }); + expect(tests.rows.pins.status, args.join(' ')).toBe('FAIL'); + } + }); +});