mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 11:40:51 +01:00
feat(capy): vendored Capy skill, frozen bootstrapper and full wrapper bin/gstack-capy-session
capy/skill/gstack/ is the Drive skill's source of truth. scripts/install.sh is a frozen bootstrapper: it prints gstack-capy-skill: wrapper <n> (<path>), clones or fetches under .gstack-capy-install.lock, reads the remote capy/support.env (FETCH_HEAD), fast-forwards a clean unpinned checkout below GSTACK_MIN_REVISION (refusing dirty, diverged or pinned checkouts with the exact commands; GSTACK_CAPY_AUTO_UPGRADE=0 disables), and execs the checkout's own bin/gstack-capy-session with GSTACK_CAPY_LOCK_HELD=1 so the held lock is never taken twice. Offline it says the remote minimum is unknown. A Drive copy older than GSTACK_CAPY_MIN_WRAPPER prints an owner action (CAPY_WRAPPER_STALE). SKILL.md: check at session start, install lazily before the first workflow, the GSTACK_LAUNCH line, the trailer rule, the subagent first-report line, gstack-pregate sealed before any push, and the autoplan/DRIVER.md pointer. The context-budget fixture gains the skill's entry. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
d6c04d71a8
commit
f63ed5f7bd
6 files changed
+475
-1
No files matched your search
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/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.
|
||||
#
|
||||
# Usage: gstack-capy-session [--check] --root <checkout> [gstack-capy-install arguments]
|
||||
# --check certify only (bin/gstack-capy-install --check: the doctor with the
|
||||
# launch PATH and the remote minimum, the verdict, the launch line)
|
||||
# default install or repair (bin/gstack-capy-install)
|
||||
# The last line is the `gstack:` trailer; the exit status is the installer's.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
prev=""
|
||||
for a in "$@"; do [ "$prev" = --root ] && ROOT="$a"; prev="$a"; done
|
||||
INSTALLER="$ROOT/bin/gstack-capy-install"
|
||||
if [ ! -x "$INSTALLER" ]; then
|
||||
echo "gstack: fail installer — $INSTALLER is missing; fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh} (re-clones or fast-forwards)"
|
||||
exit 1
|
||||
fi
|
||||
[ "${GSTACK_CAPY_REMOTE_MIN:-}" = unknown ] && echo "gstack-capy-session: remote minimum unknown (fetch failed); the revision row is local only"
|
||||
exec "$INSTALLER" "$@"
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: gstack
|
||||
description: Install and use gstack in Capy and Claude Code. Load at session start to check this machine, and for gstack planning, code review, debugging, QA, design, security, documentation, and shipping workflows.
|
||||
---
|
||||
|
||||
# gstack for Capy and Claude Code
|
||||
|
||||
## Session start: check, then install only when needed
|
||||
|
||||
Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper <n> (<path>)` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`).
|
||||
|
||||
1. At session start run `bash scripts/install.sh --check`, adding `--for <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP <component>` row per component, a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>` (exit 0) or `gstack: fail <ids> …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout.
|
||||
2. If the trailer is `gstack: fail revision …` or `gstack: fail runtime …` and a checkout exists, run the same command without `--check` (900-second timeout). A clean, unpinned checkout below the supported minimum is fast-forwarded (`gstack-capy-skill: upgraded <old> -> <new>`), reinstalled and rechecked in one locked step; about 1 s when nothing changed. A pinned (`--revision`, detached), dirty or diverged checkout is never moved: the trailer names the cause and the exact commands. `GSTACK_CAPY_AUTO_UPGRADE=0` disables the fast-forward.
|
||||
3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for <skill>` (about 30 s cold: clone, the SHA-256-verified Bun 1.4.2, both host profiles without Chromium).
|
||||
4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)` and `SKIP pins (planning-only; …)` are not failures.
|
||||
|
||||
**The trailer rule.** No `^gstack: ok` line from this machine in this session means gstack is not installed here: do not run a workflow, and do not claim the skills work. One machine's install never covers another.
|
||||
|
||||
**Subagents.** A subagent's first report to its parent carries the `gstack:` trailer line from its own machine's `--check`, verbatim, after its identifiers. A parent treats a missing line as `gstack: fail not-installed`.
|
||||
|
||||
`gstack-capy-skill: owner action — Drive copy is wrapper <n>, minimum <m>; copy <root>/capy/skill/gstack/ (CAPY_WRAPPER_STALE)` means this skill folder is older than the checkout wants. The run still works; tell the owner, who copies that directory over the skill folder. Never write the skill folder yourself.
|
||||
|
||||
## Launch every gstack command with the GSTACK_LAUNCH line
|
||||
|
||||
Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH) and, then, `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax. Prefix each gstack command with those assignments, or export them once in the shell that runs it; a child shell's export cannot configure the parent's later commands. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum.
|
||||
|
||||
With `GSTACK_CAPY=1`, a workflow started in an unattended or spawned session checks the install verdict the last run wrote for this checkout, HEAD and workflow, and the Bun on PATH. When either fails it prints an `INSTALL: fail …` row with its fix and `GSTACK_RESULT: skill=<s> status=refused run=-` and exits 3: stop, run the fix, start again. An ok whose pins row was `SKIP pins (planning-only; …)` does not admit a workflow that runs the repo's tests (`review`, `ship`, `qa`), and a verdict that did not check a component the workflow needs does not admit it either; run `--for <that skill>` first.
|
||||
|
||||
## Load the right workflow
|
||||
|
||||
For Capy running GPT-6 Astra, read `${CODEX_HOME:-$HOME/.codex}/skills/gstack/SKILL.md` for routing, then `${CODEX_HOME:-$HOME/.codex}/skills/gstack-<skill>/SKILL.md` for the selected workflow. These are the generated GPT-6 Astra instructions, not the Claude source files.
|
||||
|
||||
For Claude Code, read `~/.claude/skills/gstack/SKILL.md` for routing and `~/.claude/skills/<skill>/SKILL.md` for the selected workflow. When launching a Claude Code session, tell it to load gstack and name the intended workflow. For Capy running a Claude model, the Claude instruction files can be read directly with the Capy adaptations below; that does not turn Capy into the Claude Code harness.
|
||||
|
||||
If no specific skill was requested, use the router's task-to-skill mapping. Read only the selected workflow and its referenced files. For other models, use the closest applicable instruction profile without claiming a model-specific profile was generated or evaluated for them.
|
||||
|
||||
## Unattended runs for a parent agent
|
||||
|
||||
For `/autoplan` driven from Capy, read `autoplan/DRIVER.md` in the checkout (`~/.local/share/gstack/autoplan/DRIVER.md`): the compact phase loop, artifact contract and gate protocol, without the interactive preamble. A checkout without that file predates it; follow its `autoplan/SKILL.md` instead. Run every command with the `GSTACK_LAUNCH` assignments and read outputs as files, not prose: the run directory holds `run.json`, `decisions.jsonl`, `findings.jsonl`, `tasks.jsonl`, `timing.json`, `plan.md` and `review-record.md`, and the run ends with `GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>`. `~/.local/share/gstack/bin/gstack-artifact validate <dir>/run.json` is the completion check. The gate is answered through `gstack-autoplan answer` (or `gstack-gate parse`) with the printed `gate_rev`; an unattended run never approves its own gate. `docs/unattended.md` in the checkout is the contract.
|
||||
|
||||
## Before any push or pull request
|
||||
|
||||
In a repository with a sealed configuration (a `sealed` key in its `.gstack/pregate.json`), run `~/.local/share/gstack/bin/gstack-pregate sealed` before every push and before creating a pull request: Capy's pull-request tool may not run local git hooks, so this is the check that keeps sealed or eval output out of a public repository. A refusal names the file, line and rule; do not push until it passes.
|
||||
|
||||
## Adapt host-specific instructions to Capy
|
||||
|
||||
- An instruction to invoke `Skill` means read and follow the selected SKILL.md with the available file-reading tool. Do not call a nonexistent Skill tool or assume slash commands were registered in Capy's UI.
|
||||
- Use Capy's user-visible messaging and question tools for output and decisions. Plain assistant text is not a delivered message in Capy. Use Capy's task and pull-request workflows rather than unavailable Claude or Codex host tools.
|
||||
- Capy has no native gstack host target. Claude hooks do not become enforced Capy hooks; safety skills are advisory here, and `/autoplan` prints `autoplan guard: not enforced by this host` once to say so. Report missing capabilities instead of silently claiming equivalent enforcement.
|
||||
- Selecting GPT-6 Astra or Claude in Capy does not install or authenticate the Codex or Claude Code CLI. For cross-model reviews, a subagent on another model family is a full outside voice; record the model that reviewed. Do not report a missing Codex CLI as degraded coverage when another runner reviewed.
|
||||
- Preserve the user's requested scope, decisions, and consent. Do not enable telemetry or remote artifact sharing on their behalf. Installing gstack does not authorize merging, deploying, destructive operations, or bypassing approvals.
|
||||
|
||||
## Maintenance
|
||||
|
||||
`bash scripts/install.sh --check` is the health check; `bash scripts/install.sh` installs or repairs and fast-forwards a checkout below the supported minimum. Between minimums, `~/.local/share/gstack/bin/gstack-upgrade --check` says whether the checkout is behind, and `~/.local/share/gstack/bin/gstack-upgrade` fast-forwards it to the tip of the default branch and reinstalls (it refuses dirty, diverged or pinned checkouts and never stashes or resets). The installer is for Capy's Ubuntu cloud machines with Git, curl, Node.js, flock and timeout; do not run it on a personal device without permission.
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack Capy skill bootstrapper. FROZEN: this file is copied into the
|
||||
# owner's Drive skill folder and runs before any checkout code, so it does
|
||||
# only what a stale checkout cannot do for itself:
|
||||
# 1. clone gstack when absent (install mode);
|
||||
# 2. fetch origin's default branch (refs only, no --depth) and read the
|
||||
# supported minimum from FETCH_HEAD:capy/support.env (parsed, never sourced);
|
||||
# 3. in install mode, fast-forward a clean, unpinned checkout below that
|
||||
# minimum, under .gstack-capy-install.lock (--check never moves it);
|
||||
# 4. exec the checkout's full wrapper, bin/gstack-capy-session, with the same
|
||||
# arguments, handing the held lock over (GSTACK_CAPY_LOCK_HELD=1).
|
||||
# Everything else (Bun, setup, the doctor, the verdict, the launch line) is
|
||||
# the checkout's. The last line is the `gstack:` trailer; exit 0 only on
|
||||
# `gstack: ok`. Contract: docs/capy-install.md; tests:
|
||||
# test/capy-skill-wrapper.test.ts.
|
||||
#
|
||||
# Usage:
|
||||
# bash install.sh --check [--for <skill> | --require <id,id>] [--project <path>]
|
||||
# bash install.sh [--for <skill> | --require <id,id>] [--with-browser] [--revision <ref>] [--project <path>]
|
||||
# Environment: GSTACK_CAPY_ROOT (default ~/.local/share/gstack), GSTACK_CAPY_REPO,
|
||||
# GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s).
|
||||
set -u
|
||||
GSTACK_CAPY_WRAPPER=1
|
||||
|
||||
self="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
|
||||
root="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}"
|
||||
repo="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}"
|
||||
echo "gstack-capy-skill: wrapper $GSTACK_CAPY_WRAPPER ($self)"
|
||||
|
||||
check=0 skill="" revision="" min_note="" remote_min="" min_wrapper=""
|
||||
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)
|
||||
min_wrapper=$(support_get "$(cat "$root/capy/support.env" 2>/dev/null)" GSTACK_CAPY_MIN_WRAPPER)
|
||||
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_wrapper="" 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)
|
||||
min_wrapper=$(support_get "$support" GSTACK_CAPY_MIN_WRAPPER)
|
||||
else
|
||||
min_note="remote minimum unknown (fetch failed)"
|
||||
echo "gstack-capy-skill: $min_note; checking the local install only"
|
||||
min_wrapper=$(support_get "$(cat "$root/capy/support.env" 2>/dev/null)" GSTACK_CAPY_MIN_WRAPPER)
|
||||
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 [ -n "$min_wrapper" ] && below "$GSTACK_CAPY_WRAPPER" "$min_wrapper"; then
|
||||
if [ ! -x "$session" ]; then
|
||||
echo "gstack: fail wrapper — Capy skill wrapper $GSTACK_CAPY_WRAPPER is older than this checkout's minimum $min_wrapper and $root has no bin/gstack-capy-session; fix: copy $root/capy/skill/gstack/ over the skill folder (CAPY_WRAPPER_STALE)"
|
||||
exit 1
|
||||
fi
|
||||
echo "gstack-capy-skill: owner action — Drive copy is wrapper $GSTACK_CAPY_WRAPPER, minimum $min_wrapper; copy $root/capy/skill/gstack/ (CAPY_WRAPPER_STALE)"
|
||||
fi
|
||||
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[@]}"}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* capy/skill/gstack/: the vendored Capy skill. scripts/install.sh is the
|
||||
* frozen bootstrapper the Drive copy holds: it prints its wrapper revision
|
||||
* first, learns the supported minimum from origin's default branch
|
||||
* (`git show FETCH_HEAD:capy/support.env`), fast-forwards a clean, unpinned
|
||||
* checkout below that minimum in install mode only (--check never moves the
|
||||
* checkout), and execs the checkout's own full wrapper, bin/gstack-capy-session,
|
||||
* handing over the held install lock (GSTACK_CAPY_LOCK_HELD=1). Pinned on the
|
||||
* machine lines (`gstack-capy-skill: wrapper|upgraded|owner action`, the
|
||||
* `gstack:` trailer as the last line), exit codes and HEAD before/after.
|
||||
* Fixture: a bare remote whose main moves from A (1.0.0, no capy/ directory:
|
||||
* an old checkout) to B (1.0.1, capy/support.env minimum 1.0.1, the vendored
|
||||
* scripts and a stub bin/gstack-capy-install that logs argv and env), a
|
||||
* depth-1 file:// clone at A, on the simulated Capy host.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { RESULT_CODES } from '../lib/result-codes';
|
||||
import { capyHost, type CapyHost } from './helpers/capy-host';
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, '..');
|
||||
const SKILL = path.join(REPO, 'capy/skill/gstack');
|
||||
const BOOT = path.join(SKILL, 'scripts/install.sh');
|
||||
const hosts: CapyHost[] = [];
|
||||
afterEach(() => { for (const h of hosts.splice(0)) h.cleanup(); });
|
||||
|
||||
const write = (file: string, text: string, mode = 0o644) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, text, { mode });
|
||||
};
|
||||
const envValue = (text: string, key: string) => text.match(new RegExp(`^${key}=([0-9.]+)`, 'm'))?.[1] ?? '';
|
||||
const below = (a: string, b: string) => {
|
||||
const x = a.split('.').map(Number), y = b.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
|
||||
return false;
|
||||
};
|
||||
|
||||
const STUB_INSTALLER = [
|
||||
'#!/usr/bin/env bash',
|
||||
'echo "installer $* lock=${GSTACK_CAPY_LOCK_HELD:-} rev=${GSTACK_CAPY_WRAPPER_REV:-} min=${GSTACK_CAPY_REMOTE_MIN:-} from=${GSTACK_CAPY_UPGRADED_FROM:-}" >> "$WRAP_LOG"',
|
||||
'cd "$(dirname "$0")/.."',
|
||||
'if [ "${1:-}" != --check ] && [ -f "$WRAP_FAIL_ONCE" ]; then rm -f "$WRAP_FAIL_ONCE"; echo "gstack: fail claude"; exit 1; fi',
|
||||
'echo "GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_CAPY=1"',
|
||||
'echo "gstack: ok $(cat VERSION) for=autoplan"',
|
||||
].join('\n') + '\n';
|
||||
|
||||
function fixture(opts: { minWrapper?: number } = {}) {
|
||||
const host = capyHost({ systemBun: '1.4.2' });
|
||||
hosts.push(host);
|
||||
const git = (args: string[], cwd: string) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
const log = path.join(host.base, 'wrap.log');
|
||||
const seed = path.join(host.base, 'seed');
|
||||
const bare = path.join(host.base, 'remote.git');
|
||||
fs.mkdirSync(seed, { recursive: true });
|
||||
git(['init', '-q', '-b', 'main'], seed);
|
||||
write(path.join(seed, 'VERSION'), '1.0.0\n');
|
||||
write(path.join(seed, 'README.md'), 'old gstack\n');
|
||||
write(path.join(seed, 'setup'), '#!/usr/bin/env bash\nexit 0\n', 0o755);
|
||||
git(['add', '-A'], seed);
|
||||
git(['commit', '-qm', 'A'], seed);
|
||||
const a = git(['rev-parse', 'HEAD'], seed);
|
||||
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
|
||||
git(['push', '-q', bare, 'main'], seed);
|
||||
const root = path.join(host.base, 'gstack');
|
||||
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
||||
write(path.join(seed, 'VERSION'), '1.0.1\n');
|
||||
write(path.join(seed, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.1\nGSTACK_CAPY_MIN_WRAPPER=${opts.minWrapper ?? 1}\n`);
|
||||
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session']) {
|
||||
if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
|
||||
}
|
||||
write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755);
|
||||
git(['add', '-A'], seed);
|
||||
git(['commit', '-qm', 'B'], seed);
|
||||
const b = git(['rev-parse', 'HEAD'], seed);
|
||||
git(['push', '-q', bare, 'main'], seed);
|
||||
const env = { ...host.env, GSTACK_CAPY_ROOT: root, GSTACK_CAPY_REPO: `file://${bare}`, WRAP_LOG: log, WRAP_FAIL_ONCE: path.join(host.base, 'fail-once') };
|
||||
const run = (args: string[] = [], extra: Record<string, string> = {}) => {
|
||||
const r = spawnSync('bash', [BOOT, ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 120_000 });
|
||||
const lines = r.stdout.trim().split('\n');
|
||||
return { ...r, lines, last: lines.at(-1) ?? '' };
|
||||
};
|
||||
return {
|
||||
host, root, bare, a, b, run, git: (args: string[]) => git(args, root),
|
||||
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)', () => {
|
||||
test('the first line names the wrapper revision and path; bash -n passes for the bootstrapper and the full wrapper', () => {
|
||||
for (const file of [BOOT, path.join(REPO, 'bin/gstack-capy-session')]) {
|
||||
expect(fs.existsSync(file), file).toBe(true);
|
||||
const r = spawnSync('bash', ['-n', file], { encoding: 'utf8' });
|
||||
expect(r.status, `${file}: ${r.stderr}`).toBe(0);
|
||||
}
|
||||
const f = fixture();
|
||||
const r = f.run(['--check', '--for', 'autoplan']);
|
||||
expect(r.lines[0]).toMatch(new RegExp(`^gstack-capy-skill: wrapper \\d+ \\(${BOOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)$`));
|
||||
});
|
||||
|
||||
test('below the remote minimum: --check is read-only and names the fast-forwarding command; install fast-forwards, installs and ends ok; the next session start is ok without moving', () => {
|
||||
const f = fixture();
|
||||
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
|
||||
const check = f.run(['--check', '--for', 'autoplan']);
|
||||
expect(check.status, check.stdout + check.stderr).toBe(1);
|
||||
expect(check.last).toBe(`gstack: fail revision — installed 1.0.0 is below the supported minimum 1.0.1 (fast-forward available); fix: bash ${BOOT} --for autoplan (fast-forwards) (REVISION_BELOW_MINIMUM)`);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
||||
expect(f.calls()).toEqual([]);
|
||||
|
||||
const install = f.run(['--for', 'autoplan']);
|
||||
expect(install.status, install.stdout + install.stderr).toBe(0);
|
||||
expect(install.stdout).toContain('gstack-capy-skill: upgraded 1.0.0 -> 1.0.1');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
|
||||
expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
||||
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=1.0.0`]);
|
||||
|
||||
const next = f.run(['--check', '--for', 'autoplan']);
|
||||
expect(next.status, next.stdout + next.stderr).toBe(0);
|
||||
expect(next.stdout).not.toContain('upgraded');
|
||||
expect(next.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
||||
expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=1 min=1.0.1 from=`);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
});
|
||||
|
||||
test('dirty, pinned, diverged or auto-upgrade disabled: no change, fail revision names the cause and the exact commands', () => {
|
||||
const f = fixture();
|
||||
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
|
||||
const dirty = f.run(['--for', 'autoplan']);
|
||||
expect(dirty.status).toBe(1);
|
||||
expect(dirty.last).toMatch(/^gstack: fail revision — installed 1\.0\.0 is below the supported minimum 1\.0\.1 \(tracked files changed: README\.md\); fix: .*git -C \S+ status --short.*\(REVISION_BELOW_MINIMUM\)$/);
|
||||
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
||||
f.git(['checkout', '-q', '--', 'README.md']);
|
||||
|
||||
const pinned = f.run(['--for', 'autoplan', '--revision', f.a]);
|
||||
expect(pinned.status).toBe(1);
|
||||
expect(pinned.last).toContain('(pinned by --revision');
|
||||
f.git(['checkout', '-q', '--detach', 'HEAD']);
|
||||
const detached = f.run(['--for', 'autoplan']);
|
||||
expect(detached.last).toContain('(pinned: HEAD is detached');
|
||||
f.git(['checkout', '-q', 'main']);
|
||||
|
||||
const disabled = f.run(['--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' });
|
||||
expect(disabled.status).toBe(1);
|
||||
expect(disabled.last).toContain('(auto-upgrade disabled)');
|
||||
expect(disabled.last).toContain('GSTACK_CAPY_AUTO_UPGRADE');
|
||||
|
||||
fs.writeFileSync(path.join(f.root, 'README.md'), 'local commit\n');
|
||||
f.git(['commit', '-qam', 'local']);
|
||||
const head = f.git(['rev-parse', 'HEAD']);
|
||||
const diverged = f.run(['--for', 'autoplan']);
|
||||
expect(diverged.status).toBe(1);
|
||||
expect(diverged.last).toContain('(diverged: ');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(head);
|
||||
expect(f.git(['stash', 'list'])).toBe('');
|
||||
expect(f.calls()).toEqual([]);
|
||||
});
|
||||
|
||||
test('an interrupted install after the fast-forward leaves the trailer fail; the next install run completes it', () => {
|
||||
const f = fixture();
|
||||
write(path.join(f.host.base, 'fail-once'), '1');
|
||||
const first = f.run(['--for', 'autoplan']);
|
||||
expect(first.status).toBe(1);
|
||||
expect(first.last).toBe('gstack: fail claude');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
const second = f.run(['--for', 'autoplan']);
|
||||
expect(second.status, second.stdout + second.stderr).toBe(0);
|
||||
expect(second.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
||||
});
|
||||
|
||||
test('offline: the remote minimum is unknown and said so; an old checkout is never ok', () => {
|
||||
const f = fixture();
|
||||
fs.renameSync(f.bare, `${f.bare}.gone`);
|
||||
const old = f.run(['--check', '--for', 'autoplan']);
|
||||
expect(old.status).toBe(1);
|
||||
expect(old.stdout).toContain('remote minimum unknown (fetch failed)');
|
||||
expect(old.last).toMatch(/^gstack: fail /);
|
||||
fs.renameSync(`${f.bare}.gone`, f.bare);
|
||||
expect(f.run(['--for', 'autoplan']).status).toBe(0);
|
||||
fs.renameSync(f.bare, `${f.bare}.gone`);
|
||||
const current = f.run(['--check', '--for', 'autoplan']);
|
||||
expect(current.stdout).toContain('remote minimum unknown (fetch failed)');
|
||||
expect(f.calls().at(-1)).toContain('min=unknown');
|
||||
});
|
||||
|
||||
test('a stale bootstrapper still runs the checkout wrapper and names the owner action', () => {
|
||||
const f = fixture({ minWrapper: 99 });
|
||||
const r = f.run(['--for', 'autoplan']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(new RegExp(`^gstack-capy-skill: owner action — Drive copy is wrapper \\d+, minimum 99; copy ${f.root}/capy/skill/gstack/ \\(CAPY_WRAPPER_STALE\\)$`, 'm'));
|
||||
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
||||
});
|
||||
|
||||
test('no checkout: --check records the not-installed trailer; install clones and runs the checkout wrapper', () => {
|
||||
const f = fixture();
|
||||
fs.rmSync(f.root, { recursive: true, force: true });
|
||||
const check = f.run(['--check', '--for', 'autoplan']);
|
||||
expect(check.status).toBe(1);
|
||||
expect(check.last).toBe(`gstack: fail not-installed — no gstack checkout at ${f.root}; fix: bash ${BOOT} --for autoplan (installs)`);
|
||||
const install = f.run(['--for', 'autoplan']);
|
||||
expect(install.status, install.stdout + install.stderr).toBe(0);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('capy skill: vendored text and support file', () => {
|
||||
const support = fs.existsSync(path.join(REPO, 'capy/support.env')) ? fs.readFileSync(path.join(REPO, 'capy/support.env'), 'utf8') : '';
|
||||
test('the wrapper revision meets the checkout minimum; GSTACK_MIN_REVISION <= VERSION', () => {
|
||||
const wrapper = Number(fs.readFileSync(BOOT, 'utf8').match(/^GSTACK_CAPY_WRAPPER=(\d+)$/m)?.[1] ?? 0);
|
||||
expect(wrapper).toBeGreaterThanOrEqual(Number(envValue(support, 'GSTACK_CAPY_MIN_WRAPPER') || 999));
|
||||
const min = envValue(support, 'GSTACK_MIN_REVISION');
|
||||
expect(min).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
expect(below(fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(), min)).toBe(false);
|
||||
});
|
||||
|
||||
test('SKILL.md: frontmatter, line cap, the session protocol tokens, and every command it names exists', () => {
|
||||
const text = fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8');
|
||||
expect(text).toMatch(/^---\nname: gstack\ndescription: .+\n---\n/);
|
||||
expect(text.split('\n').length).toBeLessThanOrEqual(90);
|
||||
for (const token of ['--check', 'GSTACK_LAUNCH', '^gstack: ok', 'gstack: fail not-installed', 'gstack-pregate sealed', 'autoplan/DRIVER.md', 'first report']) {
|
||||
expect(text, token).toContain(token);
|
||||
}
|
||||
const named = [...text.matchAll(/\b(?:bin\/)?(gstack-[a-z0-9-]+)\b/g)].map(m => m[1]).filter(n => n !== 'gstack-capy-skill');
|
||||
const missing = [...new Set(named)].filter(n => !fs.existsSync(path.join(REPO, 'bin', n)) && n !== 'gstack-pregate');
|
||||
expect(missing).toEqual([]);
|
||||
expect(fs.existsSync(path.join(REPO, 'bin/gstack-pregate'))).toBe(true);
|
||||
});
|
||||
|
||||
test('every (CODE) the install-side bash bins print resolves to a troubleshooting anchor', () => {
|
||||
const bins = ['bin/gstack-capy-install', 'bin/gstack-upgrade', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', 'capy/skill/gstack/scripts/install.sh'];
|
||||
const codes = new Set<string>();
|
||||
for (const rel of bins) for (const m of fs.readFileSync(path.join(REPO, rel), 'utf8').matchAll(/\(([A-Z][A-Z0-9]+_[A-Z0-9_]+)[;)]/g)) codes.add(m[1]);
|
||||
for (const m of fs.readFileSync(path.join(REPO, 'bin/gstack-install-verdict.sh'), 'utf8').matchAll(/code=([A-Z][A-Z0-9_]+)/g)) codes.add(m[1]);
|
||||
expect([...codes].sort()).toEqual(expect.arrayContaining(['CAPY_WRAPPER_STALE', 'INSTALL_UNVERIFIED', 'REVISION_BELOW_MINIMUM', 'RUNTIME_BELOW_MINIMUM', 'UPGRADE_FETCH_FAILED', 'UPGRADE_REFUSED']));
|
||||
expect([...codes].filter(c => !(c in RESULT_CODES))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Vendored
+1
@@ -8,6 +8,7 @@
|
||||
"browse": 8961,
|
||||
"browser-skills/hackernews-frontpage": 371,
|
||||
"canary": 13520,
|
||||
"capy/skill/gstack": 2225,
|
||||
"careful": 1328,
|
||||
"codex": 15598,
|
||||
"context-restore": 10995,
|
||||
|
||||
@@ -165,7 +165,8 @@ describe('gstack-upgrade', () => {
|
||||
});
|
||||
|
||||
test('every bin a trailer or fix line names exists in the repo', () => {
|
||||
const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md'];
|
||||
const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md',
|
||||
'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'];
|
||||
const named = new Set<string>();
|
||||
for (const rel of sources) {
|
||||
expect(fs.existsSync(path.join(REPO, rel)), rel).toBe(true);
|
||||
|
||||
Reference in new issue
Block a user