From 52efb61a7f8fa1f84ed5c3e4933aca15e1e0d905 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:47:44 +0000 Subject: [PATCH] W7 R2 R5 T6: ./setup --claude-model opt-in with a shared Claude render library - bin/gstack-render-claude.sh: overlay resolution from claude_overlay_model (presence via gstack-config has), the pinned render without brain blocks, activation records beside each render, the change-only banner, the default-install committed fallback after a failed upgrade render, a mkdir lock around swaps, and the bun-free --status lines. - setup: --claude-model / --claude-model= (validated before any write, persisted only when Claude is selected, persisted by the --refresh-registered parent before children run), Model selection help, the --model error points at --claude-model, the overlay renders after gbrain detection whatever it found, and per-install renders pass --model. - gstack-config: claude_overlay_model key docs and default; gbrain-refresh renders the overlay in both branches and uses a PID-unique detection tmp. - test/claude-overlay-setup.test.ts drives real setup and gbrain-refresh. --- bin/gstack-config | 51 +++- bin/gstack-render-claude.sh | 200 +++++++++++++++ setup | 134 ++++++++-- test/claude-overlay-models.test.ts | 6 +- test/claude-overlay-setup.test.ts | 301 +++++++++++++++++++++++ test/setup-claude-code-migration.test.ts | 2 +- 6 files changed, 672 insertions(+), 22 deletions(-) create mode 100644 bin/gstack-render-claude.sh create mode 100644 test/claude-overlay-setup.test.ts diff --git a/bin/gstack-config b/bin/gstack-config index 5a37b3a0e..a34bf5e26 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -135,6 +135,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # The vendor capture consent (`memorable enable`) # # is separate; gstack never sets it. # +# ─── Claude skill overlay ──────────────────────────────────────────── +# claude_overlay_model: claude # Claude model ID whose skill overlay ./setup +# # renders (e.g. claude-opus-4-8). Absent = the +# # generic claude overlay. Set it with +# # ./setup --claude-model , which validates the +# # ID and re-renders; ./setup --claude-model claude +# # returns to the generic overlay. +# # ─── Advanced ──────────────────────────────────────────────────────── # codex_reviews: enabled # Workflow outside review (Codex or Claude Code by harness). # # enabled: /review, /ship, /document-release, plan reviews, @@ -195,6 +203,7 @@ lookup_default() { pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks memorable_recall) echo "off" ;; # on | off — Memorable bridge gate, fail-closed until `gstack-memorable enable` founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false + claude_overlay_model) echo "claude" ;; # set only by ./setup --claude-model; presence via `has` # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: # brain_trust_policy@ — unset on fresh install; setup-gbrain # writes 'personal' for local engines, @@ -660,6 +669,7 @@ case "${1:-}" in # Run this after installing or uninstalling gbrain so your locally # generated SKILL.md files match your installation state. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + . "$SCRIPT_DIR/gstack-render-claude.sh" 2>/dev/null || { echo "$0: $SCRIPT_DIR/gstack-render-claude.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; } DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect" DETECTION_FILE="$STATE_DIR/gbrain-detection.json" mkdir -p "$STATE_DIR" @@ -667,10 +677,10 @@ case "${1:-}" in echo "gstack-gbrain-detect not found at $DETECT_BIN" >&2 exit 1 fi - if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp" 2>/dev/null; then - printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp" + if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp.$$" 2>/dev/null; then + printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp.$$" fi - mv "$DETECTION_FILE.tmp" "$DETECTION_FILE" + mv "$DETECTION_FILE.tmp.$$" "$DETECTION_FILE" # Summarize for the user. Use python (already required elsewhere) to # parse the JSON portably; fall back to grep if python is unavailable. @@ -721,10 +731,14 @@ case "${1:-}" in # SKILL.md link dangling — the whole skill set vanished from # Claude Code until a successful re-render. A failed render now # leaves the previous render fully intact. + gstack_claude_overlay "$INSTALL_DIR" "$0" RENDER_TMP="$RENDER_DIR.tmp.$$" rm -rf "$RENDER_TMP" - if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" >/dev/null 2>&1 ); then + if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" >/dev/null 2>&1 ); then + gstack_render_lock "$RENDER_DIR" _swap_in_render "$RENDER_DIR" "$RENDER_TMP" + gstack_render_unlock "$RENDER_DIR" + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok rendered # Repoint installed skills at the render — gstack-relink prefers # the render dir when present. "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true @@ -734,12 +748,41 @@ case "${1:-}" in rm -rf "$RENDER_TMP" echo "Warning: render failed — previous render (if any) left in place, links stay valid." echo "Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error." + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok failed + "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true fi fi ;; *) echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files." echo "Install gbrain (see /setup-gbrain) and re-run 'gstack-config gbrain-refresh' once it's configured." + # Drop a brain-aware render, or re-render a pinned overlay without + # brain blocks, so removing gbrain keeps the chosen overlay. + INSTALL_DIR="$HOME/.claude/skills/gstack" + RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$STATE_DIR/render/claude}" + if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model; } \ + && [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ] && [ -f "$INSTALL_DIR/VERSION" ] && [ -f "$INSTALL_DIR/package.json" ]; then + if ! command -v bun >/dev/null 2>&1; then + echo "Skip: bun not on PATH — the Claude render at $RENDER_DIR was not refreshed. Install bun, then re-run 'gstack-config gbrain-refresh'." + else + gstack_claude_overlay "$INSTALL_DIR" "$0" + gstack_claude_render_action "$RENDER_DIR" absent + if [ "$_GSTACK_RENDER_ACTION" = plain ]; then + if gstack_claude_render_plain "$INSTALL_DIR" "$RENDER_DIR"; then + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent rendered + else + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent failed + fi + else + if [ -d "$RENDER_DIR" ]; then + rm -rf "$RENDER_DIR" + echo "Removed the brain-aware render at $RENDER_DIR; Claude skills serve the committed files." + fi + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent removed + fi + "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true + fi + fi ;; esac ;; diff --git a/bin/gstack-render-claude.sh b/bin/gstack-render-claude.sh new file mode 100644 index 000000000..18088420a --- /dev/null +++ b/bin/gstack-render-claude.sh @@ -0,0 +1,200 @@ +# gstack-render-claude.sh — the Claude skill render shared by ./setup and +# `gstack-config gbrain-refresh`. Source it; it only defines functions. +# bash-3.2-clean and safe under `set -euo pipefail`. +# +# Overlay selection is opt-in: the overlay is the family of the persisted +# `claude_overlay_model` (written only by `./setup --claude-model `), or +# `claude` when that key is absent. The ID is re-resolved through +# scripts/models.ts at every render, so an ID that stops resolving after an +# upgrade falls back to `claude` with the reason printed. +# +# Each render keeps a plain-text activation record beside it +# (.overlay: overlay, source, model, version, gbrain, render), so +# `./setup --status` and the change-only banner never need bun. No record +# means the default `claude` overlay on the committed render. +# +# Callers define `_swap_in_render` (setup and bin/gstack-config each keep +# their tested copy) and may define `log` and `bun_cmd`; plain echo and bun +# are used otherwise. + +_gstack_render_say() { + if declare -F log >/dev/null 2>&1; then log "$@"; else echo "$@"; fi +} + +_gstack_render_bun() { + if declare -F bun_cmd >/dev/null 2>&1; then bun_cmd "$@"; else bun "$@"; fi +} + +# gstack_claude_overlay SOURCE_DIR CONFIG_BIN — resolve the requested overlay. +# Sets _GSTACK_OVERLAY (family), _GSTACK_OVERLAY_SOURCE (explicit|default), +# _GSTACK_OVERLAY_ID (the configured ID, or claude), _GSTACK_OVERLAY_GENERIC +# (1 when a specific ID uses the generic overlay) and _GSTACK_OVERLAY_NOTE +# (why a configured ID fell back to claude, else empty; printed here). +gstack_claude_overlay() { + local src="$1" CONFIG_BIN="$2" out rc=0 + _GSTACK_OVERLAY=claude + _GSTACK_OVERLAY_SOURCE=default + _GSTACK_OVERLAY_ID=claude + _GSTACK_OVERLAY_GENERIC=0 + _GSTACK_OVERLAY_NOTE="" + "$CONFIG_BIN" has claude_overlay_model >/dev/null 2>&1 || return 0 + _GSTACK_OVERLAY_SOURCE=explicit + _GSTACK_OVERLAY_ID="$("$CONFIG_BIN" get claude_overlay_model 2>/dev/null || true)" + out="$(cd "$src" && _gstack_render_bun run scripts/models.ts claude-overlay "$_GSTACK_OVERLAY_ID" 2>&1)" || rc=$? + if [ "$rc" -eq 0 ]; then + _GSTACK_OVERLAY="$(printf '%s' "$out" | tail -1 | cut -f1)" + _GSTACK_OVERLAY_GENERIC="$(printf '%s' "$out" | tail -1 | cut -f2)" + return 0 + fi + _GSTACK_OVERLAY=claude + _GSTACK_OVERLAY_NOTE="claude_overlay_model '$_GSTACK_OVERLAY_ID' no longer resolves to a Claude overlay ($(printf '%s\n' "$out" | sed -n 2p)); using the generic claude overlay. Fix: ./setup --claude-model , or ./setup --claude-model claude." + _gstack_render_say " note: $_GSTACK_OVERLAY_NOTE" +} + +# gstack_claude_overlay_field RECORD KEY — one field of an activation record. +gstack_claude_overlay_field() { + [ -f "$1" ] || return 0 + sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1 +} + +# gstack_render_lock DIR / gstack_render_unlock DIR — serialize the swap and +# record write of concurrent renders (mkdir lock, no flock). A lock whose +# holder is gone is broken; after 30 s the swap proceeds unlocked. +gstack_render_lock() { + local lock="$1.lock" i=0 pid + mkdir -p "$(dirname "$1")" 2>/dev/null || true + while ! mkdir "$lock" 2>/dev/null; do + pid="$(cat "$lock/pid" 2>/dev/null || true)" + if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then + rm -rf "$lock" + continue + fi + i=$((i + 1)) + [ "$i" -lt 300 ] || return 0 + sleep 0.1 + done + echo "$$" > "$lock/pid" +} +gstack_render_unlock() { + rm -rf "$1.lock" +} + +# gstack_claude_render_action RENDER_DIR GBRAIN — what the default install's +# render dir needs: `user` (brain-aware :user render), `plain` (pinned +# overlay, no brain blocks), `remove` (serve the committed files) or `keep`. +# GBRAIN is ok, absent or unknown (detector missing or failed: no brain +# blocks, and a render is left alone unless an overlay change needs it). +gstack_claude_render_action() { + local render_dir="$1" gbrain="$2" previous + if [ "$gbrain" = ok ]; then _GSTACK_RENDER_ACTION=user; return 0; fi + if [ "$_GSTACK_OVERLAY" != claude ]; then _GSTACK_RENDER_ACTION=plain; return 0; fi + previous="$(gstack_claude_overlay_field "$render_dir.overlay" overlay)" + if [ "$gbrain" = unknown ] && [ "${previous:-claude}" = claude ]; then + _GSTACK_RENDER_ACTION=keep + else + _GSTACK_RENDER_ACTION=remove + fi +} + +# gstack_claude_render_plain SOURCE_DIR RENDER_DIR — render the pinned overlay +# without brain blocks into a temp dir and swap it in only on success. +gstack_claude_render_plain() { + local src="$1" RENDER_DIR="$2" RENDER_TMP out rc=0 + RENDER_TMP="$RENDER_DIR.tmp.$$" + rm -rf "$RENDER_TMP" + _gstack_render_say "Rendering Claude skills with the $_GSTACK_OVERLAY overlay into $RENDER_DIR (source checkout stays clean)..." + out="$(cd "$src" && _gstack_render_bun run gen:skill-docs --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" 2>&1)" || rc=$? + if [ "$rc" -ne 0 ]; then + rm -rf "$RENDER_TMP" + printf '%s\n' "$out" | tail -3 + return "$rc" + fi + gstack_render_lock "$RENDER_DIR" + _swap_in_render "$RENDER_DIR" "$RENDER_TMP" + gstack_render_unlock "$RENDER_DIR" +} + +# gstack_claude_render_settle SOURCE_DIR RENDER_DIR GBRAIN OUTCOME [DEFAULT] +# Record what a render step left in place and report it. OUTCOME is rendered, +# removed or failed. DEFAULT is 1 (default install: a failed pinned render +# from an older gstack version falls back to the committed files) or 0 (a +# per-install render keeps its previous render). Prints the overlay banner +# only when the served overlay differs from the previous record's. +gstack_claude_render_settle() { + local src="$1" render_dir="$2" gbrain="$3" outcome="$4" default="${5:-1}" + local record="$2.overlay" version previous previous_version served render_kind + version="$(cat "$src/VERSION" 2>/dev/null | tr -d '[:space:]' || true)" + previous="$(gstack_claude_overlay_field "$record" overlay)" + previous_version="$(gstack_claude_overlay_field "$record" version)" + case "$outcome" in + rendered) served="$_GSTACK_OVERLAY"; render_kind=user ;; + removed) served=claude; render_kind=committed ;; + failed) + if [ "$default" = 1 ] && [ "$_GSTACK_OVERLAY" != claude ] && [ -d "$render_dir" ] \ + && [ "$previous_version" != "$version" ]; then + rm -rf "$render_dir" + _gstack_render_say " The $_GSTACK_OVERLAY overlay render failed and the previous render came from gstack ${previous_version:-unknown}, so the committed claude render is served. Rerun ./setup to retry." + served=claude; render_kind=committed + elif [ -d "$render_dir" ]; then + [ "$_GSTACK_OVERLAY" = claude ] \ + || _gstack_render_say " The $_GSTACK_OVERLAY overlay render failed; the previous render (${previous:-claude} overlay) stays in use. Rerun ./setup to retry." + return 0 + else + _gstack_render_say " The $_GSTACK_OVERLAY overlay render failed; the committed claude render is served. Rerun ./setup to retry." + served=claude; render_kind=committed + fi + ;; + *) return 0 ;; + esac + gstack_render_lock "$render_dir" + if [ "$render_kind" = committed ] && [ "$_GSTACK_OVERLAY_SOURCE" = default ]; then + rm -f "$record" + else + printf 'overlay=%s\nsource=%s\nmodel=%s\nversion=%s\ngbrain=%s\nrender=%s\n' \ + "$served" "$_GSTACK_OVERLAY_SOURCE" "$_GSTACK_OVERLAY_ID" "${version:-unknown}" "$gbrain" "$render_kind" \ + > "$record.tmp.$$" && mv "$record.tmp.$$" "$record" + fi + gstack_render_unlock "$render_dir" + if [ "$served" != "${previous:-claude}" ]; then + _gstack_render_say "" + _gstack_render_say "Claude skill overlay: $served (was ${previous:-claude})." + if [ "$_GSTACK_OVERLAY_SOURCE" = explicit ]; then + _gstack_render_say " Source: claude_overlay_model = $_GSTACK_OVERLAY_ID, set by ./setup --claude-model." + [ "$_GSTACK_OVERLAY_GENERIC" = 1 ] && _gstack_render_say " $_GSTACK_OVERLAY_ID has no overlay of its own, so it uses the generic claude overlay." + else + _gstack_render_say " Source: default (no claude_overlay_model set)." + fi + _gstack_render_say " Return to the generic overlay: ./setup --claude-model claude" + fi +} + +# gstack_claude_overlay_status — `./setup --status` overlay lines. Reads only +# activation records and the registry: no bun, no writes. +gstack_claude_overlay_status() { + local default_render rows dest render record overlay source model version kind + default_render="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}" + rows="$(gstack_install_registry_rows 2>/dev/null | awk -F '\t' '$1 == "claude" { print $4 "\t" $9 }')" + [ -n "$rows" ] || return 0 + echo "Claude skill overlay:" + while IFS=' ' read -r dest render; do + [ -n "$dest" ] || continue + case "$render" in + ''|-|committed) record="$default_render.overlay"; render="$default_render"; kind="user render" ;; + *) record="$render.overlay"; kind="per-install render" ;; + esac + overlay="$(gstack_claude_overlay_field "$record" overlay)" + [ -d "$render" ] || kind="committed render" + if [ -z "$overlay" ]; then + echo " $(_gstack_tilde "$dest"): claude (default, $kind)" + continue + fi + source="$(gstack_claude_overlay_field "$record" source)" + model="$(gstack_claude_overlay_field "$record" model)" + version="$(gstack_claude_overlay_field "$record" version)" + [ "$(gstack_claude_overlay_field "$record" render)" = committed ] && kind="committed render" + if [ "$source" = explicit ]; then source="explicit: $model"; fi + echo " $(_gstack_tilde "$dest"): $overlay ($source, $kind, gstack $version)" + done < Codex model profile override. Otherwise reads Codex config. --global Allow this checkout to replace an existing global install (or register Codex globally from a project-vendored source). --prefix Install skills with the gstack- prefix (e.g. /gstack-review). @@ -40,11 +39,21 @@ Options: -q, --quiet Suppress progress output. -h, --help Show this help and exit. +Model selection: + --model Codex model profile override. Otherwise reads Codex config. + --claude-model + Claude skill overlay for a Claude model ID (for example + claude-opus-4-8). Saved as claude_overlay_model and used by + every later setup, upgrade and gbrain-refresh. Without it + the generic claude overlay is used; --claude-model claude + returns to it. + Examples: ./setup # solo install for Claude Code ./setup --host codex # install for OpenAI Codex CLI only ./setup --host auto # install for every detected agent ./setup --host codex --model gpt-5.6-sol + ./setup --claude-model claude-opus-4-8 # Opus 4.8 overlay for Claude skills ./setup --status # what is installed, where, at which version ./setup --team # team mode for a shared repo ./setup --no-prefix # use short slash-command names @@ -66,9 +75,11 @@ for _arg in "$@"; do case "$_arg" in --status) _STATUS_DIR="$(cd "$(dirname "$0")" && pwd -P)" - . "$_STATUS_DIR/bin/gstack-state-root.sh" && . "$_STATUS_DIR/bin/gstack-install-registry.sh" || exit 1 + . "$_STATUS_DIR/bin/gstack-state-root.sh" && . "$_STATUS_DIR/bin/gstack-install-registry.sh" \ + && . "$_STATUS_DIR/bin/gstack-render-claude.sh" || exit 1 gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" gstack_install_status + gstack_claude_overlay_status exit 0 ;; esac done @@ -88,12 +99,14 @@ PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = ex TIMELINE_STOP_HOOK_MODE="" # "" = resolve from env/config; "yes"/"no" = explicit (#2677) MODEL_OVERRIDE="" MODEL_OVERRIDE_SET=0 +CLAUDE_MODEL_ARG="" +CLAUDE_MODEL_SET=0 REFRESH_REGISTERED=0 HOST_FLAG_SET=0 # Reject unknown options before anything is written, with the closest flag. _setup_unknown_option() { local bad="$1" next="${2:-}" best="" best_d=99 flag d value="" - for flag in --host --model --global --local --prefix --no-prefix --team --no-team \ + for flag in --host --model --claude-model --global --local --prefix --no-prefix --team --no-team \ --plan-tune-hooks --no-plan-tune-hooks --timeline-stop-hook --no-timeline-stop-hook \ --refresh-registered --status --quiet --help; do d="$(awk -v a="${bad%%=*}" -v b="$flag" 'BEGIN { @@ -109,7 +122,7 @@ _setup_unknown_option() { if [ "$d" -lt "$best_d" ]; then best_d="$d"; best="$flag"; fi done echo "Unknown option: $bad — nothing was installed or changed." >&2 - case "$best" in --host|--model) case "$next" in ''|-*) ;; *) value=" $next" ;; esac ;; esac + case "$best" in --host|--model|--claude-model) case "$next" in ''|-*) ;; *) value=" $next" ;; esac ;; esac if [ "$best_d" -le 3 ]; then echo "Did you mean: ./setup $best$value" >&2 fi @@ -121,6 +134,8 @@ while [ $# -gt 0 ]; do --host=*) HOST="${1#--host=}"; HOST_FLAG_SET=1; shift ;; --model) [ -z "$2" ] && echo "Missing value for --model" >&2 && exit 1; MODEL_OVERRIDE="$2"; MODEL_OVERRIDE_SET=1; shift 2 ;; --model=*) MODEL_OVERRIDE="${1#--model=}"; MODEL_OVERRIDE_SET=1; shift ;; + --claude-model) [ -z "$2" ] && echo "Missing value for --claude-model (a Claude model ID such as claude-opus-4-8, or claude)" >&2 && exit 1; CLAUDE_MODEL_ARG="$2"; CLAUDE_MODEL_SET=1; shift 2 ;; + --claude-model=*) CLAUDE_MODEL_ARG="${1#--claude-model=}"; [ -z "$CLAUDE_MODEL_ARG" ] && echo "Missing value for --claude-model (a Claude model ID such as claude-opus-4-8, or claude)" >&2 && exit 1; CLAUDE_MODEL_SET=1; shift ;; --local) LOCAL_INSTALL=1; shift ;; --global) GLOBAL_INSTALL=1; shift ;; --prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;; @@ -225,8 +240,35 @@ SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)" . "$SOURCE_GSTACK_DIR/bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SOURCE_GSTACK_DIR/bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" . "$SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh is missing. fix: reinstall from a fresh clone (docs/ADDING_A_HOST.md)" >&2; exit 1; } +. "$SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; } SETUP_VERSION="$(cat "$SOURCE_GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)" +# --claude-model is validated before anything is written, and persisted once +# Claude is known to be selected (below, and in the --refresh-registered parent). +if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then + if ! _CLAUDE_MODEL_RESOLVED="$(cd "$SOURCE_GSTACK_DIR" && bun run scripts/models.ts claude-overlay "$CLAUDE_MODEL_ARG" 2>&1)"; then + printf 'Error: --claude-model %s\n' "$_CLAUDE_MODEL_RESOLVED" | sed '2,$s/^/ /' >&2 + echo " Nothing was installed or changed." >&2 + exit 1 + fi +fi +# _persist_claude_model — save the validated --claude-model value as given. +_persist_claude_model() { + local overlay generic + [ "$CLAUDE_MODEL_SET" -eq 1 ] || return 0 + if ! "$SOURCE_GSTACK_DIR/bin/gstack-config" set claude_overlay_model "$CLAUDE_MODEL_ARG" >/dev/null; then + echo "Error: could not save claude_overlay_model. Nothing was installed or changed." >&2 + exit 1 + fi + overlay="$(printf '%s' "$_CLAUDE_MODEL_RESOLVED" | tail -1 | cut -f1)" + generic="$(printf '%s' "$_CLAUDE_MODEL_RESOLVED" | tail -1 | cut -f2)" + if [ "$generic" = 1 ]; then + log "Claude overlay: $CLAUDE_MODEL_ARG has no overlay of its own, so it uses the generic claude overlay (saved as claude_overlay_model)." + else + log "Claude overlay: $CLAUDE_MODEL_ARG uses the $overlay overlay (saved as claude_overlay_model; ./setup --claude-model claude returns to the generic overlay)." + fi +} + # ─── Quiet mode helper ──────────────────────────────────────── log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; } @@ -292,6 +334,16 @@ $_RF_TARGETS" in $HOST "*) ;; *) if [ "$HOST_FLAG_SET" -eq 1 ] || [ -z "$_RF_TARGETS" ]; then _rf_add "$HOST" global -; fi ;; esac + # The children read claude_overlay_model, so the parent saves it first. + if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then + case " +$_RF_TARGETS" in + *" +claude "*) _persist_claude_model ;; + *) echo "Error: --claude-model applies to Claude installs, and no Claude install is registered for this checkout. Nothing was installed or changed. Install Claude first: ./setup --claude-model $CLAUDE_MODEL_ARG" >&2 + exit 1 ;; + esac + fi _RF_SUMMARY="$(mktemp "${TMPDIR:-/tmp}/gstack-refresh.XXXXXX")" printf '%s' "$_RF_SKIPPED" > "$_RF_SUMMARY" _RF_FAILED=0 @@ -821,7 +873,7 @@ _render_install() { rm -rf "$tmp" rc=0 if [ "$host" = claude ]; then - out="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs:user --host claude --out-dir "$tmp" --link-root "$render" --install-root "$named" 2>&1)" || rc=$? + out="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs:user --host claude --out-dir "$tmp" --link-root "$render" --install-root "$named" --model "$_GSTACK_OVERLAY" 2>&1)" || rc=$? want="$(find "$SOURCE_GSTACK_DIR" -mindepth 2 -maxdepth 2 -name SKILL.md | wc -l)" got="$(find "$tmp" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l)" [ "$rc" -ne 0 ] || { [ "$want" -eq "$got" ] && grep -qF "$named/bin/gstack-skill-start --skill" "$tmp/SKILL.md" 2>/dev/null; } \ @@ -840,7 +892,10 @@ _render_install() { printf '%s\n' "$out" | tail -3 | sed 's/^/ /' >&2 [ -d "$render" ] || return 1 else + gstack_render_lock "$render" _swap_in_render "$render" "$tmp" + gstack_render_unlock "$render" + [ "$host" != claude ] || gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$render" "${_GBRAIN_RENDER_STATE:-unknown}" rendered 0 fi _INSTALL_RENDER="$render" } @@ -1092,9 +1147,14 @@ if [ "$INSTALL_COPILOT" -eq 1 ] && [ -n "${COPILOT_HOME:-}" ] && [ "${COPILOT_HO fi if [ "$MODEL_OVERRIDE_SET" -eq 1 ] && [ "$INSTALL_CODEX" -eq 0 ]; then - echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed)." >&2 + echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed). For the Claude overlay, use --claude-model ." >&2 exit 1 fi +if [ "$CLAUDE_MODEL_SET" -eq 1 ] && [ "$INSTALL_CLAUDE" -eq 0 ]; then + echo "Error: --claude-model is supported only when Claude is selected (the default host, --host claude, or --host auto with Claude Code installed). Nothing was installed or changed." >&2 + exit 1 +fi +_persist_claude_model migrate_direct_codex_install() { local gstack_dir="$1" @@ -1434,6 +1494,10 @@ if [ "$INSTALL_CODEX" -eq 1 ] || [ "$CODEX_GENERATION_MODEL" != "gpt-6-astra" ]; log "Codex skill profile: $CODEX_GENERATION_MODEL" log "Source: $CODEX_GENERATION_MODEL_SOURCE" fi +# Claude skill overlay: claude_overlay_model when present (only ./setup +# --claude-model writes it), else the generic claude overlay +# (bin/gstack-render-claude.sh). +gstack_claude_overlay "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/bin/gstack-config" # Migrate existing wrappers BEFORE build can regenerate/prune shared host trees. # This also repairs existing Codex/Kiro installs during a Claude-only setup. @@ -3514,6 +3578,11 @@ fi # as-is (zero token overhead) and any stale render dir is removed so it # can't shadow canonical files on the next relink. # +# A pinned Claude overlay (claude_overlay_model, set by ./setup +# --claude-model) is rendered into the same dir whatever detection says; a +# missing or failed detector counts as "no brain blocks" +# (bin/gstack-render-claude.sh). +# # Users who install gbrain after running ./setup should re-run setup OR # call `gstack-config gbrain-refresh`. DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect" @@ -3523,6 +3592,8 @@ _GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}" # PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't # clobber each other's in-flight detection write. DETECTION_TMP="$DETECTION_FILE.$$.tmp" +# ok | absent | unknown (detector missing or failed). +_GBRAIN_RENDER_STATE=unknown mkdir -p "$GBRAIN_STATE_DIR" if [ -x "$DETECT_BIN" ]; then if "$DETECT_BIN" > "$DETECTION_TMP" 2>/dev/null; then @@ -3534,10 +3605,12 @@ if [ -x "$DETECT_BIN" ]; then # This install serves its own per-install render (:user, so gbrain # detection decides the brain-aware blocks); refresh it with the # detection just written. + _GBRAIN_RENDER_STATE=detected if _render_claude_install "$INSTALL_GSTACK_DIR" && [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null fi elif "$DETECT_BIN" --is-ok 2>/dev/null; then + _GBRAIN_RENDER_STATE=ok if [ -n "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then # Dev/source tree (set by bin/dev-setup): detection is persisted # above; the dev workspace renders the :user variant into its own @@ -3560,29 +3633,36 @@ if [ -x "$DETECT_BIN" ]; then # No pipe before the || guard: `cmd | tail -3` reports TAIL's exit # status, so a generator crash read as success (same masking the # main gen:skill-docs site had). Capture, show the tail, propagate. - _GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude --out-dir "$_GSTACK_RENDER_TMP" --link-root "$_GSTACK_RENDER_DIR" 2>&1) + _GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude --out-dir "$_GSTACK_RENDER_TMP" --link-root "$_GSTACK_RENDER_DIR" --model "$_GSTACK_OVERLAY" 2>&1) _GEN_USER_RC=$? printf '%s\n' "$_GEN_USER_OUT" | tail -3 exit "$_GEN_USER_RC" ); then + gstack_render_lock "$_GSTACK_RENDER_DIR" _swap_in_render "$_GSTACK_RENDER_DIR" "$_GSTACK_RENDER_TMP" - # Repoint the installed skills at the fresh render — the installer - # prefers rendered files when present (#2569). - if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then - link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null - fi + gstack_render_unlock "$_GSTACK_RENDER_DIR" + gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" ok rendered else rm -rf "$_GSTACK_RENDER_TMP" log " warning: gen:skill-docs:user failed — previous render (if any) left in place, links stay valid. Run 'bun run gen:skill-docs:user --host claude --out-dir $_GSTACK_RENDER_DIR' manually if you want fresh brain-aware blocks" + gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" ok failed + fi + # Repoint the installed skills at the fresh render — the installer + # prefers rendered files when present (#2569). + if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then + link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null fi fi else + _GBRAIN_RENDER_STATE=absent log "gbrain not detected — brain-aware blocks suppressed in planning-skill SKILL.md files (zero token overhead)." log " To enable: install gbrain via /setup-gbrain, then re-run ./setup or 'gstack-config gbrain-refresh'." # A render from a previous gbrain install would shadow canonical files - # on the next link/relink — drop it and restore canonical links. - if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] && [ "$INSTALL_CLAUDE" -eq 1 ]; then + # on the next link/relink — drop it and restore canonical links. A + # pinned overlay re-renders it without brain blocks instead (below). + if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] && [ "$INSTALL_CLAUDE" -eq 1 ] && [ "$_GSTACK_OVERLAY" = claude ]; then rm -rf "$_GSTACK_RENDER_DIR" + gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" absent removed if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null fi @@ -3593,6 +3673,32 @@ if [ -x "$DETECT_BIN" ]; then log " warning: gstack-gbrain-detect failed — brain-aware blocks will stay suppressed" fi fi +# Default install without brain blocks: render a pinned overlay, or (detector +# missing or failed) drop a previous pinned render after a switch back to claude. +if [ "$INSTALL_CLAUDE" -eq 1 ] && [ -z "$_CLAUDE_INSTALL_RENDER" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] \ + && { [ "$_GBRAIN_RENDER_STATE" = absent ] || [ "$_GBRAIN_RENDER_STATE" = unknown ]; }; then + gstack_claude_render_action "$_GSTACK_RENDER_DIR" "$_GBRAIN_RENDER_STATE" + case "$_GSTACK_RENDER_ACTION" in + plain) + if gstack_claude_render_plain "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR"; then + gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" "$_GBRAIN_RENDER_STATE" rendered + else + gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" "$_GBRAIN_RENDER_STATE" failed + fi + ;; + remove) + if [ "$_GBRAIN_RENDER_STATE" = unknown ]; then + rm -rf "$_GSTACK_RENDER_DIR" + gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" unknown removed + else + _GSTACK_RENDER_ACTION=keep + fi + ;; + esac + if [ "$_GSTACK_RENDER_ACTION" != keep ] && [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then + link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null + fi +fi # Hook path resolution is CANONICAL-ONLY via the resolver defined near # CANONICAL_GSTACK_ROOT above (_hook_command_path): a hook command registered into diff --git a/test/claude-overlay-models.test.ts b/test/claude-overlay-models.test.ts index 4b27f1d8f..e12cb8e3d 100644 --- a/test/claude-overlay-models.test.ts +++ b/test/claude-overlay-models.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { ALL_MODEL_NAMES, CLAUDE_OVERLAY_MODELS, resolveClaudeOverlay } from '../scripts/models'; +import { ALL_MODEL_NAMES, CLAUDE_OVERLAY_MODELS, type ClaudeOverlay, type ClaudeOverlayError, resolveClaudeOverlay } from '../scripts/models'; const ROOT = resolve(import.meta.dir, '..'); @@ -23,7 +23,7 @@ describe('resolveClaudeOverlay', () => { ['claude-opus-4-7-1', 'claude', true], ['claude-haiku-4-5', 'claude', true], ['claude-3-opus-20240229', 'claude', true], - ])('%p uses the %p overlay', (id, overlay, generic) => { + ] as [string, ClaudeOverlay, boolean][])('%p uses the %p overlay', (id, overlay, generic) => { expect(resolveClaudeOverlay(id)).toEqual({ overlay, generic }); }); @@ -39,7 +39,7 @@ describe('resolveClaudeOverlay', () => { ['gpt-6-astra', 'non-claude'], ['banana', 'unknown'], ['', 'unknown'], - ])('%p is rejected as %p with problem, cause and fix', (id, kind) => { + ] as [string, ClaudeOverlayError['kind']][])('%p is rejected as %p with problem, cause and fix', (id, kind) => { const result = resolveClaudeOverlay(id); expect('error' in result).toBe(true); if (!('error' in result)) return; diff --git a/test/claude-overlay-setup.test.ts b/test/claude-overlay-setup.test.ts new file mode 100644 index 000000000..9b5f7a8c7 --- /dev/null +++ b/test/claude-overlay-setup.test.ts @@ -0,0 +1,301 @@ +/** + * W7 Claude overlay opt-in (`./setup --claude-model `), exercised through + * the real ./setup and `gstack-config gbrain-refresh` against throwaway + * checkouts and HOMEs (test/helpers/install-fixture.ts). + * + * The overlay is the family of the persisted `claude_overlay_model`, written + * only by `--claude-model`, or `claude` when the key is absent. Renders and + * their activation records come from bin/gstack-render-claude.sh. The marker + * below is text only the opus-4-7 and opus-4-8 overlays render. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { cleanupFixtures, type Fixture, makeFixture, makeSource, put, registryRows, runSetup, setVersion } from './helpers/install-fixture'; + +afterEach(cleanupFixtures); + +const MARKER = 'Effort-match the step'; + +/** The fixture's bun, plus a switch that fails every out-dir render. */ +function failableBun(f: Fixture) { + put(join(f.commands, 'bun'), `#!/usr/bin/env bash +case "$*" in + 'install --frozen-lockfile') exit 0 ;; + 'run build') echo 'Unexpected build in an install fixture' >&2; exit 90 ;; + *gen:skill-docs*--out-dir*) if [ -f "$HOME/fail-render" ]; then echo 'render failed (fixture)' >&2; exit 7; fi ;; +esac +exec ${JSON.stringify(process.execPath)} "$@" +`, 0o755); +} + +/** gbrain detection driven by $HOME/gbrain-mode: ok, absent (default) or fail. */ +function fakeDetect(src: string) { + put(join(src, 'bin/gstack-gbrain-detect'), `#!/usr/bin/env bash +mode="$(cat "$HOME/gbrain-mode" 2>/dev/null || echo absent)" +[ "$mode" = fail ] && exit 1 +if [ "\${1:-}" = --is-ok ]; then [ "$mode" = ok ]; exit $?; fi +if [ "$mode" = ok ]; then echo '{"gbrain_local_status":"ok","gbrain_version":"0.0.1"}'; else echo '{"gbrain_local_status":"no-cli"}'; fi +`, 0o755); +} + +function setup(f: Fixture, src: string, args: string[] = [], cwd?: string) { + const r = runSetup(f, join(src, 'setup'), ['--no-prefix', ...args], { cwd }); + return { ...r, out: `${r.stdout}\n${r.stderr}` }; +} + +function config(f: Fixture, src: string, ...args: string[]) { + return spawnSync('bash', [join(src, 'bin/gstack-config'), ...args], { env: f.env, encoding: 'utf8', timeout: 120_000 }); +} + +const defaultRender = (f: Fixture) => join(f.home, '.gstack/render/claude'); + +function record(render: string): Record { + const file = `${render}.overlay`; + if (!existsSync(file)) return {}; + return Object.fromEntries(readFileSync(file, 'utf8').trim().split('\n').map(line => line.split(/=(.*)/s).slice(0, 2))); +} + +const served = (skills: string) => readFileSync(join(skills, 'autoplan/SKILL.md'), 'utf8'); + +describe.skipIf(process.platform === 'win32')('./setup --claude-model (default install)', () => { + test('no claude_overlay_model: committed render, no render dir, no record', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + const r = setup(f, src); + expect(r.status, r.out).toBe(0); + expect(existsSync(defaultRender(f))).toBe(false); + expect(existsSync(`${defaultRender(f)}.overlay`)).toBe(false); + expect(config(f, src, 'has', 'claude_overlay_model').status).not.toBe(0); + expect(served(join(f.home, '.claude/skills'))).toBe(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8')); + expect(r.out).not.toContain('Claude skill overlay:'); + const status = setup(f, src, ['--status']); + expect(status.stdout).toContain('~/.claude/skills: claude (default, committed render)'); + }, 120_000); + + test('pinned overlay renders and persists; the banner shows only on change; claude returns to committed files', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + const skills = join(f.home, '.claude/skills'); + + const pinned = setup(f, src, ['--claude-model', 'claude-opus-4-7']); + expect(pinned.status, pinned.out).toBe(0); + expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude-opus-4-7'); + expect(pinned.out).toContain('Claude skill overlay: opus-4-7 (was claude).'); + expect(pinned.out).toContain('./setup --claude-model claude'); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-7', source: 'explicit', model: 'claude-opus-4-7', gbrain: 'absent', render: 'user' }); + expect(served(skills)).toContain(MARKER); + expect(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8')).not.toContain(MARKER); + + const again = setup(f, src); + expect(again.status, again.out).toBe(0); + expect(again.out).not.toContain('Claude skill overlay:'); + expect(served(skills)).toContain(MARKER); + + const back = setup(f, src, ['--claude-model', 'claude']); + expect(back.status, back.out).toBe(0); + expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude'); + expect(back.out).toContain('Claude skill overlay: claude (was opus-4-7).'); + expect(existsSync(defaultRender(f))).toBe(false); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'explicit', render: 'committed' }); + expect(served(skills)).not.toContain(MARKER); + }, 180_000); + + test('an ID without its own overlay is accepted as the generic overlay', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + const r = setup(f, src, ['--claude-model=claude-opus-5-5']); + expect(r.status, r.out).toBe(0); + expect(r.out).toContain('claude-opus-5-5 has no overlay of its own, so it uses the generic claude overlay'); + expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude-opus-5-5'); + expect(existsSync(defaultRender(f))).toBe(false); + }, 120_000); + + test('invalid IDs, a missing value and a non-Claude host error before anything is persisted', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + for (const [args, expected] of [ + [['--claude-model', 'opus'], 'is a Claude Code alias'], + [['--claude-model', 'gpt-5.4'], 'selects Claude overlays only'], + [['--claude-model=banana'], 'does not match any model family'], + [['--host', 'codex', '--claude-model', 'claude-opus-4-7'], '--claude-model is supported only when Claude is selected'], + ] as const) { + const r = setup(f, src, [...args]); + expect(r.status, r.out).toBe(1); + expect(r.stderr).toContain(expected); + expect(r.stderr).toContain('Nothing was installed or changed.'); + } + expect(config(f, src, 'has', 'claude_overlay_model').status).not.toBe(0); + for (const args of [['--claude-model'], ['--claude-model=']]) { + const missing = spawnSync('bash', [join(src, 'setup'), ...args], { env: f.env, encoding: 'utf8', timeout: 30_000 }); + expect(missing.status).toBe(1); + expect(missing.stderr).toContain('Missing value for --claude-model'); + } + const typo = runSetup(f, join(src, 'setup'), ['--claude-modle', 'claude-opus-4-8']); + expect(typo.status).toBe(2); + expect(typo.stderr).toContain('Did you mean: ./setup --claude-model claude-opus-4-8'); + const codexOnly = setup(f, src, ['--model', 'gpt-5.4']); + expect(codexOnly.stderr).toContain('For the Claude overlay, use --claude-model .'); + }, 240_000); + + test('a persisted ID that no longer resolves falls back to claude and says why', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + expect(config(f, src, 'set', 'claude_overlay_model', 'opus').status).toBe(0); + const r = setup(f, src); + expect(r.status, r.out).toBe(0); + expect(r.out).toContain("claude_overlay_model 'opus' no longer resolves to a Claude overlay"); + expect(r.out).toContain('using the generic claude overlay'); + expect(existsSync(defaultRender(f))).toBe(false); + }, 120_000); +}); + +describe.skipIf(process.platform === 'win32')('overlay render lifecycle with gbrain and failures', () => { + test('gbrain present → absent keeps a pinned overlay; a failed detector still renders it', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + fakeDetect(src); + const skills = join(f.home, '.claude/skills'); + + put(join(f.home, 'gbrain-mode'), 'ok'); + expect(setup(f, src, ['--claude-model', 'claude-opus-4-8']).status).toBe(0); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-8', gbrain: 'ok' }); + expect(served(skills)).toContain(MARKER); + + put(join(f.home, 'gbrain-mode'), 'absent'); + const refresh = config(f, src, 'gbrain-refresh'); + expect(refresh.status, refresh.stdout + refresh.stderr).toBe(0); + expect(refresh.stdout).toContain('gbrain not detected'); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-8', gbrain: 'absent' }); + expect(served(skills)).toContain(MARKER); + + put(join(f.home, 'gbrain-mode'), 'fail'); + const failed = setup(f, src); + expect(failed.status, failed.out).toBe(0); + expect(failed.out).toContain('gstack-gbrain-detect failed'); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-8', gbrain: 'unknown' }); + expect(served(skills)).toContain(MARKER); + }, 240_000); + + test('gbrain present → absent with the default overlay drops the brain render', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + fakeDetect(src); + expect(setup(f, src).status).toBe(0); + put(join(f.home, 'gbrain-mode'), 'ok'); + expect(config(f, src, 'gbrain-refresh').status).toBe(0); + expect(existsSync(join(defaultRender(f), 'autoplan/SKILL.md'))).toBe(true); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'default', gbrain: 'ok' }); + + put(join(f.home, 'gbrain-mode'), 'absent'); + const r = config(f, src, 'gbrain-refresh'); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(existsSync(defaultRender(f))).toBe(false); + expect(existsSync(`${defaultRender(f)}.overlay`)).toBe(false); + expect(served(join(f.home, '.claude/skills'))).toBe(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8')); + }, 180_000); + + test('a failed render keeps the previous render; after an upgrade it serves the committed files', () => { + const f = makeFixture(); + failableBun(f); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + const skills = join(f.home, '.claude/skills'); + expect(setup(f, src, ['--claude-model', 'claude-sonnet-5']).status).toBe(0); + expect(record(defaultRender(f)).overlay).toBe('sonnet-5'); + + put(join(f.home, 'fail-render'), '1'); + const kept = setup(f, src); + expect(kept.status, kept.out).toBe(0); + expect(kept.out).toContain('the previous render (sonnet-5 overlay) stays in use'); + expect(existsSync(join(defaultRender(f), 'autoplan/SKILL.md'))).toBe(true); + + setVersion(src, '9.9.9.9'); + const upgraded = setup(f, src); + expect(upgraded.status, upgraded.out).toBe(0); + expect(upgraded.out).toContain('the committed claude render is served'); + expect(upgraded.out).toContain('Claude skill overlay: claude (was sonnet-5).'); + expect(existsSync(defaultRender(f))).toBe(false); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'explicit', render: 'committed', version: '9.9.9.9' }); + expect(served(skills)).toBe(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8')); + }, 240_000); + + test('concurrent gbrain-refresh runs leave one valid render and no temp, old or lock dirs', async () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + fakeDetect(src); + expect(setup(f, src, ['--claude-model', 'claude-opus-4-7']).status).toBe(0); + put(join(f.home, 'gbrain-mode'), 'ok'); + const run = () => new Promise<{ code: number | null; err: string }>(resolve => { + const child = spawn('bash', [join(src, 'bin/gstack-config'), 'gbrain-refresh'], { env: f.env, stdio: ['ignore', 'ignore', 'pipe'] }); + let err = ''; + child.stderr.on('data', chunk => { err += chunk; }); + child.on('exit', code => resolve({ code, err })); + }); + const runs = await Promise.all([run(), run(), run()]); + expect(runs).toEqual([{ code: 0, err: '' }, { code: 0, err: '' }, { code: 0, err: '' }]); + const renderParent = join(f.home, '.gstack/render'); + expect(readdirSync(renderParent).sort()).toEqual(['claude', 'claude.overlay']); + expect(readdirSync(defaultRender(f)).filter(name => name.startsWith('claude'))).toEqual([]); + expect(readFileSync(join(defaultRender(f), 'autoplan/SKILL.md'), 'utf8')).toContain(MARKER); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-7', gbrain: 'ok' }); + }, 240_000); +}); + +describe.skipIf(process.platform === 'win32')('nondefault installs, refresh and status', () => { + test('a project install keeps its own per-install render: pinned, back to claude, failure keeps the previous render', () => { + const f = makeFixture(); + failableBun(f); + const project = join(f.home, 'proj'); + const vendored = makeSource(f, join(project, '.claude/skills/gstack')); + const skills = join(project, '.claude/skills'); + const renderOf = () => registryRows(f).find(row => row[0] === 'claude')?.[8] ?? ''; + + expect(setup(f, vendored, ['--claude-model', 'claude-opus-4-7'], project).status).toBe(0); + const render = renderOf(); + expect(render).toContain('/render/installs/claude-'); + expect(record(render)).toMatchObject({ overlay: 'opus-4-7', source: 'explicit' }); + expect(served(skills)).toContain(MARKER); + expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`); + + expect(setup(f, vendored, ['--claude-model', 'claude'], project).status).toBe(0); + expect(renderOf()).toBe(render); + expect(record(render).overlay).toBe('claude'); + expect(served(skills)).not.toContain(MARKER); + expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`); + + put(join(f.home, 'fail-render'), '1'); + const failed = setup(f, vendored, ['--claude-model', 'claude-opus-4-8'], project); + expect(failed.status, failed.out).toBe(0); + expect(failed.out).toContain('the previous render stays in use'); + expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`); + expect(served(skills)).not.toContain(MARKER); + }, 240_000); + + test('--refresh-registered persists the overlay before refreshing; later refreshes honor it with no flag', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + expect(setup(f, src).status).toBe(0); + const refreshed = setup(f, src, ['--refresh-registered', '--claude-model', 'claude-opus-4-7']); + expect(refreshed.status, refreshed.out).toBe(0); + expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude-opus-4-7'); + expect(record(defaultRender(f)).overlay).toBe('opus-4-7'); + + const quiet = runSetup(f, join(src, 'setup'), ['-q', '--refresh-registered']); + expect(quiet.status, quiet.stdout + quiet.stderr).toBe(0); + expect(record(defaultRender(f)).overlay).toBe('opus-4-7'); + expect(served(join(f.home, '.claude/skills'))).toContain(MARKER); + }, 180_000); + + test('./setup --status reports the overlay without bun on PATH', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + expect(setup(f, src, ['--claude-model', 'claude-opus-4-7']).status).toBe(0); + const noBunPath = '/usr/bin:/bin'; + expect(spawnSync('bash', ['-c', 'command -v bun'], { env: { ...f.env, PATH: noBunPath }, encoding: 'utf8', timeout: 10_000 }).status).not.toBe(0); + const r = spawnSync('bash', [join(src, 'setup'), '--status'], { env: { ...f.env, PATH: noBunPath }, encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('Claude skill overlay:'); + expect(r.stdout).toContain('~/.claude/skills: opus-4-7 (explicit: claude-opus-4-7, user render, gstack '); + }, 120_000); +}); diff --git a/test/setup-claude-code-migration.test.ts b/test/setup-claude-code-migration.test.ts index 7fb8f54f0..fe33e9910 100644 --- a/test/setup-claude-code-migration.test.ts +++ b/test/setup-claude-code-migration.test.ts @@ -30,7 +30,7 @@ function fixture(copy: boolean) { put(path.join(root, 'VERSION'), '2.0.0.0\n'); put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n'); put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } })); - for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-install-registry.sh']) { + for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh']) { const target = path.join(root, 'bin', bin); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(path.join(ROOT, 'bin', bin), target);