#!/usr/bin/env bash
# gstack setup — build browser binary + register skills with Claude Code / Codex
set -e
umask 077  # Restrict new files to owner-only (0o600 files, 0o700 dirs)

# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
# pipe in the forked child before exec, with no reader on the other end. On
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
# body >=512B blocks write() forever — ./setup --help would hang with no
# output. Compat level 50 restores the tempfile path. This script is
# bash-3.2-clean, so the compat level costs it nothing. Not exported: the
# guard is per-script, and it survives `bash setup` call sites that bypass
# the shebang.
BASH_COMPAT=50

usage() {
  cat <<'EOF'
gstack setup — install gstack skills + build browse binary

Usage: ./setup [options]

Options:
  --host <name>     Install for one host. Default: claude. Hosts by tier:
                      full:             claude
                      experimental:     codex, kiro, factory, opencode, cursor, copilot
                      instruction-only: slate, openclaw, hermes, gbrain
                      auto:             every installed agent that setup detects
                    An explicit --host never changes another host's install.
  --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).
  --no-prefix       Install skills with short names (e.g. /review). Default.
  --team            Switch to team mode (per-repo gstack with auto-update).
  --no-team         Force solo install even if a team-mode repo is detected.
  --status          Print one row per gstack install (read-only) and exit.
  --refresh-registered
                    Refresh every install registered for this checkout (what
                    /gstack-upgrade runs), then print one row per install.
  -q, --quiet       Suppress progress output.
  -h, --help        Show this help and exit.

Model selection:
  --model <id>      Codex model profile override. Otherwise reads Codex config.
  --claude-model <id>
                    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

Docs: https://github.com/garrytan/gstack#other-ai-agents
      docs/ADDING_A_HOST.md (install ownership rules, host tiers)
EOF
}

# Short-circuit on -h/--help before any environment checks so users can
# discover flags even without bun installed.
for _arg in "$@"; do
  case "$_arg" in
    -h|--help) usage; exit 0 ;;
  esac
done
# --status is read-only: no bun, no build, no writes (docs/ADDING_A_HOST.md).
# _status_install_checks — per registered install (C3): is the router a real
# file (Codex skips a symlinked SKILL.md), and does every section pointer in an
# installed SKILL.md resolve? A pointer is "Read `<path>/sections/<f>.md`",
# optionally "relative to the installed `<skill>` SKILL.md directory". A bare
# `sections/...` pointer counts in a carved skill (sections/ installed, or a
# **STOP.** line); prefixed ones ($GSTACK_ROOT, ~, absolute, ../) everywhere.
_status_install_checks() {
  local reg="$GSTACK_STATE_ROOT/installs.tsv" host scope project dest root src rest router md dir stop tok names n path checked broken first fix
  [ -f "$reg" ] || return 0
  echo "Install checks:"
  while IFS='	' read -r host scope project dest root src rest; do
    case "$host" in ''|'#'*) continue ;; esac
    fix="Fix: cd $src && ./setup --host $host"
    router="$root/SKILL.md"
    if [ -L "$router" ]; then router="router is a symlink, which Codex skips. $fix"
    elif [ -f "$router" ]; then router="router is a real file"
    else router="router missing at $router. $fix"; fi
    checked=0 broken=0 first=""
    for md in "$dest"/*/SKILL.md; do
      [ -f "$md" ] || continue
      dir="${md%/SKILL.md}"
      while IFS='|' read -r stop tok names; do
        [ -n "$tok" ] || continue
        path=""
        case "$tok" in
          sections/*)
            if [ -n "$names" ]; then
              for n in $names; do path="$dest/$n/$tok"; [ -r "$path" ] && break; done
            elif [ -d "$dir/sections" ] || [ "$stop" = 1 ]; then path="$dir/$tok"
            else continue; fi ;;
          '$GSTACK_ROOT/'*) path="$root/${tok#\$GSTACK_ROOT/}" ;;
          '~/'*) path="$HOME/${tok#\~/}" ;;
          /*) path="$tok" ;;
          ../*) path="$dir/$tok" ;;
          *) continue ;;
        esac
        checked=$((checked + 1))
        [ -r "$path" ] && continue
        broken=$((broken + 1))
        [ -n "$first" ] || first="${md#"$dest"/} names $tok"
      done <<EOF
$(awk '{
  line = $0; stop = index(line, "**STOP.**") ? 1 : 0
  while (match(line, /Read `[^` ]*sections\/[A-Za-z0-9._-]+\.md`( relative to the installed (`[^`]+`\/?)+)?/)) {
    m = substr(line, RSTART + 6, RLENGTH - 6); line = substr(line, RSTART + RLENGTH)
    tok = m; sub(/`.*/, "", tok); names = ""
    if (index(m, " relative to the installed ")) { names = substr(m, index(m, " relative to the installed ") + 27); gsub(/[`\/]+/, " ", names) }
    print stop "|" tok "|" names
  }
}' "$md")
EOF
    done
    if [ "$broken" -eq 0 ]; then
      printf '  %s %s: %s; section links: %s checked, all resolve\n' "$host" "$scope" "$router" "$checked"
    else
      printf '  %s %s: %s; section links: %s of %s broken (first: %s). %s\n' "$host" "$scope" "$router" "$broken" "$checked" "$first" "$fix"
    fi
  done < "$reg"
}
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" \
        && . "$_STATUS_DIR/bin/gstack-render-claude.sh" || exit 1
      gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
      gstack_install_status
      _status_install_checks
      gstack_claude_overlay_status
      exit 0 ;;
  esac
done

# Informational hosts do not install anything. Resolve them before tool,
# platform, and installation-path probes (especially costly in Git Bash).
# ─── Parse flags ──────────────────────────────────────────────
HOST="claude"
QUIET=0
LOCAL_INSTALL=0
GLOBAL_INSTALL=0
SKILL_PREFIX=1
SKILL_PREFIX_FLAG=0
TEAM_MODE=0
NO_TEAM_MODE=0
PLAN_TUNE_HOOKS_MODE=""   # "" = resolve from env/config/prompt; "yes"/"no" = explicit
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 --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 {
      n = length(a); m = length(b)
      for (i = 0; i <= n; i++) d[i, 0] = i
      for (j = 0; j <= m; j++) d[0, j] = j
      for (i = 1; i <= n; i++) for (j = 1; j <= m; j++) {
        c = (substr(a, i, 1) == substr(b, j, 1)) ? 0 : 1
        v = d[i-1, j] + 1; if (d[i, j-1] + 1 < v) v = d[i, j-1] + 1; if (d[i-1, j-1] + c < v) v = d[i-1, j-1] + c
        d[i, j] = v
      }
      print d[n, m] }')"
    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|--claude-model) case "$next" in ''|-*) ;; *) value=" $next" ;; esac ;; esac
  if [ "$best_d" -le 3 ]; then
    echo "Did you mean: ./setup $best$value" >&2
  fi
  echo "Run ./setup --help for every option." >&2
}
while [ $# -gt 0 ]; do
  case "$1" in
    --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, cursor, copilot, slate, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; HOST_FLAG_SET=1; shift 2 ;;
    --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 ;;
    --no-prefix) SKILL_PREFIX=0; SKILL_PREFIX_FLAG=1; shift ;;
    --team)    TEAM_MODE=1; shift ;;
    --no-team) NO_TEAM_MODE=1; shift ;;
    --plan-tune-hooks)    PLAN_TUNE_HOOKS_MODE="yes"; shift ;;
    --no-plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="no"; shift ;;
    --plan-tune-hooks=*)  PLAN_TUNE_HOOKS_MODE="${1#--plan-tune-hooks=}"; shift ;;
    --timeline-stop-hook)    TIMELINE_STOP_HOOK_MODE="yes"; shift ;;
    --no-timeline-stop-hook) TIMELINE_STOP_HOOK_MODE="no"; shift ;;
    --timeline-stop-hook=*)  TIMELINE_STOP_HOOK_MODE="${1#--timeline-stop-hook=}"; shift ;;
    --refresh-registered) REFRESH_REGISTERED=1; shift ;;
    -q|--quiet) QUIET=1; shift ;;
    *) _setup_unknown_option "$@"; exit 2 ;;
  esac
done

# Shared by the instruction-tier explainer arms (openclaw, hermes). The digest
# path is anchored to the script's own directory — $(pwd) would print a
# nonexistent path when setup is invoked from anywhere else.
print_instruction_tier() {
  local source_dir
  source_dir="$(cd "$(dirname "$0")" && pwd -P)"
  echo ""
  echo "Instruction-only tier (no install): copy the 2KB rules digest into a"
  echo "location your agent reads (e.g. append to your project's AGENTS.md)."
  echo "It carries gstack's ethos, reuse ladder, and voice rules:"
  echo ""
  echo "  $source_dir/agents-digest/gstack-AGENTS.md"
  echo ""
  echo "Re-copy it after upgrading gstack — the digest's first line shows its version."
  echo ""
}

case "$HOST" in
  claude|codex|kiro|factory|opencode|cursor|copilot|auto) ;;
  slate)
    echo ""
    echo "Slate is not yet a first-class install target (docs/designs/SLATE_HOST.md —"
    echo "blocked on the host-config refactor). Slate discovers skills from"
    echo ".claude/skills as a compatibility fallback, so a Slate user is served by"
    echo "the Claude install today:"
    echo ""
    echo "  ./setup --host claude"
    echo ""
    exit 0 ;;
  openclaw)
    echo ""
    echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
    echo "sessions natively via ACP. gstack provides methodology artifacts, not a"
    echo "full skill installation."
    echo ""
    echo "To integrate gstack with OpenClaw:"
    echo "  1. Tell your OpenClaw agent: 'install gstack for openclaw'"
    echo "  2. Or generate artifacts: bun run gen:skill-docs --host openclaw"
    echo "  3. See docs/OPENCLAW.md for the full architecture"
    print_instruction_tier
    exit 0 ;;
  hermes)
    echo ""
    echo "Hermes is an instruction-only gstack host: setup installs nothing for"
    echo "Hermes and changed nothing on this machine."
    echo ""
    echo "To use gstack with Hermes:"
    echo "  1. Copy the rules digest below into a file Hermes reads, or"
    echo "  2. Render Hermes-flavoured skills yourself (not certified; #2826):"
    echo "       bun run gen:skill-docs --host hermes"
    echo "     then point a Hermes profile's skills.external_dirs at .hermes/skills."
    print_instruction_tier
    exit 0 ;;
  gbrain)
    echo ""
    echo "GBrain is a mod for gstack — it makes coding skills brain-aware."
    echo "GBrain generates brain-enhanced skill variants that search your brain"
    echo "for context before starting and save results after finishing."
    echo ""
    echo "To generate brain-aware skills:"
    echo "  bun run gen:skill-docs --host gbrain"
    echo ""
    echo "GBrain setup and brain skills ship from the GBrain repo."
    echo ""
    exit 0 ;;
  *)
    echo "Unknown --host value: $HOST — nothing was installed or changed. Expected one of: claude (default), codex, kiro, factory, opencode, cursor, copilot, slate, openclaw, hermes, gbrain, or auto. Tiers: ./setup --help" >&2; exit 1 ;;
esac

# Bun versions come from one sourced file (floor and tested; E1).
. "$(dirname "$0")/bin/gstack-bun-version.sh" 2>/dev/null || { echo "$0: bin/gstack-bun-version.sh is missing; nothing was installed or changed. Fix: reinstall gstack (git clone https://github.com/garrytan/gstack)" >&2; exit 1; }
if ! command -v bun >/dev/null 2>&1; then
  echo "Error: bun is required but not installed." >&2
  echo "Install with checksum verification:" >&2
  echo "  BUN_VERSION=\"$GSTACK_BUN_TESTED\"" >&2
  echo '  tmpfile=$(mktemp)' >&2
  echo '  curl -fsSL "https://bun.sh/install" -o "$tmpfile"' >&2
  echo '  echo "Verify checksum before running: sha256sum $tmpfile   # or: shasum -a 256 $tmpfile"' >&2
  echo '  BUN_VERSION="$BUN_VERSION" bash "$tmpfile" && rm "$tmpfile"' >&2
  exit 1
fi
_BUN_FOUND="$(bun --version 2>/dev/null | head -1)"
case "$(gstack_bun_status "$_BUN_FOUND")" in
  too-old)
    echo "gstack needs Bun $GSTACK_BUN_FLOOR or newer ($GSTACK_BUN_TESTED recommended); found $_BUN_FOUND at $(command -v bun). Nothing was installed or changed." >&2
    echo "Why: older Bun ignores --no-compile-autoload-dotenv, so gstack's compiled tools could read a project's .env." >&2
    echo "Fix: bun upgrade, then re-run ./setup (docs/troubleshooting.md#bun-too-old)" >&2
    exit 1 ;;
  untested) echo "warning: gstack is tested on Bun $GSTACK_BUN_TESTED (CI pin); found $_BUN_FOUND. Upgrade with: bun upgrade (docs/troubleshooting.md#bun-too-old)" >&2 ;;
  malformed) echo "warning: could not read the Bun version (bun --version printed '$(printf '%s' "$_BUN_FOUND" | head -c 60)'); continuing. gstack is tested on Bun $GSTACK_BUN_TESTED (docs/troubleshooting.md#bun-too-old)" >&2 ;;
esac

INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)"
# One state root for everything setup writes (bin/gstack-state-root.sh, docs/state-root.md).
. "$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; }

# ─── --refresh-registered: refresh exactly the registered installs (#1925) ───
# What /gstack-upgrade and the team-mode auto-update run. Each install this
# checkout registered is refreshed by running setup for that one host, in its
# own process; installs owned by another checkout, and project installs of
# another project, are listed with the command that refreshes them there and
# are never repointed. Pre-registry installs that resolve to this checkout are
# discovered and registered; with nothing registered or discovered, the
# selected host (default claude) is installed as before.
if [ "$REFRESH_REGISTERED" -eq 1 ] && [ -z "${GSTACK_SETUP_REFRESH_CHILD:-}" ]; then
  gstack_install_registry_reconcile || true
  _RF_HERE="$(pwd -P)"
  _RF_TARGETS=""
  _RF_SKIPPED=""
  _rf_add() {
    case "
$_RF_TARGETS" in *"
$1	$2	$3
"*) return 0 ;; esac
    _RF_TARGETS="$_RF_TARGETS$1	$2	$3
"
  }
  while IFS='	' read -r _rf_host _rf_scope _rf_project _rf_dest _rf_root _rf_src _rf_ver _rf_rest; do
    [ -n "$_rf_host" ] || continue
    if [ "$_rf_src" != "$SOURCE_GSTACK_DIR" ]; then
      _RF_SKIPPED="$_RF_SKIPPED$_rf_host	$_rf_scope	$_rf_src	$_rf_dest	$_rf_ver	$(cat "$_rf_src/VERSION" 2>/dev/null || echo -)	skipped	owned by another checkout; refresh it there: cd $_rf_src && ./setup --refresh-registered
"
      continue
    fi
    if [ "$_rf_scope" = project ]; then
      case "$_RF_HERE/" in
        "$(cd "$_rf_project" 2>/dev/null && pwd -P)/"*) ;;
        *) _RF_SKIPPED="$_RF_SKIPPED$_rf_host	$_rf_scope	$_rf_src	$_rf_dest	$_rf_ver	$SETUP_VERSION	skipped	project install of $_rf_project; refresh it from inside that project: cd $_rf_project && $_rf_root/setup --refresh-registered
"
           continue ;;
      esac
    fi
    _rf_add "$_rf_host" "$_rf_scope" "$_rf_root"
  done <<EOF
$(gstack_install_registry_rows)
EOF
  _RF_REGISTERED_ROOTS="|$(gstack_install_registry_rows | awk -F '\t' '{ printf "%s|", $5 }')"
  while IFS='	' read -r _rf_host _rf_scope _rf_dest _rf_root; do
      [ -n "$_rf_host" ] || continue
      { [ -e "$_rf_root" ] || [ -L "$_rf_root" ]; } || continue
      case "$_RF_REGISTERED_ROOTS" in *"|$_rf_root|"*) continue ;; esac
      _rf_src="$(_gstack_install_source "$_rf_root")"
      [ -n "$_rf_src" ] || continue
      if [ "$_rf_src" = "$SOURCE_GSTACK_DIR" ]; then
        _rf_add "$_rf_host" "$_rf_scope" "$_rf_root"
      elif [ "$_rf_scope" = global ]; then
        _RF_SKIPPED="$_RF_SKIPPED$_rf_host	$_rf_scope	$_rf_src	$_rf_dest	-	$(cat "$_rf_src/VERSION" 2>/dev/null || echo -)	skipped	pre-registry install owned by another checkout; choose: refresh it there (cd $_rf_src && ./setup --host $_rf_host) or move it to this checkout (./setup --host $_rf_host --global)
"
      fi
  done <<EOF
$(gstack_install_known_roots)
EOF
  case "
$_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
  log "Refreshing registered gstack installs from source: $SOURCE_GSTACK_DIR (v$SETUP_VERSION)"
  while IFS='	' read -r _rf_host _rf_scope _rf_root; do
    [ -n "$_rf_host" ] || continue
    _rf_setup="$SOURCE_GSTACK_DIR/setup"
    for _rf_try in "$_rf_root" "$(dirname "$(dirname "$(dirname "$_rf_root")")")/.claude/skills/gstack"; do
      if [ -f "$_rf_try/setup" ] && [ "$(cd "$_rf_try" && pwd -P)" = "$SOURCE_GSTACK_DIR" ]; then
        _rf_setup="$_rf_try/setup"; break
      fi
    done
    _rf_args=(--host "$_rf_host")
    if [ "$_rf_host" = codex ] && [ "$_rf_scope" = global ]; then _rf_args+=(--global); fi
    if [ "$QUIET" -eq 1 ]; then _rf_args+=(-q); fi
    log ""
    log "── $_rf_host ($_rf_scope) ← $_rf_setup"
    _rf_before="$(wc -l < "$_RF_SUMMARY" | tr -d ' ')"
    if ! GSTACK_SETUP_REFRESH_CHILD=1 GSTACK_SETUP_SUMMARY_FILE="$_RF_SUMMARY" bash "$_rf_setup" "${_rf_args[@]}" </dev/null; then
      _RF_FAILED=1
      if [ "$(wc -l < "$_RF_SUMMARY" | tr -d ' ')" = "$_rf_before" ]; then
        printf '%s	%s	%s	%s	-	%s	failed	setup exited non-zero before installing; its previous install was left in place. Retry: cd %s && ./setup --host %s
' \
          "$_rf_host" "$_rf_scope" "$SOURCE_GSTACK_DIR" "${_rf_root:--}" "$SETUP_VERSION" "$SOURCE_GSTACK_DIR" "$_rf_host" >> "$_RF_SUMMARY"
      fi
    fi
  done <<EOF
$_RF_TARGETS
EOF
  grep -q '	failed	' "$_RF_SUMMARY" && _RF_FAILED=1
  echo ""
  if [ "$_RF_FAILED" -eq 1 ]; then
    echo "Upgrade summary (NOT every install was refreshed; see failed rows):"
  else
    echo "Upgrade summary:"
  fi
  gstack_install_registry_render < "$_RF_SUMMARY"
  _rf_disabled="$(gstack_disabled_normalize "$("$SOURCE_GSTACK_DIR/bin/gstack-config" get disabled_skills 2>/dev/null || true)")"
  if [ "$_rf_disabled" != " " ]; then
    echo "  disabled skills (not registered on any host):$_rf_disabled"
  fi
  rm -f "$_RF_SUMMARY"
  exit "$_RF_FAILED"
fi
INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")"
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"
CODEX_SKILLS="${CODEX_HOME:-$HOME/.codex}/skills"
CODEX_GSTACK="$CODEX_SKILLS/gstack"
FACTORY_SKILLS="$HOME/.factory/skills"
FACTORY_GSTACK="$FACTORY_SKILLS/gstack"
OPENCODE_SKILLS="$HOME/.config/opencode/skills"
OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"
CURSOR_SKILLS="$HOME/.cursor/skills"
CURSOR_GSTACK="$CURSOR_SKILLS/gstack"
KIRO_SKILLS="$HOME/.kiro/skills"
COPILOT_SKILLS="$HOME/.copilot/skills"
COPILOT_GSTACK="$COPILOT_SKILLS/gstack"

SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")"
SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")"
CODEX_REPO_LOCAL=0
if [ "$(basename "$INSTALL_GSTACK_DIR")" = "gstack" ] && [ "$SKILLS_BASENAME" = "skills" ] \
  && { [ "$SKILLS_PARENT_BASENAME" = ".agents" ] || [ "$SKILLS_PARENT_BASENAME" = ".claude" ]; } \
  && [ "$INSTALL_SKILLS_DIR" != "$HOME/.agents/skills" ] \
  && [ "$INSTALL_SKILLS_DIR" != "$HOME/.claude/skills" ] \
  && [ "$INSTALL_SKILLS_DIR" != "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills" ]; then
  if [ "$GLOBAL_INSTALL" -eq 1 ]; then
    echo "Global Codex registration requested from project source: $INSTALL_GSTACK_DIR (resolved: $SOURCE_GSTACK_DIR)" >&2
  else
    CODEX_REPO_LOCAL=1
    if [ "$SKILLS_PARENT_BASENAME" = ".agents" ]; then
      CODEX_SKILLS="$INSTALL_SKILLS_DIR"
      CODEX_GSTACK="$INSTALL_GSTACK_DIR"
    else
      CODEX_SKILLS="$(dirname "$(dirname "$INSTALL_SKILLS_DIR")")/.agents/skills"
      CODEX_GSTACK="$CODEX_SKILLS/gstack"
    fi
  fi
fi

if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then
  _resolve_codex_namespace() {
    local namespace="$1" label="$2" parent="$1" suffix="" physical
    while [ ! -d "$parent" ]; do
      if [ -L "$parent" ] || [ -e "$parent" ]; then
        echo "Refusing: project-local Codex $label contains an unresolvable directory: $parent. Use a separate project-local skills directory; setup will not relink it automatically." >&2
        return 1
      fi
      suffix="/$(basename "$parent")$suffix"
      parent="$(dirname "$parent")"
    done
    if ! physical="$(cd "$parent" && pwd -P)"; then
      echo "Refusing: project-local Codex $label contains an unresolvable directory: $namespace. Use a separate project-local skills directory; setup will not relink it automatically." >&2
      return 1
    fi
    printf '%s%s\n' "$physical" "$suffix"
  }
  _codex_project="$(cd "$(dirname "$(dirname "$INSTALL_SKILLS_DIR")")" && pwd -P)"
  _codex_parent="$(_resolve_codex_namespace "$CODEX_SKILLS" destination)" || exit 1
  case "$_codex_parent" in
    "$_codex_project"|"$_codex_project"/*) ;;
    *) echo "Refusing: project-local Codex destination resolves outside the project: $CODEX_SKILLS -> $_codex_parent. Use a separate project-local skills directory; setup will not relink it automatically." >&2; exit 1 ;;
  esac
  _codex_payload="$_codex_parent"
  while :; do
    if [ "$_codex_payload" = "$SOURCE_GSTACK_DIR" ] \
      || { [ -f "$_codex_payload/setup" ] && [ -f "$_codex_payload/VERSION" ] && [ -f "$_codex_payload/bin/gstack-relink" ]; }; then
      echo "Refusing: project-local Codex destination overlaps the source tree at $_codex_payload: $CODEX_SKILLS -> $_codex_parent. Use a separate project-local skills directory; setup will not relink it automatically." >&2
      exit 1
    fi
    [ "$_codex_payload" = / ] && break
    _codex_payload="$(dirname "$_codex_payload")"
  done
  _codex_generation="$(_resolve_codex_namespace "$SOURCE_GSTACK_DIR/.agents/skills" 'generation namespace')" || exit 1
  case "$_codex_generation" in
    "$SOURCE_GSTACK_DIR"/*) ;;
    *) echo "Refusing: project-local Codex generation namespace resolves outside the source tree: $SOURCE_GSTACK_DIR/.agents/skills -> $_codex_generation. Use a separate project-local skills directory; setup will not relink it automatically." >&2; exit 1 ;;
  esac
  if [ -d "$CODEX_GSTACK" ] && [ "$(cd "$CODEX_GSTACK" && pwd -P)" != "$SOURCE_GSTACK_DIR" ] \
    && { [ -e "$CODEX_GSTACK/.git" ] \
      || { [ -f "$CODEX_GSTACK/setup" ] && [ -f "$CODEX_GSTACK/VERSION" ] && [ -f "$CODEX_GSTACK/bin/gstack-relink" ]; }; }; then
    echo "Refusing to replace existing source checkout at $CODEX_GSTACK. Run setup from that checkout or choose a separate project-local install location." >&2
    exit 1
  fi
fi

if { [ "$CODEX_REPO_LOCAL" -eq 1 ] || [ "$HOST" = codex ] || { [ "$HOST" = auto ] && command -v codex >/dev/null 2>&1; }; } \
  && [ -d "$CODEX_GSTACK" ] && [ ! -L "$CODEX_GSTACK" ]; then
  _codex_runtime="$(cd "$CODEX_GSTACK" && pwd -P)"
  case "$SOURCE_GSTACK_DIR" in
    "$_codex_runtime"/*)
      echo "Refusing: Codex runtime directory contains the source checkout: $CODEX_GSTACK -> $_codex_runtime (source: $SOURCE_GSTACK_DIR). Use a separate skills directory; setup will not relocate or replace it automatically." >&2
      exit 1 ;;
  esac
fi

IS_WINDOWS=0
case "$(uname -s)" in
  MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;;
esac

# Windows: binaries are compiled with .exe suffix
if [ "$IS_WINDOWS" -eq 1 ]; then
  BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse.exe"
fi

# ─── Symlink-or-copy helper ───────────────────────────────────
# On macOS/Linux: create a symlink (existing behavior).
# On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently
# creates a frozen file copy that doesn't refresh after `git pull`. We use
# explicit `cp -R` / `cp -f` so the user gets a real copy and the staleness
# is reportable (re-run ./setup after pull). Auto-detects file vs dir.
#
# INVARIANT: every symlink in this script MUST route through this helper.
# A raw ln call here will be caught by test/setup-windows-fallback.test.ts
# (the static-invariant assertion D7).
#
# Windows copies are staged: the new copy is built beside the destination and
# swapped in only when complete, so a failed or interrupted refresh keeps the
# last working copy. Interruption recovery: a destination that is missing
# while "$dst.gstack-old.*" exists (killed between the two renames) is
# restored from it on the next run; stale "$dst.gstack-new.*" partial copies
# are discarded.
_link_or_copy() {
  local src="$1"
  local dst="$2"
  local new old leftover
  if [ "$IS_WINDOWS" -eq 1 ]; then
    for leftover in "$dst".gstack-old.*; do
      [ -e "$leftover" ] || continue
      if [ ! -e "$dst" ]; then mv "$leftover" "$dst"; else rm -rf "$leftover"; fi
    done
    for leftover in "$dst".gstack-new.*; do
      [ -e "$leftover" ] && rm -rf "$leftover"
    done
    # Unix `ln -snf` accepts a name-only or relative-path source even when the
    # target doesn't resolve from CWD (e.g. the connect-chrome alias points at
    # the sibling-relative "gstack/open-gstack-browser"). On Windows the
    # equivalent semantics don't exist — we'd need a real source on disk to
    # copy. Skip the alias quietly rather than aborting setup under `set -e`.
    if [ ! -e "$src" ]; then
      return 0
    fi
    new="$dst.gstack-new.$$"
    old="$dst.gstack-old.$$"
    if [ -d "$src" ]; then
      cp -R "$src" "$new" || { rm -rf "$new"; return 1; }
    else
      cp -f "$src" "$new" || { rm -f "$new"; return 1; }
    fi
    if [ -e "$dst" ] || [ -L "$dst" ]; then
      mv "$dst" "$old" || { rm -rf "$new"; return 1; }
    fi
    if ! mv "$new" "$dst"; then
      [ -e "$old" ] && mv "$old" "$dst"
      rm -rf "$new"
      return 1
    fi
    rm -rf "$old"
  else
    ln -snf "$src" "$dst"
  fi
}

# _link_runtime_dists SOURCE ROOT — the compiled design and make-pdf binaries
# behind $GSTACK_DESIGN and $GSTACK_MAKE_PDF on every env-var host (#2891),
# and freeze/bin (with the careful/bin helper it sources), whose state and
# check scripts /freeze, /guard, /unfreeze and /investigate run as
# "$GSTACK_ROOT/freeze/bin/...". Subdirectories only, never the whole skill
# directory: make-pdf/, freeze/ and careful/ ship a Claude SKILL.md a host
# scanning its skills root would discover as a duplicate skill.
_link_runtime_dists() {
  local d
  # Record the source checkout (#3026): on Windows the root's browse/dist is a
  # copy with no node_modules beside it, and the browse CLI runs the checkout's
  # Node server bundle from here instead. /gstack-upgrade reads it too.
  if [ "$IS_WINDOWS" -eq 1 ] && command -v cygpath >/dev/null 2>&1; then
    cygpath -m "$1" > "$2/.source-path"
  else
    printf '%s\n' "$1" > "$2/.source-path"
  fi
  for d in design/dist make-pdf/dist freeze/bin careful/bin; do
    [ -d "$1/$d" ] || continue
    if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$2/$d" ] || [ ! -e "$2/$d" ]; then
      mkdir -p "$2/${d%/*}"
      _link_or_copy "$1/$d" "$2/$d"
    fi
  done
}

# ─── Real-file SKILL.md copies in host runtime roots (C3, #333) ──────────────
# Codex skips a symlinked SKILL.md file, so the router and the nested SKILL.md
# files in every host runtime root are real copies, rewritten on every run.
# setup records the hash of each copy it writes in $GSTACK_STATE_ROOT/
# skill-copies.tsv (hash<TAB>path); a copy whose bytes no longer match was
# edited by hand and is saved to a uniquely named backup under the state root
# before the tree is replaced, so the backup outlives the old tree.
_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"
_copy_skill_md() {
  rm -f "$2" && cp "$1" "$2"
}
_skill_copy_hash() {
  git hash-object "$1" 2>/dev/null
}
# _skill_copy_unmodified FILE — FILE is a copy setup wrote and nobody edited.
_skill_copy_unmodified() {
  local h
  [ -f "$1" ] && [ ! -L "$1" ] && [ -f "$_SKILL_COPIES_FILE" ] || return 1
  h="$(_skill_copy_hash "$1")" || return 1
  grep -qxF "$h	$1" "$_SKILL_COPIES_FILE"
}
# _preserve_skill_copy_edits ROOT — back up every recorded copy under ROOT
# whose bytes changed. Non-zero when a backup failed: the caller must then
# keep the old tree (a failed backup is never a license to overwrite).
_preserve_skill_copy_edits() {
  local root="$1" hash path dir=""
  [ -f "$_SKILL_COPIES_FILE" ] || return 0
  while IFS='	' read -r hash path; do
    case "$path" in "$root"/*) ;; *) continue ;; esac
    { [ -f "$path" ] && [ ! -L "$path" ]; } || continue
    [ "$(_skill_copy_hash "$path")" = "$hash" ] && continue
    if [ -z "$dir" ]; then
      mkdir -p "$GSTACK_STATE_ROOT/backups/skill-copies" || return 1
      dir="$(mktemp -d "$GSTACK_STATE_ROOT/backups/skill-copies/$(date +%Y%m%dT%H%M%S)-XXXXXX")" || return 1
    fi
    mkdir -p "$dir/$(dirname "${path#/}")" && cp -p "$path" "$dir/${path#/}" || return 1
    echo "  saved your edited $path to $dir/${path#/} (setup rewrites gstack's SKILL.md copies on every run)"
  done < "$_SKILL_COPIES_FILE"
}
# _record_skill_copies ROOT — replace ROOT's rows with the real SKILL.md files
# now under it (find does not descend into linked directories). Sets
# _COPIES_REFRESHED and _COPIES_REMOVED (paths relative to ROOT).
_record_skill_copies() {
  local root="$1" f tmp old="" new
  _COPIES_REFRESHED="" _COPIES_REMOVED=""
  if [ -f "$_SKILL_COPIES_FILE" ]; then
    old="$(awk -F'\t' -v r="$root/" 'index($2, r) == 1 { print substr($2, length(r) + 1) }' "$_SKILL_COPIES_FILE" | sort)"
  fi
  new="$(cd "$root" 2>/dev/null && find . -name SKILL.md -type f | sed 's|^\./||' | sort)"
  mkdir -p "$GSTACK_STATE_ROOT" || return 1
  tmp="$_SKILL_COPIES_FILE.tmp.$$"
  {
    [ -f "$_SKILL_COPIES_FILE" ] && awk -F'\t' -v r="$root/" 'index($2, r) != 1' "$_SKILL_COPIES_FILE"
    for f in $new; do printf '%s\t%s\n' "$(_skill_copy_hash "$root/$f")" "$root/$f"; done
  } > "$tmp" && mv -f "$tmp" "$_SKILL_COPIES_FILE" || { rm -f "$tmp"; return 1; }
  _COPIES_REFRESHED="$(printf '%s\n' "$new" | tr '\n' ' ' | sed 's/ *$//')"
  _COPIES_REMOVED="$(printf '%s\n' "$old" | grep -vxF -f <(printf '%s\n' "$new") | tr '\n' ' ' | sed 's/ *$//')"
}

# ─── Ownership gate for skill entries (#2119) ─────────────────────────────────
# setup and gstack-relink must never delete or link over a skill they do not
# own. This is the single rule both use (relink carries the same logic — keep
# them in sync until the shared helper filed in TODOS.md lands). Proof has two
# strengths: STRONG (a symlink resolving into gstack, or the .gstack-owned
# marker) means we created the entry and may delete or refresh it whole; WEAK
# (byte-identity with our source, or the two-line generated banner on a real
# file) covers only that SKILL.md — never the directory — and a differing
# weakly-proven file is moved to $GSTACK_STATE_ROOT/backups/skills/<ts>/
# before we install over it. An entry is OURS
# when: it is a symlink resolving into the gstack payload / render dir (or any
# path with a `gstack` segment, the convention cleanup and gstack-uninstall
# already use, so entries from a sibling worktree still count), a real dir
# whose SKILL.md is such a symlink, or a real-file copy proven by the
# .gstack-owned marker, byte-identity with the source, or gen-skill-docs'
# generated header. Anything else is FOREIGN: skipped, reported, listed in
# the final summary.
_FOREIGN_SKIPPED_ENTRIES=()
_gstack_link_target_abs() {
  # readlink of a relative link is relative to the link's directory; anchor it
  # there and canonicalize the directory part (`..`, symlinked components).
  local link="$1" dest d b d_real
  dest="$(readlink "$link" 2>/dev/null || true)"
  [ -n "$dest" ] || return 1
  case "$dest" in /*) ;; *) dest="$(dirname "$link")/$dest" ;; esac
  d="${dest%/*}"; b="${dest##*/}"
  if d_real="$(cd "$d" 2>/dev/null && pwd -P)"; then printf '%s\n' "$d_real/$b"; else printf '%s\n' "$dest"; fi
}
_gstack_target_is_ours() {
  # $1 = absolute target path, $2 = gstack payload dir
  local t="$1" g="$2" g_real render render_real
  g_real="$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")"
  render="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
  render_real="$(cd "$render" 2>/dev/null && pwd -P || printf '%s' "$render")"
  case "$t" in
    "$g"/*|"$g_real"/*|"$render"/*|"$render_real"/*|gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;;
  esac
  # A checkout named without a `gstack` segment (git worktree add
  # ../gstack-<branch>, a ZIP unpacked as gstack-main): the target's skill
  # root is a gstack tree if it carries setup + VERSION + bin/gstack-relink
  # (a hand-written skill repo with a VERSION file and a setup script does not).
  local root="${t%/*/SKILL.md}"
  if [ "$root" != "$t" ] && [ -f "$root/VERSION" ] && [ -f "$root/setup" ] && [ -f "$root/bin/gstack-relink" ]; then return 0; fi
  return 1
}
_claude_entry_is_ours() {
  # $1 = existing entry (dir or symlink), $2 = the gstack source SKILL.md it
  # would be linked to, $3 = gstack payload dir
  local entry="$1" src_md="$2" g="$3" render_md
  _claude_entry_owned_strongly "$entry" "$g" && return 0
  # A symlink that did not resolve into gstack is someone else's; never follow
  # it into the "unclaimed directory" rule below.
  [ -L "$entry" ] && return 1
  # No SKILL.md at all: an UNCLAIMED directory (a weak cleanup left the user's
  # other files behind, or it was never a skill). Adding our SKILL.md
  # overwrites nothing, so installing into it is allowed; the cleanup arms
  # require a SKILL.md and so never touch it.
  if [ -d "$entry" ] && [ ! -e "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ]; then return 0; fi
  if [ -d "$entry" ] && [ -f "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ]; then
    [ -n "$src_md" ] && [ -f "$src_md" ] && cmp -s "$entry/SKILL.md" "$src_md" && return 0
    # A gbrain install serves the RENDERED file (link_claude_skill_dirs prefers
    # it), so an exact copy of that render is ours too.
    if [ -n "$src_md" ]; then
      render_md="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md"
      [ -f "$render_md" ] && cmp -s "$entry/SKILL.md" "$render_md" && return 0
    fi
    _gstack_generated_header "$entry/SKILL.md" && return 0
  fi
  return 1
}
# _claude_entry_owned_strongly ENTRY GSTACK_DIR — we created it: a symlink into
# gstack, or a real dir with the .gstack-owned marker or a SKILL.md symlink
# into gstack. Only strong proof authorizes deleting a directory whole.
_claude_entry_owned_strongly() {
  local entry="$1" g="$2" dest
  if [ -L "$entry" ]; then
    dest="$(_gstack_link_target_abs "$entry")" || return 1
    _gstack_target_is_ours "$dest" "$g"; return $?
  fi
  [ -d "$entry" ] || return 1
  [ -f "$entry/.gstack-owned" ] && return 0
  if [ -L "$entry/SKILL.md" ]; then
    dest="$(_gstack_link_target_abs "$entry/SKILL.md")" || return 1
    _gstack_target_is_ours "$dest" "$g"; return $?
  fi
  return 1
}
# Weakly-proven real files we would otherwise overwrite are moved here (mv, so
# the path is free for the link); the final summary prints one line.
_SKILL_BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)"
_BACKED_UP_SKILL_MDS=()
_backup_skill_md() {
  # Returns non-zero when the file could NOT be moved: the caller must then
  # leave the entry untouched (a failed backup is never a license to overwrite).
  local file="$1" name="$2"
  mkdir -p "$_SKILL_BACKUP_ROOT/$name" 2>/dev/null || return 1
  mv -f "$file" "$_SKILL_BACKUP_ROOT/$name/SKILL.md" 2>/dev/null || return 1
  _BACKED_UP_SKILL_MDS+=("$name")
  return 0
}
# _cleanup_weak_dir DIR — remove only what weak proof covers: the SKILL.md and
# our marker. User files in the directory stay, and so does the directory
# when it is not empty afterwards.
# _cleanup_weak_dir DIR GSTACK_DIR [SRC_SKILL_MD NAME] — remove only what weak
# proof covers. A real SKILL.md that differs from our source (raw, or with its
# name: line rewritten to NAME, which is how alias and prefixed copies differ)
# is a customized file: it is moved to the backup root, never deleted, and if
# the backup fails it stays. Our runtime-asset links go; the user's files stay.
_cleanup_weak_dir() {
  local d="$1" g="$2" src="${3:-}" name="${4:-${1##*/}}" e dest
  if [ -f "$d/SKILL.md" ] && [ ! -L "$d/SKILL.md" ] && [ -n "$src" ] && [ -f "$src" ] \
     && ! cmp -s "$d/SKILL.md" "$src" \
     && ! sed "1,/^---\$/ s/^name:[[:space:]].*/name: $name/" "$src" | cmp -s - "$d/SKILL.md"; then
    if ! _backup_skill_md "$d/SKILL.md" "$name"; then
      echo "  kept $name/SKILL.md: could not back up the customized file — left untouched" >&2
      return 0
    fi
  else
    rm -f "$d/SKILL.md"
  fi
  rm -f "$d/.gstack-owned"
  for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do
    [ -L "$e" ] || continue
    dest="$(_gstack_link_target_abs "$e")" || continue
    if _gstack_target_is_ours "$dest" "$g"; then rm -f "$e"; fi
  done
  rmdir "$d" 2>/dev/null || echo "  cleaned ${d##*/}/SKILL.md (other files in that directory were left in place)"
}
# _gstack_dir_only_links DIR GSTACK_DIR — true when deleting DIR whole loses
# nothing of the user's: every entry is a symlink resolving into gstack, or
# our marker. A user's own link (notes.md -> ~/notes) makes the dir mixed.
_gstack_dir_only_links() {
  local d="$1" g="$2" e dest
  for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do
    { [ -e "$e" ] || [ -L "$e" ]; } || continue
    [ "${e##*/}" = ".gstack-owned" ] && continue
    [ -L "$e" ] || return 1
    dest="$(_gstack_link_target_abs "$e")" || return 1
    _gstack_target_is_ours "$dest" "$g" || return 1
  done
  return 0
}
# _cleanup_linked_dir DIR GSTACK_DIR — a real dir whose SKILL.md is a symlink
# into gstack. Whole-directory removal needs the marker (we created it) or a
# directory holding nothing but our links; otherwise only our files go.
_cleanup_linked_dir() {
  if [ -f "$1/.gstack-owned" ] || _gstack_dir_only_links "$1" "$2"; then rm -rf "$1"; else _cleanup_weak_dir "$1" "$2"; fi
}
# _gstack_generated_header FILE — a pre-marker legacy COPY (Windows, before
# .gstack-owned existed) is recognized by gen-skill-docs' full two-line banner
# near the top, not by a one-line substring another generator could plausibly
# emit. Still forgeable by a gstack fork that renders the same banner — that
# residual is accepted and filed; the marker is the load-bearing signal.
_gstack_generated_header() {
  # Bytes, not lines: a long frontmatter pushes the banner past line 40 in
  # four real skills (investigate: line 57), and a line-count check left them
  # "foreign" on every pre-marker Windows install.
  local f="$1" head40
  head40="$(head -c 8192 "$f" 2>/dev/null)" || return 1
  case "$head40" in
    *'<!-- AUTO-GENERATED from '*'<!-- Regenerate: bun run gen:skill-docs -->'*) return 0 ;;
  esac
  return 1
}
_write_owned_marker() {
  # Windows copy installs have no symlink to readlink; the marker proves
  # provenance. Records the owning payload's real path for forensics.
  local dir="$1" g="$2"
  printf '%s\n' "$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")" > "$dir/.gstack-owned" 2>/dev/null || true
}

# ─── Ownership gates for the Windows refresh bypass (#2444 → #2142) ─────────
# On Windows a refresh means rm -rf + re-copy (_link_or_copy). The host
# skills dirs are SHARED namespaces (~/.codex/skills, ~/.factory/skills,
# ~/.cursor/skills, ...), so a gstack* glob name can collide with a user's
# OWN real directory (e.g. ~/.cursor/skills/gstack-notes) — deleting it on
# every ./setup re-run is silent data loss. Mirror of bin/gstack-uninstall's
# provenance gate (#2563): an existing REAL skill dir may only be replaced
# when its SKILL.md carries the generated banner. Missing targets and
# symlinks always pass (replacing a link never destroys content); non-dir
# targets pass (file targets live inside gstack-owned roots).
_owned_for_windows_refresh() {
  local dst="$1"
  if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then return 0; fi
  if [ -L "$dst" ]; then return 0; fi
  if [ ! -d "$dst" ]; then return 0; fi
  grep -q '<!-- AUTO-GENERATED from' "$dst/SKILL.md" 2>/dev/null
}

# ─── Helper: prune renders of skills whose source is gone ────────────────────
# gen-skill-docs prunes its own render tree (.agents/.factory/.opencode/.cursor
# skills) at the end of every run; this helper covers what the generator cannot
# reach: the HOST skills dirs that link to or copy those renders, and a render
# tree left behind by an older generator. Candidates are gstack-* entries in the
# render tree AND in each host dir, so a host entry is cleaned even when the
# generator already removed its render.
# $1 = install root, $2 = render tree, $3.. = host skills dirs (optional).
# Ownership (#2119): a real render dir goes; a host symlink goes only when it
# RESOLVES into gstack; a bannered REAL host dir is cleaned through
# _cleanup_weak_dir (our SKILL.md, marker and links only) rather than deleted
# whole; anything else is a user's own entry and is left alone. Symlinks in the
# render tree are skipped: `rm -rf` on a slash-terminated link empties its
# TARGET. Pinned by test/setup-prune-stale-generated.test.ts.
_skill_source_exists() {
  # $1 = install root, $2 = rendered name (gstack-<x>). gen-skill-docs names a
  # render from the template's frontmatter `name:` when that differs from the
  # directory, so both the directory and every frontmatter name count.
  local g="$1" n="$2" base="${2#gstack-}"
  [ -f "$g/$base/SKILL.md.tmpl" ] && return 0
  [ -f "$g/$n/SKILL.md.tmpl" ] && return 0
  grep -qsE "^name:[[:space:]]*(gstack-)?${base}[[:space:]]*$" "$g"/*/SKILL.md.tmpl 2>/dev/null && return 0
  return 1
}
_prune_stale_generated() {
  local gstack_dir="$1" gen_dir="$2" d n names="" host dest gen_real
  shift 2
  for d in "$gen_dir"/gstack-*; do
    [ -d "$d" ] && [ ! -L "$d" ] || continue
    names="$names ${d##*/}"
  done
  for host in "$@"; do
    [ -n "$host" ] && [ -d "$host" ] || continue
    for d in "$host"/gstack-*; do
      { [ -e "$d" ] || [ -L "$d" ]; } || continue
      names="$names ${d##*/}"
    done
  done
  [ -n "$names" ] || return 0
  for n in $(printf '%s\n' $names | sort -u); do
    # The rename helper owns replacement-before-retirement, including failures.
    if [ "$n" = "gstack-claude" ] && [ "${GSTACK_DEFER_CLAUDE_RENAME_PRUNE:-0}" = "1" ]; then continue; fi
    _skill_source_exists "$gstack_dir" "$n" && continue
    if [ -d "$gen_dir/$n" ] && [ ! -L "$gen_dir/$n" ]; then rm -rf "$gen_dir/$n"; fi
    for host in "$@"; do
      [ -n "$host" ] && { [ -e "$host/$n" ] || [ -L "$host/$n" ]; } || continue
      _owned_for_windows_refresh "$host/$n" || continue
      if [ -L "$host/$n" ]; then
        # Strong proof only when the link resolves into gstack: the render tree
        # we were handed (a dangling link into it still names that path) or
        # any gstack path per _gstack_target_is_ours.
        dest="$(_gstack_link_target_abs "$host/$n")" || continue
        gen_real="$(cd "$gen_dir" 2>/dev/null && pwd -P || printf '%s' "$gen_dir")"
        case "$dest" in
          "$gen_dir"/*|"$gen_real"/*) rm -f "$host/$n" ;;
          *) _gstack_target_is_ours "$dest" "$gstack_dir" && rm -f "$host/$n" ;;
        esac
      elif [ ! -d "$host/$n" ]; then
        rm -f "$host/$n"
      else
        _cleanup_weak_dir "$host/$n" "$gstack_dir"
      fi
    done
    log "  pruned retired skill: $n"
  done
}

# A sidecar/runtime ROOT (…/skills/gstack) is provably USER-owned when it is
# a real dir whose SKILL.md exists but lacks the generated banner — a
# hand-written skill squatting on the canonical name. The sidecar installers
# skip it entirely rather than write into (or wipe) someone else's skill.
# A root with NO SKILL.md stays presumed ours: it is the documented gstack
# install location and old/partial installs legitimately look like that.
_sidecar_root_user_owned() {
  local root="$1"
  [ -d "$root" ] || return 1
  [ -L "$root" ] && return 1
  [ -f "$root/SKILL.md" ] || return 1
  ! grep -q '<!-- AUTO-GENERATED from' "$root/SKILL.md" 2>/dev/null
}

# Swap a freshly-rendered tmp dir into the live render location (#2569
# hardening). Installed skills SYMLINK into the live dir, so it is only ever
# replaced AFTER a successful render — a failed render leaves the previous
# render (and every link into it) fully intact. Keep in sync with
# bin/gstack-config's _swap_in_render (same contract, both pinned by
# test/user-render-out-dir-install.test.ts).
_swap_in_render() {
  local render_dir="$1" render_tmp="$2"
  local render_old="$render_dir.old.$$"
  rm -rf "$render_old"
  if [ -e "$render_dir" ] || [ -L "$render_dir" ]; then mv "$render_dir" "$render_old"; fi
  mv "$render_tmp" "$render_dir"
  rm -rf "$render_old"
}

# ─── Per-install render (install-context render contract, #1882) ────────────
# Committed skills call the host's default install (~/.claude/skills/gstack,
# ~/.codex/skills/gstack). An install anywhere else (a renamed checkout, a
# project-vendored Claude copy, CLAUDE_CONFIG_DIR, CODEX_HOME) serves skills
# rendered for ITS root, out of tree in $GSTACK_STATE_ROOT/render/installs/
# <host>-<id>/, so each skill's literal paths call this install and two
# installs never overwrite each other. Built in a temp dir, validated, swapped
# in; callers link to it only after success (a failed render keeps the previous
# one). Worktree-isolated Claude Code refuses a command path containing
# whitespace or shell metacharacters in any quoting, so such a root is named
# through a plain alias symlink beside the render.
# _render_install HOST ROOT — sets _INSTALL_RENDER (empty = committed render).
_INSTALL_RENDER=""
_render_install() {
  local host="$1" root="$2" default render named tmp out rc want got
  _INSTALL_RENDER=""
  case "$host" in
    claude) default="$HOME/.claude/skills/gstack" ;;
    codex) default="$HOME/.codex/skills/gstack" ;;
    *) return 0 ;;
  esac
  [ "$root" = "$default" ] && return 0
  # The default reached through another spelling (a symlinked parent) is the default.
  got="$(cd "$(dirname "$root")" 2>/dev/null && pwd -P)" || got=""
  [ -n "$got" ] && [ "$(basename "$root")" = gstack ] \
    && [ "$got" = "$(cd "$(dirname "$default")" 2>/dev/null && pwd -P)" ] && return 0
  render="$(gstack_install_render_dir "$host" "$root")"
  named="$root"
  if ! printf '%s' "$named" | grep -Eq '^/[A-Za-z0-9_.@+/-]*$'; then
    named="$render.root"
    if [ "$IS_WINDOWS" -eq 1 ] || ! printf '%s' "$named" | grep -Eq '^/[A-Za-z0-9_.@+/-]*$'; then
      echo "  warning: the $host install root ($root) is not a plain path and no plain alias is available here (state root: $GSTACK_STATE_ROOT); its skills keep the default-install paths" >&2
      return 1
    fi
    mkdir -p "$(dirname "$named")" && _link_or_copy "$root" "$named" || return 1
  fi
  tmp="$render.tmp.$$"
  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" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} 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; } \
      || { rc=1; out="render incomplete: $got of $want skills, or a start line that does not name $named"; }
    [ "$rc" -ne 0 ] || "$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$tmp" "$SKILL_PREFIX" >/dev/null || rc=$?
  else
    out="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" --install-root "$named" --out-dir "$tmp" --link-root "$render" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} 2>&1)" || rc=$?
    want="$(find "$SOURCE_GSTACK_DIR/.agents/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l)"
    got="$(find "$tmp/.agents/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l)"
    [ "$rc" -ne 0 ] || { [ "$got" -gt 0 ] && { [ "$want" -eq 0 ] || [ "$want" -eq "$got" ]; } && grep -qF "GSTACK_ROOT=\"$named\"" "$tmp/.agents/skills/gstack-review/SKILL.md" 2>/dev/null; } \
      || { rc=1; out="render incomplete: $got of $want skills, or a runtime root that does not name $named"; }
  fi
  if [ "$rc" -ne 0 ]; then
    rm -rf "$tmp"
    echo "  warning: could not render $host skills for the install at $root (exit $rc); $( [ -d "$render" ] && echo 'the previous render stays in use' || echo 'they keep the default-install paths')" >&2
    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"
}
# Claude: the arm serves the render through GSTACK_USER_RENDER_DIR, which
# linking, relink and the ownership checks read.
_CLAUDE_INSTALL_RENDER=""
_render_claude_install() {
  _CLAUDE_INSTALL_RENDER=""
  _render_install claude "$1" || return 1
  [ -n "$_INSTALL_RENDER" ] || return 0
  _CLAUDE_INSTALL_RENDER="$_INSTALL_RENDER"
  export GSTACK_USER_RENDER_DIR="$_INSTALL_RENDER"
}

_WINDOWS_COPY_NOTE_PRINTED=0
_print_windows_copy_note_once() {
  if [ "$IS_WINDOWS" -eq 1 ] && [ "$_WINDOWS_COPY_NOTE_PRINTED" -eq 0 ]; then
    echo "  note: Windows install uses file copies (no Developer Mode required). Re-run ./setup after every 'git pull' to refresh skill files."
    _WINDOWS_COPY_NOTE_PRINTED=1
  fi
}


# ─── Install rows: registry publication + one summary row per install ───────
# Rows publish to the registry only after a host arm finished activating
# (bin/gstack-install-registry.sh). The summary uses the same renderer as
# ./setup --status and /gstack-upgrade. A setup that dies inside a host arm
# reports that host as failed (its previous install is left in place) with the
# exact retry command, from the EXIT trap.
_SETUP_ROWS=""
_SETUP_ARM=""
_SETUP_SUMMARY_DONE=0
_setup_row() {
  _SETUP_ROWS="$_SETUP_ROWS$1	$2	$SOURCE_GSTACK_DIR	$3	$4	$5	$6	${7:-}
"
}
_setup_retry_command() {
  printf 'cd %s && ./setup --host %s' "$SOURCE_GSTACK_DIR" "$1"
}
_setup_arm_begin() { _SETUP_ARM="$1	$2	$3"; }
_setup_arm_publish() {
  local host="$1" scope="$2" project="$3" dest="$4" root="$5" prefix="$6" render="${7:-committed}" old
  old="$(gstack_install_registry_rows | awk -F '\t' -v h="$host" -v d="$dest" '$1 == h && $4 == d { v = $7 } END { print v }')"
  _SETUP_ARM=""
  if ! gstack_install_registry_upsert "$host" "$scope" "$project" "$dest" "$root" "$SOURCE_GSTACK_DIR" "$SETUP_VERSION" "$prefix" "$render"; then
    _setup_row "$host" "$scope" "$dest" "${old:--}" "$SETUP_VERSION" failed "installed, but the registry write failed, so upgrades will not refresh it; retry: $(_setup_retry_command "$host")"
    return 0
  fi
  if [ -z "$old" ]; then
    _setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" installed
  elif [ "$old" = "$SETUP_VERSION" ]; then
    _setup_row "$host" "$scope" "$dest" "$old" "$SETUP_VERSION" unchanged
  else
    _setup_row "$host" "$scope" "$dest" "$old" "$SETUP_VERSION" updated
  fi
}
_setup_print_summary() {
  [ "$_SETUP_SUMMARY_DONE" -eq 0 ] || return 0
  _SETUP_SUMMARY_DONE=1
  [ -n "$_SETUP_ROWS" ] || return 0
  if [ -n "${GSTACK_SETUP_SUMMARY_FILE:-}" ]; then
    printf '%s' "$_SETUP_ROWS" >> "$GSTACK_SETUP_SUMMARY_FILE"
    return 0
  fi
  if [ "$QUIET" -eq 1 ]; then
    case "$_SETUP_ROWS" in *"	failed	"*) ;; *) return 0 ;; esac
  fi
  echo ""
  echo "Install summary:"
  printf '%s' "$_SETUP_ROWS" | gstack_install_registry_render
  if [ "$_DISABLED_SKILLS" != " " ] && [ -n "${_DISABLED_SKILLS:-}" ]; then
    echo "  disabled skills (not registered on any host):$_DISABLED_SKILLS"
    echo "    re-enable: gstack-config set disabled_skills \"\""
  fi
}
_setup_exit_summary() {
  local rc="$1" host scope dest
  [ "$_SETUP_SUMMARY_DONE" -eq 0 ] || return 0
  if [ "$rc" -ne 0 ] && [ -n "$_SETUP_ARM" ]; then
    IFS='	' read -r host scope dest <<EOF
$_SETUP_ARM
EOF
    _setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" failed "setup stopped (exit $rc) while installing $host; its previous install was left in place. Retry: $(_setup_retry_command "$host")"
  fi
  [ "$rc" -ne 0 ] || [ -n "$_SETUP_ROWS" ] || return 0
  _setup_print_summary >&2
}

# Aside (aside.com, macOS 15+) is the primary browser; the compiled browse
# binary is the fallback. Best-effort hint only — no probe of a running app.
# Reads _PW_FAIL_REASON (the best-effort Chromium bootstrap in # 2 records why
# the bundled browser is unusable) so the line never promises a fallback
# browser that cannot launch. GSTACK_SKIP_ASIDE=1 (the library's and the
# skills' opt-out) counts as Aside absent. Pinned by test/setup-browser-hint.test.ts.
_browser_hint() {
  if [ "${GSTACK_SKIP_ASIDE:-}" != "1" ] && command -v aside >/dev/null 2>&1; then
    if [ -n "${_PW_FAIL_REASON:-}" ]; then
      log "  browser: Aside (primary) — gstack browser fallback unavailable (Chromium bootstrap: ${_PW_FAIL_REASON})"
    else
      log "  browser: Aside (primary) — gstack browser is the fallback"
    fi
  elif [ "${_PW_FAIL_REASON:-}" = "skipped" ]; then
    # An explicit opt-out (GSTACK_SKIP_PLAYWRIGHT=1) is a request, not a failure — same wording as the summary.
    log "  browser: none available — Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1); install Aside (aside.com, macOS 15+) or re-run ./setup without the flag"
  elif [ -n "${_PW_FAIL_REASON:-}" ]; then
    log "  browser: none available — Chromium bootstrap: ${_PW_FAIL_REASON}; install Aside (aside.com, macOS 15+) or fix the bootstrap and re-run ./setup"
  else
    log "  browser: gstack browser (fallback). Install Aside for the primary path: aside.com (macOS 15+)"
  fi
}

_CODEX_PREFLIGHT_SELECTED=0
if [ "$LOCAL_INSTALL" -eq 0 ]; then
  if [ "$HOST" = codex ] || { [ "$HOST" = auto ] && command -v codex >/dev/null 2>&1; }; then
    _CODEX_PREFLIGHT_SELECTED=1
  fi
fi
bun "$SOURCE_GSTACK_DIR/scripts/preflight-codex-overlap.ts" \
  --source "$SOURCE_GSTACK_DIR" --namespace "$CODEX_SKILLS" \
  --selected "$_CODEX_PREFLIGHT_SELECTED" --local "$CODEX_REPO_LOCAL" \
  --windows "$IS_WINDOWS" --relocation "$HOME/.gstack/repos/gstack" || exit 1

# ─── Resolve skill prefix preference ─────────────────────────
# Priority: CLI flag > saved config > interactive prompt (or flat default for non-TTY)
GSTACK_CONFIG="$SOURCE_GSTACK_DIR/bin/gstack-config"
export GSTACK_SETUP_RUNNING=1  # Prevent gstack-config post-set hook from triggering relink mid-setup
if [ "$SKILL_PREFIX_FLAG" -eq 0 ]; then
  _saved_prefix="$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || true)"
  if [ "$_saved_prefix" = "true" ]; then
    SKILL_PREFIX=1
  elif [ "$_saved_prefix" = "false" ]; then
    SKILL_PREFIX=0
  else
    # No saved preference — prompt interactively (or default flat for non-TTY/quiet)
    if [ "$QUIET" -eq 1 ]; then
      SKILL_PREFIX=0
    elif [ -t 0 ]; then
      echo ""
      echo "Skill naming: how should gstack skills appear?"
      echo ""
      echo "  1) Short names: /qa, /ship, /review"
      echo "     Recommended. Clean and fast to type."
      echo ""
      echo "  2) Namespaced: /gstack-qa, /gstack-ship, /gstack-review"
      echo "     Use this if you run other skill packs alongside gstack to avoid conflicts."
      echo ""
      printf "Choice [1/2] (default: 1, auto-selects in 10s): "
      read -t 10 -r _prefix_choice </dev/tty 2>/dev/null || _prefix_choice=""
      case "$_prefix_choice" in
        2) SKILL_PREFIX=1 ;;
        *) SKILL_PREFIX=0 ;;
      esac
    else
      SKILL_PREFIX=0
    fi
    # Save the choice for future runs
    "$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true
  fi
else
  # Flag was passed explicitly — persist the choice
  "$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true
fi

# ─── disabled_skills (gstack-config set disabled_skills a,b) ─────────────────
# A disabled skill gets no discovery entry on any host (an existing gstack-owned
# entry is removed); its runtime files in the checkout stay, so skills that call
# it keep working. Missing or empty = every skill enabled (#1206). Linkers test
# membership inline with "${_DISABLED_SKILLS:- }", so a linker extracted on its
# own (tests) behaves as "nothing disabled".
_DISABLED_SKILLS="$(gstack_disabled_normalize "$("$GSTACK_CONFIG" get disabled_skills 2>/dev/null || true)")"
# Every render of the router (committed-path renders of env-var hosts and the
# per-install renders) omits disabled skills (C8); the default Claude router is
# rendered in place by gstack-relink.
_DISABLED_CSV="$(printf '%s' "$_DISABLED_SKILLS" | xargs | tr ' ' ',')"
# _remove_disabled_host_entry ENTRY — drop a non-Claude host's gstack-owned
# entry for a disabled skill (a link, or a generated copy); anything else stays.
_remove_disabled_host_entry() {
  { [ -e "$1" ] || [ -L "$1" ]; } || return 0
  if [ -L "$1" ] || { [ -d "$1" ] && _owned_for_windows_refresh "$1"; }; then
    rm -rf "$1"
  else
    echo "  kept $1: disabled in gstack config, but not gstack-managed" >&2
  fi
}

# --local: install to .claude/skills/ in the current working directory (deprecated)
if [ "$LOCAL_INSTALL" -eq 1 ]; then
  echo "Warning: --local is deprecated. Use global install + --team instead." >&2
  echo "  See: https://github.com/garrytan/gstack#team-mode" >&2
  if [ "$HOST" = "codex" ] || [ "$HOST" = "copilot" ]; then
    echo "Error: --local is only supported for Claude Code (not $HOST)." >&2
    exit 1
  fi
  INSTALL_SKILLS_DIR="$(pwd)/.claude/skills"
  mkdir -p "$INSTALL_SKILLS_DIR"
  HOST="claude"
  INSTALL_CODEX=0
fi

# For auto: detect which agents are installed
INSTALL_CLAUDE=0
INSTALL_CODEX=0
INSTALL_KIRO=0
INSTALL_FACTORY=0
INSTALL_OPENCODE=0
INSTALL_CURSOR=0
INSTALL_COPILOT=0
if [ "$HOST" = "auto" ]; then
  command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
  command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
  command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1
  command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1
  command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1
  # Cursor's `cursor` CLI shim isn't always on PATH; ~/.cursor is the
  # reliable footprint of an installed Cursor IDE.
  command -v cursor >/dev/null 2>&1 && INSTALL_CURSOR=1
  [ -d "$HOME/.cursor" ] && INSTALL_CURSOR=1
  command -v copilot >/dev/null 2>&1 && INSTALL_COPILOT=1
  # If none found, default to claude
  if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ]; then
    INSTALL_CLAUDE=1
  fi
elif [ "$HOST" = "claude" ]; then
  INSTALL_CLAUDE=1
elif [ "$HOST" = "codex" ]; then
  INSTALL_CODEX=1
elif [ "$HOST" = "kiro" ]; then
  INSTALL_KIRO=1
elif [ "$HOST" = "factory" ]; then
  INSTALL_FACTORY=1
elif [ "$HOST" = "opencode" ]; then
  INSTALL_OPENCODE=1
elif [ "$HOST" = "cursor" ]; then
  INSTALL_CURSOR=1
elif [ "$HOST" = "copilot" ]; then
  INSTALL_COPILOT=1
fi

# A host that passes --host validation but sets no INSTALL_* flag would
# silently configure nothing and exit 0 (the #2361 slate failure class).
# Fail loudly if a future host lands in the accept-list without a dispatch arm.
if [ "$HOST" != "auto" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ]; then
  echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor, copilot (informational: slate, openclaw, hermes, gbrain)." >&2
  exit 1
fi

# Copilot reads personal skills from $COPILOT_HOME/skills when it is set, but
# rendered skills use literal ~/.copilot paths. Refuse rather than install
# skills Copilot will never discover.
if [ "$INSTALL_COPILOT" -eq 1 ] && [ -n "${COPILOT_HOME:-}" ] && [ "${COPILOT_HOME%/}" != "$HOME/.copilot" ]; then
  echo "Error: COPILOT_HOME=$COPILOT_HOME is not supported yet — nothing was installed or changed. gstack installs Copilot skills to ~/.copilot/skills. Unset COPILOT_HOME for setup and Copilot sessions, or use ./setup --host claude." >&2
  exit 1
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). For the Claude overlay, use --claude-model <id>." >&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"
  local codex_gstack="$2"
  local migrated_dir="$HOME/.gstack/repos/gstack"

  [ -L "$codex_gstack" ] && return 0
  [ -d "$codex_gstack" ] && [ "$(cd "$codex_gstack" && pwd -P)" = "$gstack_dir" ] || return 0

  mkdir -p "$(dirname "$migrated_dir")"
  if [ -e "$migrated_dir" ] && [ "$migrated_dir" != "$gstack_dir" ]; then
    echo "gstack setup failed: direct Codex install detected at $gstack_dir" >&2
    echo "A migrated repo already exists at $migrated_dir; move one of them aside and rerun setup." >&2
    exit 1
  fi

  log "Migrating direct Codex install to $migrated_dir to avoid duplicate skill discovery..."
  mv "$gstack_dir" "$migrated_dir"
  SOURCE_GSTACK_DIR="$migrated_dir"
  INSTALL_GSTACK_DIR="$migrated_dir"
  INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")"
  BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"
  # Windows: binaries are compiled with .exe suffix (same as the top-level
  # BROWSE_BIN assignment — this re-derivation must not drop the suffix).
  if [ "$IS_WINDOWS" -eq 1 ]; then
    BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse.exe"
  fi
}

if [ "$INSTALL_CODEX" -eq 1 ] && [ "$CODEX_REPO_LOCAL" -eq 0 ]; then
  migrate_direct_codex_install "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK"
fi

SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")"
SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")"

# Kill an entire process tree rooted at $1, leaves first. Killing only the
# backgrounded subshell orphans the wedged node/bun -> Chromium probe
# processes underneath it — re-creating the #2136 stuck-process pile-up and
# potentially leaving Playwright cache locks held. macOS ships no setsid
# binary, so a portable group-kill isn't available; walk `pgrep -P` children
# depth-first instead (pgrep exists on macOS and Linux). Falls back to a
# /proc walk where available, otherwise a plain kill of the root pid.
_kill_tree() {
  local pid="$1" child stat_file stat_line proc_state proc_parent proc_rest
  if command -v pgrep >/dev/null 2>&1; then
    for child in $(pgrep -P "$pid" 2>/dev/null); do
      _kill_tree "$child"
    done
  elif [ -d /proc ]; then
    # debian-slim and git-bash ship no pgrep: walk /proc for children. The
    # comm field "(name)" may contain spaces and parens, so strip through the
    # LAST closing paren (proc(5)) before reading the ppid (second field after it).
    # A process may exit after glob expansion. One awk over every stat file
    # aborts at that missing entry and never visits the remaining live children.
    for stat_file in /proc/[0-9]*/stat; do
      IFS= read -r stat_line 2>/dev/null < "$stat_file" || continue
      child="${stat_line%% *}"
      IFS=' ' read -r proc_state proc_parent proc_rest <<< "${stat_line##*) }"
      if [ "$proc_parent" = "$pid" ]; then
        _kill_tree "$child"
      fi
    done
  fi
  kill -9 "$pid" 2>/dev/null || true
}

# Deadline-bounded wait for a background probe. macOS ships no GNU timeout;
# poll the PID and SIGKILL the whole probe tree past the deadline. Returns
# the probe's exit code, or 124 on timeout.
_wait_with_deadline() {
  local pid="$1" deadline_s="$2" waited=0
  while kill -0 "$pid" 2>/dev/null; do
    if [ "$waited" -ge "$deadline_s" ]; then
      _kill_tree "$pid"
      wait "$pid" 2>/dev/null || true
      return 124
    fi
    sleep 1
    waited=$((waited + 1))
  done
  wait "$pid"
}

ensure_playwright_browser() {
  # #2136: fresh installs hung forever at this probe (macOS arm64) and
  # re-runs stacked stuck process trees, so skills never got linked. Two
  # fixes: prefer Node for the launch probe everywhere it exists (the
  # bun --eval launch is the same pipe-bug family already worked around on
  # Windows), and bound the probe with a 90s deadline — a wedged probe now
  # reports failure (which routes to the install path) instead of hanging
  # setup.
  local probe_cmd
  if command -v node >/dev/null 2>&1; then
    probe_cmd='node -e "const { chromium } = require((process.cwd()) + \"/node_modules/playwright\"); (async () => { const b = await chromium.launch(); await b.close(); })().then(() => process.exit(0), error => { console.error(error.message); process.exit(1); })"'
  elif [ "$IS_WINDOWS" -eq 1 ]; then
    echo "gstack setup failed: Node.js is required on Windows" >&2
    return 1
  else
    probe_cmd="bun --eval 'import { chromium } from \"playwright\"; const browser = await chromium.launch(); await browser.close();'"
  fi
  (
    cd "$SOURCE_GSTACK_DIR"
    eval "$probe_cmd"
  ) >/dev/null &
  _wait_with_deadline $! 90
}

# P0 #2554: a macOS XProtect definition update can start SIGKILLing the
# Chromium revision the lockfile pins, which surfaces here as a failed launch
# probe. Clear com.apple.quarantine on the Playwright cache bundles ONLY —
# never a GSTACK_CHROMIUM_PATH bundle (that belongs to the wrapper/embedder;
# same scope contract as browse's probePoisonedChromiumBundle) — so the
# reinstall below produces a launchable browser. Best-effort and macOS-only.
_clear_playwright_quarantine() {
  [ "$(uname -s)" = "Darwin" ] || return 0
  local cache_root="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/Library/Caches/ms-playwright}"
  [ -d "$cache_root" ] || return 0
  local d
  for d in "$cache_root"/chromium-* "$cache_root"/chromium_headless_shell-*; do
    [ -d "$d" ] || continue
    echo "  clearing com.apple.quarantine on $(basename "$d") (XProtect self-heal, #2554)" >&2
    xattr -dr com.apple.quarantine "$d" 2>/dev/null || true
  done
}

# Ensure a color-emoji font is installed (Linux only).
#
# Chromium renders emoji code points as .notdef "tofu" (▯) when no color-emoji
# font is installed. macOS ships "Apple Color Emoji" and Windows ships "Segoe UI
# Emoji", so they're fine out of the box. Most Linux distros and containers ship
# NO color-emoji font, which is why make-pdf output shows tofu in headers/tables
# that contain emoji. Install Noto Color Emoji to fix it.
#
# Best-effort: warn (don't fail) if we can't install — PDFs still generate, they
# just fall back to tofu for emoji as before. Skip entirely with
# GSTACK_SKIP_FONTS=1 (CI without sudo, managed machines, offline envs).
#
# Returns 0 and sets EMOJI_FONT_INSTALLED=1 when it actually installs a font.
EMOJI_FONT_INSTALLED=0
ensure_emoji_font() {
  # macOS/Windows ship a color-emoji font; nothing to do.
  [ "$(uname -s)" = "Linux" ] || return 0
  [ "${GSTACK_SKIP_FONTS:-0}" = "1" ] && return 0

  # Idempotency: a real COLOR emoji font that resolves for an actual emoji code
  # point (U+1F600). `fc-list :lang=und-zsye` is too broad — it matches symbol
  # and last-resort fallback fonts — so we use fc-match and require color=True.
  if command -v fc-match >/dev/null 2>&1; then
    if fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' 2>/dev/null | grep -qi 'True'; then
      return 0
    fi
  fi

  local sudo=""
  if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
    # -n: never prompt. If a password is required we fail fast into the
    # warn-not-fail path below instead of hanging a non-interactive setup.
    sudo="sudo -n"
  fi

  # Every package-manager call is wrapped in `timeout` so a stuck dpkg/rpm lock
  # or a wedged mirror fails fast into the warn path instead of hanging setup.
  if command -v apt-get >/dev/null 2>&1; then
    echo "Installing color-emoji font (fonts-noto-color-emoji) so make-pdf emoji render (set GSTACK_SKIP_FONTS=1 to skip)..."
    DEBIAN_FRONTEND=noninteractive timeout 30 $sudo apt-get update -qq >/dev/null 2>&1 || true
    DEBIAN_FRONTEND=noninteractive timeout 120 $sudo apt-get install -y -qq fonts-noto-color-emoji >/dev/null 2>&1 || return 1
  elif command -v dnf >/dev/null 2>&1; then
    echo "Installing color-emoji font (google-noto-color-emoji-fonts)..."
    timeout 120 $sudo dnf install -y google-noto-color-emoji-fonts >/dev/null 2>&1 || return 1
  elif command -v pacman >/dev/null 2>&1; then
    echo "Installing color-emoji font (noto-fonts-emoji)..."
    timeout 120 $sudo pacman -Sy --noconfirm noto-fonts-emoji >/dev/null 2>&1 || return 1
  elif command -v apk >/dev/null 2>&1; then
    echo "Installing color-emoji font (font-noto-emoji)..."
    timeout 120 $sudo apk add --no-cache font-noto-emoji >/dev/null 2>&1 || return 1
  else
    return 1
  fi

  # Refresh fontconfig cache so Chromium picks up the new font. Run under sudo
  # for the system cache dirs (unprivileged fc-cache fails on unwritable dirs).
  if command -v fc-cache >/dev/null 2>&1; then
    $sudo fc-cache -f >/dev/null 2>&1 || fc-cache -f >/dev/null 2>&1 || true
  fi
  EMOJI_FONT_INSTALLED=1
  return 0
}

# After a fresh font install, stop any running browse render daemon so the next
# make-pdf render spawns a fresh Chromium that sees the new font. Chromium
# caches its font list at process start, so a daemon that was alive before the
# install would keep emitting tofu. `browse stop` is the graceful API; the
# daemon auto-respawns on the next render. Best-effort and per-project-root, so
# we also print a note for daemons in other roots.
refresh_browse_daemon_for_fonts() {
  [ "$EMOJI_FONT_INSTALLED" -eq 1 ] || return 0
  if [ -x "$BROWSE_BIN" ]; then
    "$BROWSE_BIN" stop >/dev/null 2>&1 || true
  fi
  echo "  Installed a color-emoji font. The next make-pdf render will show emoji."
  echo "  If a gstack browser is running in another project, restart it to pick up the font."
}

prepare_bun_for_windows_compile() {
  BUN_CMD="bun"
  BUN_CMD_WAS_COPIED=0
  [ "$IS_WINDOWS" -eq 1 ] || return 0

  local bun_path
  bun_path="$(command -v bun 2>/dev/null || true)"
  case "$bun_path" in
    *[![:ascii:]]*)
      local bun_copy_dir="$SOURCE_GSTACK_DIR/.tmp-bun-bin"
      mkdir -p "$bun_copy_dir"
      cp -f "$bun_path" "$bun_copy_dir/bun.exe"
      BUN_CMD="$bun_copy_dir/bun.exe"
      BUN_CMD_WAS_COPIED=1
      ;;
  esac
}

bun_cmd() {
  "$BUN_CMD" "$@"
}

cleanup_copied_bun() {
  local rc=$?
  if [ "${BUN_CMD_WAS_COPIED:-0}" -eq 1 ]; then
    rm -rf "$SOURCE_GSTACK_DIR/.tmp-bun-bin"
  fi
  _setup_exit_summary "$rc"
}

prepare_bun_for_windows_compile
trap cleanup_copied_bun EXIT

_cso_unavailable() {
  CSO_BUILD_AVAILABLE=0
  CSO_FAIL_REASON="$1"
}

# CSO has a native startup boundary. Its extra toolchain is optional for setup:
# when unavailable, install every other skill and leave /cso visibly fail-closed.
# A successful probe is only a prerequisite check; any later source build failure
# still aborts setup and therefore cannot be mistaken for a missing local tool.
probe_cso_build_prerequisites() {
  local _help _flag _platform _compiler _tmp _ps_script _repo_root _git_path _probe_out
  CSO_BUILD_AVAILABLE=1
  CSO_FAIL_REASON=""
  CSO_PROBE_DETAIL=""
  if [ "${GSTACK_SETUP_SKIP_CSO_BUILD:-0}" = "1" ]; then
    _cso_unavailable "skipped-by-request"
    return 0
  fi

  _help="$(bun_cmd build --help 2>&1 || true)"
  for _flag in --no-compile-autoload-dotenv --no-compile-autoload-bunfig --no-compile-autoload-tsconfig --no-compile-autoload-package-json; do
    case "$_help" in
      *"$_flag"*) ;;
      *) _cso_unavailable "bun-compile-flags"; return 0 ;;
    esac
  done

  _platform="$(uname -s)"
  if [ "$IS_WINDOWS" -eq 1 ]; then
    if ! command -v powershell.exe >/dev/null 2>&1 || ! command -v cygpath >/dev/null 2>&1; then
      _cso_unavailable "windows-shell-toolchain"
      return 0
    fi
    _ps_script="$(cygpath -w "$SOURCE_GSTACK_DIR/scripts/build-cso-windows.ps1")"
    _repo_root="$(cygpath -w "$SOURCE_GSTACK_DIR")"
    _git_path="$(type -P git 2>/dev/null || true)"
    if [ -z "$_git_path" ] || [ ! -f "$_git_path" ]; then
      _cso_unavailable "windows-git"
      return 0
    fi
    # E2 (#3015): a probe that found MSVC but could not compile is not a
    # missing toolchain; keep the probe's own first diagnostic for the summary.
    if ! _probe_out="$(powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass \
      -File "$_ps_script" -RepoRoot "$_repo_root" -GitExePath "$(cygpath -aw "$_git_path")" -CheckOnly 2>&1)"; then
      case "$_probe_out" in
        *"compiler probe failed"*|*"compiler probe was not produced"*)
          _cso_unavailable "windows-msvc-compile"
          CSO_PROBE_DETAIL="$(printf '%s\n' "$_probe_out" | grep -m1 -E 'error [A-Z]*[0-9]+|probe (failed|was not produced)' | tr -d '\r' | head -c 200)" ;;
        *) _cso_unavailable "windows-msvc-toolchain" ;;
      esac
    fi
    return 0
  fi

  _compiler="${CSO_CC:-cc}"
  if ! command -v "$_compiler" >/dev/null 2>&1; then
    _cso_unavailable "c-compiler"
    return 0
  fi
  _tmp="$(mktemp -d "${TMPDIR:-/tmp}/gstack-cso-probe.XXXXXX" 2>/dev/null || true)"
  if [ -z "$_tmp" ]; then
    _cso_unavailable "native-toolchain-probe"
    return 0
  fi
  printf '%s\n' 'int main(void) { return 0; }' > "$_tmp/probe.c"
  case "$_platform" in
    Linux)
      if ! "$_compiler" -std=c11 -static "$_tmp/probe.c" -o "$_tmp/probe" >/dev/null 2>&1; then
        _cso_unavailable "static-c-toolchain"
      fi
      ;;
    Darwin)
      if ! command -v codesign >/dev/null 2>&1; then
        _cso_unavailable "macos-codesign"
      elif ! "$_compiler" -std=c11 "$_tmp/probe.c" -o "$_tmp/probe" >/dev/null 2>&1 || \
        ! codesign --force --sign - --options runtime "$_tmp/probe" >/dev/null 2>&1 || \
        ! codesign --verify --strict "$_tmp/probe" >/dev/null 2>&1 || \
        ! codesign -d --verbose=4 "$_tmp/probe" 2>&1 | grep -q 'runtime'; then
        _cso_unavailable "macos-native-toolchain"
      fi
      ;;
    *) _cso_unavailable "unsupported-platform" ;;
  esac
  rm -rf "$_tmp"
}

# Resolve the model overlay used for generated Codex skills. Setup auto-detects
# only Codex because it has one canonical TOML config surface; direct generator
# calls remain deterministic and use the host default unless --model is explicit.
# The resolver runs on EVERY setup, not just codex installs: step 1b regenerates
# .agents/ unconditionally, and existing ~/.codex/skills symlinks point into it —
# a plain `./setup` on a Sol user's machine must not clobber their profile with
# the hardcoded fallback. The resolver is a read-only TOML lookup that falls
# back to the current frontier Codex profile when no Codex config exists.
CODEX_GENERATION_MODEL="gpt-6-astra"
CODEX_GENERATION_MODEL_SOURCE="default (gpt-6-astra)"
_CODEX_MODEL_ARGS=(run scripts/resolve-codex-generation-model.ts)
if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then
  _CODEX_MODEL_ARGS+=(--explicit "$MODEL_OVERRIDE")
fi
_CODEX_MODEL_OUTPUT="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd "${_CODEX_MODEL_ARGS[@]}")"
IFS=$'\t' read -r CODEX_GENERATION_MODEL CODEX_GENERATION_MODEL_SOURCE <<< "$_CODEX_MODEL_OUTPUT"
if [ -z "$CODEX_GENERATION_MODEL" ]; then
  echo "gstack setup failed: Codex model resolver returned no model" >&2
  exit 1
fi
if [ "$INSTALL_CODEX" -eq 1 ] || [ "$CODEX_GENERATION_MODEL" != "gpt-6-astra" ]; then
  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.
# A failed/foreign replacement retains its old render even when build runs
# --host all later. On success, normal generation may prune orphan old renders.
export GSTACK_DEFER_CLAUDE_RENAME_PRUNE=1
export GSTACK_CODEX_GENERATION_MODEL="$CODEX_GENERATION_MODEL"
if CODEX_HOME="$(dirname "$CODEX_SKILLS")" GSTACK_RENAME_COPY="$IS_WINDOWS" bun_cmd "$SOURCE_GSTACK_DIR/bin/gstack-migrate-claude-code" \
  --install-dir "$SOURCE_GSTACK_DIR" --skills-dir "$INSTALL_SKILLS_DIR"; then
  if [ "$CODEX_REPO_LOCAL" -eq 0 ]; then unset GSTACK_DEFER_CLAUDE_RENAME_PRUNE; fi
fi

# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock).
probe_cso_build_prerequisites
_EXE=""
if [ "$IS_WINDOWS" -eq 1 ]; then _EXE=".exe"; fi
# Never leave a trusted helper from an earlier toolchain/version available when
# this setup run cannot reproduce it. The /cso skill then reports not assessed.
if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then
  rm -f "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher" \
    "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher.exe" \
    "$SOURCE_GSTACK_DIR/bin/gstack-cso-core" \
    "$SOURCE_GSTACK_DIR/bin/gstack-cso-core.exe" \
    "$SOURCE_GSTACK_DIR/bin/gstack-cso-watchdog" \
    "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation" \
    "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation.lock"
fi
BUILD_STAMP="$SOURCE_GSTACK_DIR/browse/dist/.build-complete"
NEEDS_BUILD=0
if [ ! -f "$BUILD_STAMP" ] || [ ! -x "$BROWSE_BIN" ] || [ ! -x "$SOURCE_GSTACK_DIR/design/dist/design$_EXE" ] || [ ! -x "$SOURCE_GSTACK_DIR/make-pdf/dist/pdf$_EXE" ]; then
  NEEDS_BUILD=1
fi
if [ "$CSO_BUILD_AVAILABLE" -eq 1 ]; then
  if [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" ] || [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-core$_EXE" ] || [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation" ]; then
    NEEDS_BUILD=1
  elif [ "$IS_WINDOWS" -eq 0 ] && [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-watchdog" ]; then
    NEEDS_BUILD=1
  elif [ "$IS_WINDOWS" -eq 1 ] && [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation.lock" ];then
    NEEDS_BUILD=1
  fi
fi
# lib/ holds the canonical claude-bin, error-handling and aside-render sources
# the binaries embed (browse/src re-exports them), so it is part of the set.
if [ "$NEEDS_BUILD" -eq 0 ]; then
  if [ -n "$(find "$SOURCE_GSTACK_DIR/browse/src" "$SOURCE_GSTACK_DIR/make-pdf/src" "$SOURCE_GSTACK_DIR/design/src" "$SOURCE_GSTACK_DIR/lib" -type f -newer "$BUILD_STAMP" -print -quit 2>/dev/null)" ]; then
    NEEDS_BUILD=1
  elif [ "$SOURCE_GSTACK_DIR/package.json" -nt "$BUILD_STAMP" ]; then
    NEEDS_BUILD=1
  elif [ -f "$SOURCE_GSTACK_DIR/bun.lock" ] && [ "$SOURCE_GSTACK_DIR/bun.lock" -nt "$BUILD_STAMP" ]; then
    NEEDS_BUILD=1
  elif [ "$SOURCE_GSTACK_DIR/scripts/build.sh" -nt "$BUILD_STAMP" ]; then
    NEEDS_BUILD=1
  elif [ "$CSO_BUILD_AVAILABLE" -eq 1 ] && { [ "$SOURCE_GSTACK_DIR/scripts/build-cso.sh" -nt "$BUILD_STAMP" ] || [ "$SOURCE_GSTACK_DIR/scripts/build-cso-windows.ps1" -nt "$BUILD_STAMP" ]; }; then
    NEEDS_BUILD=1
  fi
fi

if [ "$NEEDS_BUILD" -eq 1 ]; then
  log "Building browse binary..."
  (
    cd "$SOURCE_GSTACK_DIR"
    bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
    if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then
      export GSTACK_SETUP_SKIP_CSO_BUILD=1
    fi
    bun_cmd run build
  )
  # Safety net: write .version if build script didn't (e.g., git not available during build)
  if [ ! -f "$SOURCE_GSTACK_DIR/browse/dist/.version" ]; then
    git -C "$SOURCE_GSTACK_DIR" rev-parse HEAD > "$SOURCE_GSTACK_DIR/browse/dist/.version" 2>/dev/null || true
  fi

  # macOS Apple Silicon: ad-hoc codesign compiled binaries.
  # Bun's --compile can produce a corrupt or linker-only code signature that
  # macOS kills with SIGKILL (exit 137). The two-step remove+re-sign is
  # required because a naive `codesign -s - -f` fails when the existing
  # signature block is corrupt. This is idempotent and costs <1s.
  #
  # Some binaries (observed: find-browse, gstack-global-discover) also carry
  # trailing zero-padding AFTER the Mach-O LC_CODE_SIGNATURE region. macOS
  # codesign requires the signature to be the last content and extend to EOF,
  # so the padding triggers "main executable failed strict validation" on
  # re-sign (and "internal error in Code Signing subsystem" on remove). We
  # truncate that trailing slack to the end of LC_CODE_SIGNATURE first, which
  # lets the identical re-sign succeed. The binary runs either way: Bun's
  # adhoc code-page signature satisfies the kernel's exec check even when
  # `codesign --verify` is unhappy, so a re-sign failure only warns when the
  # binary is genuinely SIGKILL'd on exec (exit 137).
  # See: https://github.com/garrytan/gstack/issues/997
  if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then
    # CSO artifacts are signed and verified inside build-cso's staged generation;
    # mutating them here would break its launcher-last publication guarantee.
    # B4 (#1254): every binary is backed up first and restored as built when
    # re-signing fails, so a failed codesign never leaves a half-signed file.
    for _bin in browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover; do
      _bin_path="$SOURCE_GSTACK_DIR/$_bin"
      [ -f "$_bin_path" ] && [ -x "$_bin_path" ] || continue
      _bin_bak="$_bin_path.gstack-presign.$$"
      if ! cp -p "$_bin_path" "$_bin_bak" 2>/dev/null; then
        rm -f "$_bin_bak"
        log "note: could not back up $_bin before re-signing; left it as built"
        continue
      fi
      # Strip any trailing bytes past LC_CODE_SIGNATURE so codesign can re-sign.
      # otool prints the signature's dataoff+datasize; if the file is larger,
      # the extra bytes are Bun padding that breaks strict validation.
      _sig_end=$(otool -l "$_bin_path" 2>/dev/null | awk '/LC_CODE_SIGNATURE/{f=1} f&&/dataoff/{o=$2} f&&/datasize/{print o+$2; exit}')
      _fsize=$(stat -f%z "$_bin_path" 2>/dev/null)
      if [ -n "$_sig_end" ] && [ -n "$_fsize" ] && [ "$_sig_end" -gt 0 ] 2>/dev/null && [ "$_sig_end" -lt "$_fsize" ] 2>/dev/null; then
        _trunc_tmp=$(mktemp 2>/dev/null) || _trunc_tmp=""
        if [ -n "$_trunc_tmp" ] && head -c "$_sig_end" "$_bin_path" > "$_trunc_tmp" 2>/dev/null; then
          cat "$_trunc_tmp" > "$_bin_path" && chmod +x "$_bin_path"
        fi
        [ -n "$_trunc_tmp" ] && rm -f "$_trunc_tmp"
      fi
      codesign --remove-signature "$_bin_path" 2>/dev/null || true
      _signed=1
      if codesign -s - -f "$_bin_path" 2>/dev/null; then
        rm -f "$_bin_bak"
      else
        _signed=0
        mv -f "$_bin_bak" "$_bin_path"
      fi
      # Exec probe: after a failed re-sign (the restored binary as built), and
      # always for design, whose skills trust a binary that starts. Only
      # SIGKILL (exit 137) means it cannot run; set -e safe.
      [ "$_signed" -eq 0 ] || [ "$_bin" = design/dist/design ] || continue
      _probe_rc=0
      "$_bin_path" --help >/dev/null 2>&1 || _probe_rc=$?
      if [ "$_probe_rc" -eq 137 ] && [ "$_bin" = design/dist/design ]; then
        echo "  design unavailable: $_bin_path is killed at launch (exit 137) after re-signing, usually an invalid code signature. Design skills will report DESIGN_NOT_AVAILABLE. Fix: cd $SOURCE_GSTACK_DIR && ./setup" >&2
      elif [ "$_probe_rc" -eq 137 ]; then
        log "warning: codesign failed for $_bin and it is SIGKILL'd on exec (exit 137) — it may not run on Apple Silicon"
      elif [ "$_signed" -eq 0 ]; then
        log "note: codesign could not re-sign $_bin; restored it as built, and it executes fine (Bun adhoc signature); continuing"
      fi
    done
  fi

  # macOS: install coreutils for `gtimeout` (Codex hang protection in /codex + /autoplan).
  # macOS ships BSD `timeout`-less; Homebrew's coreutils installs GNU timeout as
  # `gtimeout` to avoid shadowing BSD utilities. The /codex and /autoplan skills
  # fall back to unwrapped codex invocations when neither is available — this
  # auto-install upgrades them to hang-protected where possible.
  # Skip entirely with GSTACK_SKIP_COREUTILS=1 (CI, managed machines, offline envs).
  if [ "$(uname -s)" = "Darwin" ] && [ "${GSTACK_SKIP_COREUTILS:-0}" != "1" ]; then
    if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
      if command -v brew >/dev/null 2>&1; then
        log "Installing coreutils for Codex hang protection (set GSTACK_SKIP_COREUTILS=1 to skip)..."
        brew install coreutils >/dev/null 2>&1 || log "warning: brew install coreutils failed; /codex will run without hang protection"
      else
        log "warning: Homebrew not found. /codex will run without hang protection. Install coreutils manually or set GSTACK_SKIP_COREUTILS=1."
      fi
    fi
  fi
fi

if [ "$CSO_BUILD_AVAILABLE" -eq 1 ]; then
  if [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" ] || [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-core$_EXE" ] || [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation" ] || { [ "$IS_WINDOWS" -eq 0 ] && [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-watchdog" ]; } || { [ "$IS_WINDOWS" -eq 1 ] && [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation.lock" ]; }; then
    echo "gstack setup failed: CSO build completed without its required native artifact set" >&2
    exit 1
  fi
  # Evaluation-only helpers embed an unqualified cso-eval-* catalog for the
  # private evaluator. Setup never installs one, whether marked or copied bare.
  if [ -e "$SOURCE_GSTACK_DIR/bin/.gstack-cso-evaluation" ] || "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" --version 2>/dev/null | grep -q '"evaluationOnly": *true'; then
    echo "gstack setup failed: bin/ holds an evaluation-only CSO helper; remove the bin/gstack-cso-* artifacts and re-run setup" >&2
    exit 1
  fi
  # Runtime/scanner execution never pulls. Setup is the sole automatic
  # acquisition path: the trusted launcher validates committed catalogs,
  # rejects remote Docker contexts, and uses an empty Docker config for
  # anonymous exact-digest pulls. Each image gets a bounded pull window and the
  # whole catalog remains under a hard aggregate deadline; Docker or network
  # gaps stay nonfatal so a later setup can continue from local exact digests.
  _CSO_IMAGE_SUMMARY=""
  _CSO_IMAGE_PULL_TIMEOUT_SECONDS="${GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS:-30}"
  if _CSO_IMAGE_SUMMARY="$("$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" provision-images --setup-summary --per-image-seconds "$_CSO_IMAGE_PULL_TIMEOUT_SECONDS" 2>/dev/null)"; then
    [ -n "$_CSO_IMAGE_SUMMARY" ] && log "$_CSO_IMAGE_SUMMARY"
  else
    log "warning: qualified CSO images could not be checked or preloaded; static audits remain available. Re-run setup after local Docker and public registry access are available."
  fi
fi

if [ ! -x "$BROWSE_BIN" ]; then
  echo "gstack setup failed: browse binary missing at $BROWSE_BIN" >&2
  exit 1
fi

# 1b. Generate .agents/ Codex skill docs — always regenerate to prevent stale descriptions.
# .agents/ is no longer committed — generated at setup time from .tmpl templates.
# bun run build generates the host-default artifact. Always render Codex again
# with the resolved user profile so a build cannot overwrite a Sol-specific render.
# Always regenerate: generation is fast (<2s) and mtime-based staleness checks are fragile
# (miss stale files when timestamps match after clone/checkout/upgrade).
AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills"
NEEDS_AGENTS_GEN=1

if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then
  log "Generating .agents/ skill docs..."
  (
    cd "$SOURCE_GSTACK_DIR"
    bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
    bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
  )
  _prune_stale_generated "$SOURCE_GSTACK_DIR" "$AGENTS_DIR" "$CODEX_SKILLS"
fi

# 1c. Generate .factory/ Factory Droid skill docs
if [ "$INSTALL_FACTORY" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
  log "Generating .factory/ skill docs..."
  (
    cd "$SOURCE_GSTACK_DIR"
    bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
    bun_cmd run gen:skill-docs --host factory ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
  )
fi

# 1d. Generate .opencode/ OpenCode skill docs
if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
  log "Generating .opencode/ skill docs..."
  (
    cd "$SOURCE_GSTACK_DIR"
    bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
    bun_cmd run gen:skill-docs --host opencode ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
  )
fi

# 1e. Generate .cursor/ Cursor skill docs
if [ "$INSTALL_CURSOR" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
  log "Generating .cursor/ skill docs..."
  (
    cd "$SOURCE_GSTACK_DIR"
    bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
    bun_cmd run gen:skill-docs --host cursor ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
  )
fi

# 1f. Generate .copilot/ GitHub Copilot skill docs
if [ "$INSTALL_COPILOT" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
  log "Generating .copilot/ skill docs..."
  (
    cd "$SOURCE_GSTACK_DIR"
    bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
    bun_cmd run gen:skill-docs --host copilot ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
  )
fi

# 2. Ensure Playwright's Chromium is available
_PLAYWRIGHT_PLATFORM_OVERRIDE=""
_PLAYWRIGHT_UNSUPPORTED_ARCH=""
if [ -f /etc/os-release ]; then
  _os_id=$(grep '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
  _os_ver=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
  if [ "$_os_id" = "ubuntu" ] && [ "$_os_ver" = "26.04" ]; then
    _pw_arch="$(uname -m)"
    case "$_pw_arch" in
      x86_64) _PLAYWRIGHT_PLATFORM_OVERRIDE="ubuntu24.04-x64" ;;
      aarch64|arm64) _PLAYWRIGHT_PLATFORM_OVERRIDE="ubuntu24.04-arm64" ;;
      *) _PLAYWRIGHT_UNSUPPORTED_ARCH="$_pw_arch" ;;
    esac
    if [ -n "$_PLAYWRIGHT_PLATFORM_OVERRIDE" ]; then
      echo "Ubuntu 26.04 detected — using PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=$_PLAYWRIGHT_PLATFORM_OVERRIDE"
    fi
  fi
fi

# Chromium is BEST-EFFORT (#1900, #1901, #1902, #913, #2233). Every later step
# — skill registration (# 4), Codex/Kiro installs, migrations, hooks — is
# independent of the browser, so a failed or wedged download must never abort
# setup under `set -e`. Each failure records a reason code in _PW_FAIL_REASON;
# the skills that need Chromium (/qa, /qa-only, /design-review, /browse,
# make-pdf, /pair-agent) are named in the final summary instead of the user
# discovering a half-installed gstack. Lock contention is a reason too: another
# setup is installing Chromium right now, so this run registers skills and
# re-probes next time. The download is bounded (default 600s, env
# GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT) because Playwright's own retries cover a
# flaky socket but not a wedged bunx; a wedged installer is killed with its
# child tree (_kill_tree: pgrep-walked, /proc-walked where pgrep is missing).
# Reason codes: skipped, chromium-install,
# chromium-install-timeout, chromium-install-locked, windows-no-node,
# windows-node-modules, post-install-launch.
# test/setup-playwright-best-effort.test.ts pins this block.
_PW_FAIL_REASON=""
_pw_fail() {
  local code="$1"; shift
  _PW_FAIL_REASON="${_PW_FAIL_REASON:+$_PW_FAIL_REASON,}$code"
  echo "  Chromium bootstrap: $code — $*" >&2
}
_PW_INSTALL_TIMEOUT="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT:-600}"
# Normalize to a plain positive integer or fall back to the default: empty and
# non-numeric are garbage; "0"/"000" would kill the install on the first poll;
# "0600" is 600; anything past nine digits is not a deadline (a value bash
# cannot compare would leave the install unbounded — the exact failure the
# bound exists to prevent).
case "$_PW_INSTALL_TIMEOUT" in ''|*[!0-9]*) _PW_INSTALL_TIMEOUT=600 ;; esac
[ "${#_PW_INSTALL_TIMEOUT}" -le 9 ] || _PW_INSTALL_TIMEOUT=600
_PW_INSTALL_TIMEOUT=$((10#$_PW_INSTALL_TIMEOUT))
[ "$_PW_INSTALL_TIMEOUT" -gt 0 ] || _PW_INSTALL_TIMEOUT=600

if [ "${GSTACK_SKIP_PLAYWRIGHT:-0}" = "1" ]; then
  _pw_fail skipped "GSTACK_SKIP_PLAYWRIGHT=1 — Chromium install skipped by request (#913)"
elif [ -n "${_PLAYWRIGHT_UNSUPPORTED_ARCH:-}" ]; then
  _pw_fail unsupported-platform "Ubuntu 26.04 architecture $_PLAYWRIGHT_UNSUPPORTED_ARCH has no supported Playwright Chromium fallback; no browser install attempted"
elif ! ensure_playwright_browser; then
  echo "Installing Playwright Chromium..."
  # XProtect self-heal (#2554): the probe failure may be the OS killing the
  # cached Chromium, not a missing install. Clear quarantine on the Playwright
  # cache bundles before reinstalling so the fresh fetch launches clean.
  _clear_playwright_quarantine
  _PW_LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock"
  # Stale-lock self-heal: a SIGKILL'd prior setup leaves the lock dir behind
  # forever (mkdir mutexes have no owner). If the recorded holder PID is dead,
  # reclaim instead of telling the user to rmdir by hand.
  if [ -d "$_PW_LOCK" ]; then
    _PW_STALE=0; _PW_HOLDER=0
    _PW_LOCK_OLD=""
    [ -n "$(find "$_PW_LOCK" -maxdepth 0 -mmin +$(( _PW_INSTALL_TIMEOUT / 60 + 1 )) 2>/dev/null)" ] && _PW_LOCK_OLD=1
    if [ ! -f "$_PW_LOCK/pid" ]; then
      # No holder recorded (killed between mkdir and echo, or mid-write by a
      # live setup): nothing to probe, so only age can prove abandonment.
      [ -n "$_PW_LOCK_OLD" ] && _PW_STALE=1
    else
      _PW_HOLDER=$(cat "$_PW_LOCK/pid" 2>/dev/null || true)
      # A pid must be a positive integer: "", "-1" (kill -0 -1 signals every
      # process and "succeeds") or "0" (the process group) are stale, not live.
      case "$_PW_HOLDER" in ''|*[!0-9]*) _PW_HOLDER=0 ;; esac
      if [ "$_PW_HOLDER" -eq 0 ] || ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
        _PW_STALE=1
      elif [ -n "$_PW_LOCK_OLD" ]; then
        # The holder is alive but the lock is older than the install bound: the
        # holder is past its own deadline, or its pid was recycled to an
        # unrelated long-lived process. Either way nobody is installing.
        _PW_STALE=1
      fi
    fi
    if [ "$_PW_STALE" -eq 1 ]; then
      echo "  reclaiming stale Chromium-install lock (holder pid ${_PW_HOLDER:-?} is gone or past the install bound)" >&2
      # Rename first: two setups judging the same lock stale race on rm -rf +
      # mkdir, and the loser would delete the winner's fresh lock. mv of a
      # directory is atomic, so exactly one of them reclaims — and if the dir
      # we moved already belongs to a NEW live holder (it re-created the lock
      # between our judgment and our mv), hand it straight back.
      if mv "$_PW_LOCK" "$_PW_LOCK.stale.$$" 2>/dev/null; then
        _PW_MOVED_PID=$(cat "$_PW_LOCK.stale.$$/pid" 2>/dev/null || true)
        case "$_PW_MOVED_PID" in ''|*[!0-9]*) _PW_MOVED_PID=0 ;; esac
        if { [ "$_PW_MOVED_PID" -gt 0 ] && [ "$_PW_MOVED_PID" != "$_PW_HOLDER" ] && kill -0 "$_PW_MOVED_PID" 2>/dev/null; } \
           || { [ ! -f "$_PW_LOCK.stale.$$/pid" ] && [ -z "$_PW_LOCK_OLD" ]; }; then
          # A new live holder, or a fresh lock whose holder has not written its
          # pid yet (mkdir done, echo pending): not ours to reclaim.
          mv "$_PW_LOCK.stale.$$" "$_PW_LOCK" 2>/dev/null || true
        else
          rm -rf "$_PW_LOCK.stale.$$" 2>/dev/null || true
        fi
      fi
    fi
  fi
  if mkdir "$_PW_LOCK" 2>/dev/null; then
    echo "$$" > "$_PW_LOCK/pid" 2>/dev/null || true
    # Chain the earlier cleanup_copied_bun EXIT trap: `trap ... EXIT` REPLACES
    # the previous handler, so the lock trap must run both or any run taking
    # this path leaves .tmp-bun-bin behind.
    trap 'rm -rf "$_PW_LOCK" 2>/dev/null || true; cleanup_copied_bun' EXIT
    (
      cd "$SOURCE_GSTACK_DIR"
      if [ -n "$_PLAYWRIGHT_PLATFORM_OVERRIDE" ]; then
        PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$_PLAYWRIGHT_PLATFORM_OVERRIDE" bunx playwright install chromium
      else
        bunx playwright install chromium
      fi
    ) &
    _PW_PID=$!
    # Ctrl-C during the download: a backgrounded child ignores SIGINT, so
    # without this the installer would keep running as an orphan while the
    # EXIT trap frees the lock — the #2136 pile-up the lock exists to prevent.
    trap '_kill_tree "$_PW_PID" 2>/dev/null; rm -rf "$_PW_LOCK" 2>/dev/null || true; cleanup_copied_bun; exit 130' INT TERM
    _PW_RC=0
    _wait_with_deadline "$_PW_PID" "$_PW_INSTALL_TIMEOUT" || _PW_RC=$?
    trap - INT TERM
    if [ "$_PW_RC" -eq 124 ]; then
      _pw_fail chromium-install-timeout "bunx playwright install chromium exceeded ${_PW_INSTALL_TIMEOUT}s and was killed (raise with GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=<seconds>)"
    elif [ "$_PW_RC" -ne 0 ]; then
      _pw_fail chromium-install "bunx playwright install chromium exited $_PW_RC (offline, proxy, or blocked download?)"
    fi
    rm -rf "$_PW_LOCK" 2>/dev/null || true
    # Restore the original handler (never `trap - EXIT`, which would clear
    # cleanup_copied_bun for the rest of the script).
    trap cleanup_copied_bun EXIT
  else
    _pw_fail chromium-install-locked "another gstack setup is installing Chromium (lock: $_PW_LOCK) — re-run ./setup after it finishes, or remove a stale lock: rm -rf \"$_PW_LOCK\""
  fi

  if [ -z "$_PW_FAIL_REASON" ] && [ "$IS_WINDOWS" -eq 1 ]; then
    # On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253).
    # Ensure playwright is importable by Node from the gstack directory.
    if ! command -v node >/dev/null 2>&1; then
      _pw_fail windows-no-node "Node.js is required on Windows to launch Chromium (Bun cannot: oven-sh/bun#4253) — install from https://nodejs.org/ and re-run ./setup"
    else
      echo "Windows detected — verifying Node.js can load Playwright..."
      if ! (
        cd "$SOURCE_GSTACK_DIR"
        # Bun's node_modules already has playwright; verify Node can require it.
        # @ngrok/ngrok is externalized in server-node.mjs and resolved at runtime;
        # verify the platform-specific native binary is installed so /pair-agent
        # tunnels don't fail later with a cryptic module-not-found error.
        # &&-chained: errexit is off inside an `if` condition, so a failed npm
        # install on the first line must not be masked by the second.
        { node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright; } &&
        { node -e "require('@ngrok/ngrok')" 2>/dev/null || npm install --no-save @ngrok/ngrok; }
      ); then
        _pw_fail windows-node-modules "npm could not install playwright / @ngrok/ngrok for Node.js"
      fi
    fi
  fi
fi

if [ -z "$_PW_FAIL_REASON" ] && ! ensure_playwright_browser; then
  if [ "$IS_WINDOWS" -eq 1 ]; then
    _pw_fail post-install-launch "Playwright Chromium could not be launched via Node.js (oven-sh/bun#4253) — ensure 'node -e \"require('playwright')\"' works, then re-run ./setup"
  else
    _pw_fail post-install-launch "Playwright Chromium installed but could not be launched — see the launch error above. For AppArmor/user-namespace errors on Ubuntu 24.04+, try GSTACK_CHROMIUM_NO_SANDBOX=1 (#2157)"
  fi
fi

# 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux).
#     Best-effort: warn instead of failing if it can't install.
if ! ensure_emoji_font; then
  echo "  Note: could not auto-install a color-emoji font. Emoji in make-pdf" >&2
  echo "  output may render as boxes (▯). Install one manually, e.g.:" >&2
  echo "    Debian/Ubuntu: sudo apt-get install fonts-noto-color-emoji" >&2
  echo "    Fedora:        sudo dnf install google-noto-color-emoji-fonts" >&2
  echo "    Arch:          sudo pacman -S noto-fonts-emoji" >&2
  echo "    Alpine:        sudo apk add font-noto-emoji" >&2
elif [ -z "$_PW_FAIL_REASON" ]; then
  # Only when Chromium is actually usable — restarting a daemon that cannot
  # launch its browser just produces a second failure line.
  refresh_browse_daemon_for_fonts
fi

# 3. Ensure ~/.gstack global state directory exists
mkdir -p "$HOME/.gstack/projects"

# ─── Helper: link a skill's runtime assets into its installed dir ────────────
# Installs EVERY runtime asset a skill ships next to its SKILL.md (#2317,
# #2454): review/checklist.md + specialists/, qa/templates + references,
# gstack-upgrade/migrations, careful/bin, freeze/bin, sections/, etc.
# Exclusion list rather than inclusion list (F7) so a new asset file is
# installed by default instead of silently dropped:
#   - SKILL.md       linked separately by the caller (name-aware)
#   - node_modules   dependency trees, never a runtime read
#   - dist           compiled binaries; skills reference them repo-anchored
#                    (~/.claude/skills/gstack/browse/dist/...), never
#                    alias-relative, and fresh clones haven't built them
#   - test           test fixtures
#   - *.tmpl         generator sources; the generated file is the asset
#   - hidden files   excluded by the glob (no dotglob)
# Shared so any flattened-skill installer can reuse it (the Claude path is
# the first consumer; codex/factory/opencode install from generated trees).
_link_skill_runtime_assets() {
  local src_dir="$1"
  local dst_dir="$2"
  # $3 = 0 when the destination directory is not provably ours (pre-existed
  # unclaimed, or only weakly proven where gstack never wrote real assets): its
  # real files are the user's, so a same-named real asset is kept and reported
  # instead of replaced (#2119). Symlinks are never content and are always
  # refreshed. Default 1 = the directory is ours.
  local replace_real="${3:-1}"
  # $4 = this skill's directory in the gbrain render, or empty (F5, #2706).
  # The rendered SKILL.md is served from there, and its section index names
  # sections relative to the installed directory, so a rendered counterpart of
  # an asset is served per file over the checkout's (render dirs carry only
  # generated *.md; manifest.json and other assets still come from the checkout).
  local render_dir="${4:-}"
  local asset asset_name child
  for asset in "$src_dir"/*; do
    [ -e "$asset" ] || continue  # empty-glob guard
    asset_name="$(basename "$asset")"
    case "$asset_name" in
      SKILL.md|node_modules|dist|test|*.tmpl) continue ;;
    esac
    if [ -e "$dst_dir/$asset_name" ] && [ ! -L "$dst_dir/$asset_name" ] && [ "$replace_real" != "1" ]; then
      echo "  kept ${dst_dir##*/}/$asset_name: a file you own already uses that name — left untouched" >&2
      continue
    fi
    # Refresh: rm the old entry (symlink OR real copy — the Windows install
    # pattern) so re-runs after `git pull` pick up changes.
    if [ -e "$dst_dir/$asset_name" ] || [ -L "$dst_dir/$asset_name" ]; then
      rm -rf "$dst_dir/$asset_name"
    fi
    if [ -n "$render_dir" ] && [ -d "$asset" ] && [ -d "$render_dir/$asset_name" ]; then
      mkdir -p "$dst_dir/$asset_name"
      for child in "$asset"/*; do
        [ -e "$child" ] || continue
        if [ -f "$render_dir/$asset_name/${child##*/}" ]; then
          _link_or_copy "$render_dir/$asset_name/${child##*/}" "$dst_dir/$asset_name/${child##*/}"
        else
          _link_or_copy "$child" "$dst_dir/$asset_name/${child##*/}"
        fi
      done
    elif [ -n "$render_dir" ] && [ -f "$asset" ] && [ -f "$render_dir/$asset_name" ]; then
      _link_or_copy "$render_dir/$asset_name" "$dst_dir/$asset_name"
    else
      _link_or_copy "$asset" "$dst_dir/$asset_name"
    fi
    # P5: the exclusion list above filters DIRECT children only, but the
    # Windows cp -R copy sweeps NESTED gitignored build output too (concrete:
    # ios-qa/scripts/gen-accessors-tool/.build is 252MB). Prune post-copy —
    # a rendered skill install is never a build root, so nested
    # node_modules/.build/dist are dead weight. ONLY here: the generic
    # _link_or_copy stays untouched because runtime roots (browse/, design/)
    # intentionally copy their dist/ binaries.
    if [ "$IS_WINDOWS" -eq 1 ] && [ -d "$dst_dir/$asset_name" ] && [ ! -L "$dst_dir/$asset_name" ]; then
      find "$dst_dir/$asset_name" -type d \( -name node_modules -o -name .build -o -name dist \) -prune -exec rm -rf {} + 2>/dev/null || true
    fi
  done
}

# ─── Helper: link Claude skill subdirectories into a skills parent directory ──
# Creates real directories (not symlinks) at the top level with a SKILL.md symlink
# inside. This ensures Claude discovers them as top-level skills, not nested under
# gstack/ (which would auto-prefix them as gstack-*).
# When SKILL_PREFIX=1, directories are prefixed with "gstack-".
# Use --no-prefix to restore flat names.
# Run gstack-relink and surface only what the user must see: foreign entries it
# skipped (deduped against the ones this setup already reported, same wording)
# and any pre-existing SKILL.md it moved to the backup root.
_run_relink_quiet() {
  local out line name
  local rc=0
  out="$(GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" 2>&1)" || rc=$?
  if [ "$rc" -ne 0 ]; then
    echo "  warning: gstack-relink failed (exit $rc) for $INSTALL_SKILLS_DIR; skill names may not match skill_prefix. Output:" >&2
    printf '%s\n' "$out" | tail -5 | sed 's/^/    /' >&2
  fi
  while IFS= read -r line; do
    case "$line" in
      '  skipped '*)
        name="${line#  skipped }"; name="${name%%:*}"
        case " ${_FOREIGN_SKIPPED_ENTRIES[*]:-} " in
          *" $name "*) ;;
          *) echo "$line" >&2; _FOREIGN_SKIPPED_ENTRIES+=("$name") ;;
        esac ;;
      'Moved '*|'  cleaned '*) echo "  ${line#  }" >&2 ;;
    esac
  done <<EOF
$out
EOF
}

link_claude_skill_dirs() {
  local gstack_dir="$1"
  local skills_dir="$2"
  local linked=()
  for skill_dir in "$gstack_dir"/*/; do
    if [ -f "$skill_dir/SKILL.md" ]; then
      dir_name="$(basename "$skill_dir")"
      # Skip node_modules
      [ "$dir_name" = "node_modules" ] && continue
      # Use frontmatter name: if present (e.g., run-tests/ with name: test → symlink as "test")
      skill_name=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]')
      [ -z "$skill_name" ] && skill_name="$dir_name"
      case "${_DISABLED_SKILLS:- }" in *" $skill_name "*|*" $dir_name "*) continue ;; esac
      # Apply gstack- prefix unless --no-prefix or already prefixed
      if [ "$SKILL_PREFIX" -eq 1 ]; then
        case "$skill_name" in
          gstack-*) link_name="$skill_name" ;;
          *)        link_name="gstack-$skill_name" ;;
        esac
      else
        link_name="$skill_name"
      fi
      target="$skills_dir/$link_name"
      # #2119: a destination that exists and is NOT ours is a user's skill that
      # shares our name. Never rm/mkdir/ln into it — skip and report.
      if { [ -e "$target" ] || [ -L "$target" ]; } && ! _claude_entry_is_ours "$target" "$gstack_dir/$dir_name/SKILL.md" "$gstack_dir"; then
        echo "  skipped $link_name: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2
        _FOREIGN_SKIPPED_ENTRIES+=("$link_name")
        continue
      fi
      # Remember whether WE are creating this directory: only then may the
      # provenance marker below make it deletable whole. A directory we merely
      # link into (unclaimed, or a legacy install) never gets one — legacy
      # all-links dirs are removed by the only-links rule instead.
      # _assets_replace: may _link_skill_runtime_assets replace a REAL file or
      # dir already present under the target? Yes for a directory we create or
      # strongly own (marker, or SKILL.md symlink into gstack). On Windows also
      # for a weakly-proven real-file copy install (the legacy pre-marker shape,
      # whose asset copies are ours). Otherwise (unclaimed, or a weak copy on a
      # platform where gstack never wrote real assets) real files are the
      # user's and are kept.
      _pre_exists=0; _assets_replace=1
      if [ -e "$target" ] || [ -L "$target" ]; then
        _pre_exists=1
        if _claude_entry_owned_strongly "$target" "$gstack_dir"; then _assets_replace=1
        elif [ "$IS_WINDOWS" -eq 1 ] && [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ]; then _assets_replace=1
        else _assets_replace=0
        fi
      fi
      # Upgrade old directory symlinks to real directories
      if [ -L "$target" ]; then
        rm -f "$target"
      fi
      # Create real directory with symlinked SKILL.md (absolute path)
      # Use mkdir -p unconditionally (idempotent) to avoid TOCTOU race
      mkdir -p "$target"
      # Validate target isn't a symlink before creating the link
      if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi
      # #2569: prefer a rendered :user variant when present. gbrain installs
      # render brain-aware SKILL.md into ${GSTACK_HOME}/render/claude via
      # gen:skill-docs --out-dir instead of dirtying the tracked source
      # checkout; when a render exists for this skill, serve it. Its STOP
      # pointers are absolute render-dir paths, but its section index names
      # `sections/<f>.md` relative to this directory, so the runtime assets
      # below prefer the render too (F5, #2706).
      _skill_md_src="$gstack_dir/$dir_name/SKILL.md"
      _render_dir="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
      _skill_render=""
      if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then
        _skill_md_src="$_render_dir/$dir_name/SKILL.md"
        _skill_render="$_render_dir/$dir_name"
      fi
      # A real-file SKILL.md we can only WEAKLY prove ours and whose content
      # differs from what we are about to serve is moved aside, not overwritten.
      if [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ] && ! _claude_entry_owned_strongly "$target" "$gstack_dir" \
         && ! cmp -s "$target/SKILL.md" "$_skill_md_src"; then
        if ! _backup_skill_md "$target/SKILL.md" "$link_name"; then
          echo "  skipped $link_name: could not back up its customized SKILL.md — left untouched" >&2
          _FOREIGN_SKIPPED_ENTRIES+=("$link_name")
          continue
        fi
      fi
      _link_or_copy "$_skill_md_src" "$target/SKILL.md"
      # Provenance marker (#2119) on every platform — path-independent proof
      # for Windows copies and for checkouts whose path carries no `gstack`
      # segment — but only for a directory we created or already owned: a
      # pre-existing unclaimed or weakly-proven directory must not become
      # deletable whole because we linked one file into it.
      if [ "$_pre_exists" -eq 0 ] || [ -f "$target/.gstack-owned" ]; then _write_owned_marker "$target" "$gstack_dir"; fi
      # Link every runtime asset the skill ships next to its SKILL.md (#2317,
      # #2454): sections/ for carved skills, review's checklist.md +
      # specialists/, qa's templates/ + references/, gstack-upgrade's
      # migrations/, careful/freeze's bin/, ... Without this, only SKILL.md
      # landed and /review 404'd at "Read .claude/skills/review/checklist.md"
      # on every fresh Claude install. Routes through _link_or_copy so Windows
      # gets real copies refreshed on every ./setup.
      _link_skill_runtime_assets "$gstack_dir/$dir_name" "$target" "$_assets_replace" "$_skill_render"
      linked+=("$link_name")
    fi
  done
  if [ ${#linked[@]} -gt 0 ]; then
    echo "  linked skills: ${linked[*]}"
    _print_windows_copy_note_once
  fi
}

# ─── Helper: install an alias SKILL.md as a rewritten COPY ───────────────────
# Alias dirs (_gstack-command, connect-chrome) must NOT symlink the canonical
# SKILL.md: the alias then carries the canonical frontmatter name:, Claude Code
# sees two skills with the same name, and drops the ENTIRE personal-skills set
# (#2511, #2201). Copy-then-rewrite instead: sed reads the SOURCE and writes a
# fresh copy with name: set to the alias. It must never edit through an
# existing symlink — that would rewrite the generated source file itself.
# NOTE: every alias name passed to this helper (_gstack-command,
# connect-chrome, gstack-connect-chrome) is hardcoded in the _INVENTORY seed
# list in bin/gstack-uninstall — keep the two sites in sync when adding or
# renaming an alias, or uninstall will refuse to delete the new alias dir.
_install_alias_skill_md() {
  case "${_DISABLED_SKILLS:- }" in *" $(basename "$(dirname "$1")") "*) return 0 ;; esac
  local src_skill_md="$1"
  local dst_dir="$2"
  local alias_name="$3"
  [ -f "$src_skill_md" ] || return 0
  # #2119: an existing alias-named entry that is not ours is a user's skill.
  # (Ours: a whole-dir symlink into gstack, or a copy carrying the generated
  # header — every alias copy does.)
  if { [ -e "$dst_dir" ] || [ -L "$dst_dir" ]; } && ! _claude_entry_is_ours "$dst_dir" "$src_skill_md" "$SOURCE_GSTACK_DIR"; then
    echo "  skipped $alias_name: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2
    _FOREIGN_SKIPPED_ENTRIES+=("$alias_name")
    return 0
  fi
  # Old installs left the alias as a whole-dir symlink — replace it.
  _alias_pre=0
  if [ -e "$dst_dir" ] || [ -L "$dst_dir" ]; then _alias_pre=1; fi
  if [ -L "$dst_dir" ]; then rm -f "$dst_dir"; fi
  mkdir -p "$dst_dir"
  # Remove any prior symlinked SKILL.md so the redirect below cannot write
  # through it into the generated source.
  rm -f "$dst_dir/SKILL.md"
  sed "1,/^---\$/ s/^name:[[:space:]].*/name: $alias_name/" "$src_skill_md" > "$dst_dir/SKILL.md"
  # A rewritten copy is a real file on every platform; the marker proves it
  # ours on the next run without leaning on the banner — but only for a
  # directory we created (or already marked), never one we merely wrote into.
  if [ "$_alias_pre" -eq 0 ] || [ -f "$dst_dir/.gstack-owned" ]; then _write_owned_marker "$dst_dir" "$SOURCE_GSTACK_DIR"; fi
}

# Claude Code skips the repo-shaped ~/.claude/skills/gstack directory when
# building the user-facing slash-command list. Keep the repo path for runtime
# assets, and add a separate thin wrapper. Its frontmatter name is rewritten to
# `_gstack-command` (the dir name) so it never collides with the canonical
# `gstack` name (#2511).
link_claude_root_skill_alias() {
  local gstack_dir="$1"
  local skills_dir="$2"
  local target="$skills_dir/_gstack-command"

  [ -f "$gstack_dir/SKILL.md" ] || return 0
  _install_alias_skill_md "$gstack_dir/SKILL.md" "$target" "_gstack-command"
  echo "  linked root skill alias: gstack"
}

# ─── Helper: remove old unprefixed Claude skill entries ───────────────────────
# Migration: when switching from flat names to gstack- prefixed names,
# clean up stale symlinks or directories that point into the gstack directory.
# Scan $skills_dir (not $gstack_dir): orphans live next to the payload, so a
# missing payload must still be able to reap leftover flat names (#2204).
cleanup_old_claude_symlinks() {
  local gstack_dir="$1"
  local skills_dir="$2"
  local removed=()
  local old_target skill_name link_dest skill_dir
  # Destination scan. The glob already yields dangling dir symlinks; [ -e ]
  # alone would skip them, so [ -L ] keeps those entries. An unmatched `*`
  # literal (empty skills_dir) is rejected by the same guard.
  for old_target in "$skills_dir"/*; do
    [ -e "$old_target" ] || [ -L "$old_target" ] || continue
    skill_name="$(basename "$old_target")"
    [ "$skill_name" = "node_modules" ] && continue
    [ "$skill_name" = "gstack" ] && continue
    # Skip already-prefixed dirs (gstack-upgrade) — no old symlink to clean
    case "$skill_name" in gstack-*) continue ;; esac
    # Remove directory symlinks pointing into gstack/
    if [ -L "$old_target" ]; then
      link_dest="$(readlink "$old_target" 2>/dev/null || true)"
      case "$link_dest" in
        gstack/*|*/gstack/*)
          rm -f "$old_target"
          removed+=("$skill_name")
          ;;
      esac
    # Remove real directories with symlinked SKILL.md pointing into gstack/
    elif [ -d "$old_target" ] && [ -L "$old_target/SKILL.md" ]; then
      link_dest="$(readlink "$old_target/SKILL.md" 2>/dev/null || true)"
      # Anchored path segments (same as the dir-symlink arm and
      # gstack-uninstall #2563). A bare *gstack* substring would wipe a
      # user skill under e.g. ~/tools/gstack-fork/. Also accept the #2569
      # render prefix (~/.gstack/render/claude/...), which is not `/gstack/`.
      case "$link_dest" in
        gstack/*|*/gstack/*|*/.gstack/render/claude/*)
          _cleanup_linked_dir "$old_target" "$gstack_dir"
          removed+=("$skill_name")
          ;;
      esac
    fi
  done
  # Windows install pattern: real dir with real-file SKILL.md (no symlink
  # available, so we can't readlink to verify provenance). A bare name match
  # deleted a user's own same-name skill (#2119); ownership is now proven by
  # the .gstack-owned marker link_claude_skill_dirs writes, or — for copies
  # made before the marker existed — by the copy being byte-identical to the
  # gstack source SKILL.md or carrying gen-skill-docs' AUTO-GENERATED header
  # (every generated SKILL.md does; a hand-written skill does not). Anything
  # else is foreign and is left alone.
  if [ "${IS_WINDOWS:-0}" -eq 1 ] && [ -d "$gstack_dir" ]; then
    for skill_dir in "$gstack_dir"/*/; do
      if [ -f "$skill_dir/SKILL.md" ]; then
        skill_name="$(basename "$skill_dir")"
        [ "$skill_name" = "node_modules" ] && continue
        case "$skill_name" in gstack-*) continue ;; esac
        old_target="$skills_dir/$skill_name"
        if [ -d "$old_target" ] && [ ! -L "$old_target" ] \
          && [ -f "$old_target/SKILL.md" ] && [ ! -L "$old_target/SKILL.md" ] \
          && { [ -f "$old_target/.gstack-owned" ] \
               || cmp -s "$old_target/SKILL.md" "$skill_dir/SKILL.md" \
               || _gstack_generated_header "$old_target/SKILL.md"; }; then
          # Only the marker proves we created the directory; weak proof covers
          # the SKILL.md alone (a user's files next to it survive).
          if [ -f "$old_target/.gstack-owned" ]; then rm -rf "$old_target"; else _cleanup_weak_dir "$old_target" "$gstack_dir" "$skill_dir/SKILL.md" "$skill_name"; fi
          removed+=("$skill_name")
        fi
      fi
    done
  fi
  if [ ${#removed[@]} -gt 0 ]; then
    echo "  cleaned up old entries: ${removed[*]}"
  fi
}

# ─── Helper: remove old prefixed Claude skill entries ─────────────────────────
# Reverse migration: when switching from gstack- prefixed names to flat names,
# clean up stale gstack-* symlinks or directories that point into the gstack directory.
cleanup_prefixed_claude_symlinks() {
  local gstack_dir="$1"
  local skills_dir="$2"
  local removed=()
  for skill_dir in "$gstack_dir"/*/; do
    if [ -f "$skill_dir/SKILL.md" ]; then
      skill_name="$(basename "$skill_dir")"
      [ "$skill_name" = "node_modules" ] && continue
      # Only clean up prefixed entries for dirs that AREN'T already prefixed
      # (e.g., remove gstack-qa but NOT gstack-upgrade which is the real dir name)
      case "$skill_name" in gstack-*) continue ;; esac
      prefixed_target="$skills_dir/gstack-$skill_name"
      # Remove directory symlinks pointing into gstack/ — anchored path
      # segments, same as cleanup_old_claude_symlinks and gstack-uninstall: a
      # bare *gstack* substring would wipe a user skill under ~/tools/gstack-fork/.
      if [ -L "$prefixed_target" ]; then
        link_dest="$(readlink "$prefixed_target" 2>/dev/null || true)"
        case "$link_dest" in
          gstack/*|*/gstack/*|*/.gstack/render/claude/*)
            rm -f "$prefixed_target"
            removed+=("gstack-$skill_name")
            ;;
        esac
      # Remove real directories with symlinked SKILL.md pointing into gstack/
      elif [ -d "$prefixed_target" ] && [ -L "$prefixed_target/SKILL.md" ]; then
        link_dest="$(readlink "$prefixed_target/SKILL.md" 2>/dev/null || true)"
        case "$link_dest" in
          gstack/*|*/gstack/*|*/.gstack/render/claude/*)
            _cleanup_linked_dir "$prefixed_target" "$gstack_dir"
            removed+=("gstack-$skill_name")
            ;;
        esac
      # Windows install pattern: real dir with real-file SKILL.md. Provenance
      # must be PROVEN (#2119), never assumed from the name: the marker
      # link_claude_skill_dirs writes, a byte-identical copy of the source, or
      # gen-skill-docs' generated header (legacy copies made before the marker).
      elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$prefixed_target" ] && [ -f "$prefixed_target/SKILL.md" ] && [ ! -L "$prefixed_target/SKILL.md" ] \
        && { [ -f "$prefixed_target/.gstack-owned" ] \
             || cmp -s "$prefixed_target/SKILL.md" "$skill_dir/SKILL.md" \
             || _gstack_generated_header "$prefixed_target/SKILL.md"; }; then
        if [ -f "$prefixed_target/.gstack-owned" ]; then rm -rf "$prefixed_target"; else _cleanup_weak_dir "$prefixed_target" "$gstack_dir" "$skill_dir/SKILL.md" "gstack-$skill_name"; fi
        removed+=("gstack-$skill_name")
      fi
    fi
  done
  if [ ${#removed[@]} -gt 0 ]; then
    echo "  cleaned up prefixed entries: ${removed[*]}"
  fi
}

# ─── Helper: link generated Codex skills into a skills parent directory ──
# Installs from .agents/skills/gstack-* (the generated Codex-format skills)
# instead of source dirs (which have Claude paths).
link_codex_skill_dirs() {
  local gstack_dir="$1"
  local skills_dir="$2"
  local agents_dir="$gstack_dir/.agents/skills"
  local linked=()

  if [ ! -d "$agents_dir" ]; then
    echo "  Generating .agents/ skill docs..."
    ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
  fi

  if [ ! -d "$agents_dir" ]; then
    echo "  warning: .agents/skills/ generation failed — run 'bun run gen:skill-docs --host codex --model $CODEX_GENERATION_MODEL' manually" >&2
    return 1
  fi

  for skill_dir in "$agents_dir"/gstack*/; do
    if [ -f "$skill_dir/SKILL.md" ]; then
      skill_name="$(basename "$skill_dir")"
      case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac
      # Skip the sidecar directory — it contains runtime asset symlinks (bin/,
      # browse/), not a skill. Linking it would overwrite the root gstack
      # symlink that Step 5 already pointed at the repo root.
      [ "$skill_name" = "gstack" ] && continue
      if [ "${CODEX_REPO_LOCAL:-0}" -eq 1 ] && [ "$skill_name" = "gstack-claude" ]; then continue; fi
      target="$skills_dir/$skill_name"
      # Create or update symlink
      # #2444: on Windows the installed target is a REAL directory copy, so
      # the symlink-or-missing guard skipped every re-run and SKILL.md never
      # refreshed after `git pull`. IS_WINDOWS bypasses the guard —
      # _link_or_copy rm -rf's the destination first, refreshing the copy.
      # #2142: a real dir may only be replaced when it is provably ours
      # (_owned_for_windows_refresh), never a user's own colliding dir.
      if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
        if _owned_for_windows_refresh "$target"; then
          _link_or_copy "$skill_dir" "$target"
          linked+=("$skill_name")
        else
          echo "  left in place (existing dir not gstack-managed — no generated banner): $target" >&2
        fi
      fi
    fi
  done
  if [ ${#linked[@]} -gt 0 ]; then
    echo "  linked skills: ${linked[*]}"
  fi
}

# ─── Helper: create .agents/skills/gstack/ sidecar symlinks ──────────
# Codex/Gemini/Cursor read skills from .agents/skills/. We link runtime
# assets (bin/, browse/dist/, review/, qa/, etc.) so skill templates can
# resolve paths like $SKILL_ROOT/review/design-checklist.md.
create_agents_sidecar() {
  local repo_root="$1"
  local agents_gstack="$repo_root/.agents/skills/gstack"
  # #2142: a hand-written skill squatting on the canonical name is the
  # user's — never write into it (the Windows branch would rm -rf its
  # subdirs on every re-run).
  if _sidecar_root_user_owned "$agents_gstack"; then
    echo "  left in place (existing dir not gstack-managed — no generated banner): $agents_gstack" >&2
    return 0
  fi
  mkdir -p "$agents_gstack"

  # Sidecar directories that skills reference at runtime. bin scripts import
  # shared modules via ../lib, so bin and lib must always travel together.
  for asset in bin lib browse review qa; do
    local src="$SOURCE_GSTACK_DIR/$asset"
    local dst="$agents_gstack/$asset"
    if [ -d "$src" ] || [ -f "$src" ]; then
      # #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
      # skipped the refresh. _link_or_copy rm -rf's the destination first.
      if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
        _link_or_copy "$src" "$dst"
      fi
    fi
  done

  # Sidecar files that skills reference at runtime
  for file in ETHOS.md; do
    local src="$SOURCE_GSTACK_DIR/$file"
    local dst="$agents_gstack/$file"
    if [ -f "$src" ]; then
      # #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
      # skipped the refresh. _link_or_copy rm -rf's the destination first.
      if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
        _link_or_copy "$src" "$dst"
      fi
    fi
  done

  _link_runtime_dists "$SOURCE_GSTACK_DIR" "$agents_gstack"

  # supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
  # (file-level on purpose: migrations/ and functions/ are dev-only)
  if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then
    mkdir -p "$agents_gstack/supabase"
    _link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$agents_gstack/supabase/config.sh"
  fi
}

# ─── Helper: staged activation of a host runtime root (G0a) ──────────────────
# _activate_runtime_root CREATE_FN SOURCE ROOT — build the runtime root beside
# the live one ("$ROOT.gstack-new.<pid>") with errexit on, and swap it in only
# when the build succeeded. A failed refresh leaves the previous runtime root,
# and so the host's last working install, in place. The swap is two renames;
# a run killed between them leaves "$ROOT.gstack-old.<pid>", which the next
# run restores when ROOT is missing.
_activate_runtime_root() {
  local fn="$1" src="$2" root="$3" new="$3.gstack-new.$$" old="$3.gstack-old.$$" rc leftover
  for leftover in "$root".gstack-old.*; do
    [ -e "$leftover" ] || [ -L "$leftover" ] || continue
    if [ ! -e "$root" ] && [ ! -L "$root" ]; then mv "$leftover" "$root"; else rm -rf "$leftover"; fi
  done
  rm -rf "$root".gstack-new.* 2>/dev/null || true
  set +e
  ( set -e; "$fn" "$src" "$new" )
  rc=$?
  set -e
  if [ "$rc" -ne 0 ]; then
    rm -rf "$new"
    echo "  error: could not build the new runtime root for $root (exit $rc); the previous one is unchanged" >&2
    return "$rc"
  fi
  if ! _preserve_skill_copy_edits "$root"; then
    rm -rf "$new"
    echo "  error: could not back up an edited SKILL.md copy under $root; the previous runtime root is unchanged" >&2
    return 1
  fi
  if [ -e "$root" ] || [ -L "$root" ]; then
    mv "$root" "$old" || { rm -rf "$new"; return 1; }
  fi
  if ! mv "$new" "$root"; then
    { [ -e "$old" ] || [ -L "$old" ]; } && mv "$old" "$root"
    rm -rf "$new"
    return 1
  fi
  rm -rf "$old"
  _record_skill_copies "$root" || echo "  warning: could not record SKILL.md copy hashes in $_SKILL_COPIES_FILE; a later hand edit there may not be backed up" >&2
  [ -n "$_COPIES_REFRESHED" ] && log "  refreshed SKILL.md copies in $root: $_COPIES_REFRESHED"
  [ -n "$_COPIES_REMOVED" ] && log "  removed SKILL.md copies from $root: $_COPIES_REMOVED"
  return 0
}

# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root ───────────
# Codex scans ~/.codex/skills recursively. Exposing the whole repo here causes
# duplicate skills because source SKILL.md files and generated Codex skills are
# both discoverable. Keep this directory limited to runtime assets + root skill.
create_codex_runtime_root() {
  local gstack_dir="$1"
  local codex_gstack="$2"
  local agents_dir="${_CODEX_RENDER_ROOT:-$gstack_dir}/.agents/skills"

  if [ -L "$codex_gstack" ]; then
    rm -f "$codex_gstack"
  elif [ -d "$codex_gstack" ] && [ "$codex_gstack" != "$gstack_dir" ]; then
    # Old direct installs left a real directory here with stale source skills.
    # Remove it so we start fresh with only the minimal runtime assets.
    rm -rf "$codex_gstack"
  fi

  mkdir -p "$codex_gstack" "$codex_gstack/browse" "$codex_gstack/gstack-upgrade" "$codex_gstack/review"

  if [ -f "$agents_dir/gstack/SKILL.md" ]; then
    _copy_skill_md "$agents_dir/gstack/SKILL.md" "$codex_gstack/SKILL.md"
  fi
  if [ -d "$gstack_dir/bin" ]; then
    _link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
  fi
  if [ -d "$gstack_dir/lib" ]; then
    _link_or_copy "$gstack_dir/lib" "$codex_gstack/lib"
  fi
  if [ -d "$gstack_dir/browse/dist" ]; then
    _link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
  fi
  if [ -d "$gstack_dir/browse/bin" ]; then
    _link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin"
  fi
  _link_runtime_dists "$gstack_dir" "$codex_gstack"
  if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then
    _copy_skill_md "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md"
  fi
  # plan-eng-review's inline office-hours step reads
  # $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the codex-rendered
  # variant there so the documented path exists.
  if [ -f "${agents_dir}/gstack-office-hours/SKILL.md" ]; then
    mkdir -p "${codex_gstack}/office-hours"
    _copy_skill_md "${agents_dir}/gstack-office-hours/SKILL.md" "${codex_gstack}/office-hours/SKILL.md"
  fi
  # Review runtime assets (individual files, NOT the whole review/ dir which has SKILL.md)
  for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
    if [ -f "$gstack_dir/review/$f" ]; then
      _link_or_copy "$gstack_dir/review/$f" "$codex_gstack/review/$f"
    fi
  done
  # ETHOS.md — referenced by "Search Before Building" in all skill preambles
  if [ -f "$gstack_dir/ETHOS.md" ]; then
    _link_or_copy "$gstack_dir/ETHOS.md" "$codex_gstack/ETHOS.md"
  fi
  # supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
  if [ -f "$gstack_dir/supabase/config.sh" ]; then
    mkdir -p "$codex_gstack/supabase"
    _link_or_copy "$gstack_dir/supabase/config.sh" "$codex_gstack/supabase/config.sh"
  fi
}

create_factory_runtime_root() {
  local gstack_dir="$1"
  local factory_gstack="$2"
  local factory_dir="$gstack_dir/.factory/skills"

  if [ -L "$factory_gstack" ]; then
    rm -f "$factory_gstack"
  elif [ -d "$factory_gstack" ] && [ "$factory_gstack" != "$gstack_dir" ]; then
    rm -rf "$factory_gstack"
  fi

  mkdir -p "$factory_gstack" "$factory_gstack/browse" "$factory_gstack/gstack-upgrade" "$factory_gstack/review"

  if [ -f "$factory_dir/gstack/SKILL.md" ]; then
    _copy_skill_md "$factory_dir/gstack/SKILL.md" "$factory_gstack/SKILL.md"
  fi
  if [ -d "$gstack_dir/bin" ]; then
    _link_or_copy "$gstack_dir/bin" "$factory_gstack/bin"
  fi
  if [ -d "$gstack_dir/lib" ]; then
    _link_or_copy "$gstack_dir/lib" "$factory_gstack/lib"
  fi
  if [ -d "$gstack_dir/browse/dist" ]; then
    _link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist"
  fi
  if [ -d "$gstack_dir/browse/bin" ]; then
    _link_or_copy "$gstack_dir/browse/bin" "$factory_gstack/browse/bin"
  fi
  _link_runtime_dists "$gstack_dir" "$factory_gstack"
  if [ -f "$factory_dir/gstack-upgrade/SKILL.md" ]; then
    _copy_skill_md "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md"
  fi
  # plan-eng-review's inline office-hours step reads
  # $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the factory-rendered
  # variant there so the documented path exists.
  if [ -f "${factory_dir}/gstack-office-hours/SKILL.md" ]; then
    mkdir -p "${factory_gstack}/office-hours"
    _copy_skill_md "${factory_dir}/gstack-office-hours/SKILL.md" "${factory_gstack}/office-hours/SKILL.md"
  fi
  for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
    if [ -f "$gstack_dir/review/$f" ]; then
      _link_or_copy "$gstack_dir/review/$f" "$factory_gstack/review/$f"
    fi
  done
  if [ -f "$gstack_dir/ETHOS.md" ]; then
    _link_or_copy "$gstack_dir/ETHOS.md" "$factory_gstack/ETHOS.md"
  fi
  # supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
  if [ -f "$gstack_dir/supabase/config.sh" ]; then
    mkdir -p "$factory_gstack/supabase"
    _link_or_copy "$gstack_dir/supabase/config.sh" "$factory_gstack/supabase/config.sh"
  fi
}

create_opencode_runtime_root() {
  local gstack_dir="$1"
  local opencode_gstack="$2"
  local opencode_dir="$gstack_dir/.opencode/skills"

  if [ -L "$opencode_gstack" ]; then
    rm -f "$opencode_gstack"
  elif [ -d "$opencode_gstack" ] && [ "$opencode_gstack" != "$gstack_dir" ]; then
    rm -rf "$opencode_gstack"
  fi

  mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review"

  if [ -f "$opencode_dir/gstack/SKILL.md" ]; then
    _copy_skill_md "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md"
  fi
  if [ -d "$gstack_dir/bin" ]; then
    _link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin"
  fi
  if [ -d "$gstack_dir/lib" ]; then
    _link_or_copy "$gstack_dir/lib" "$opencode_gstack/lib"
  fi
  if [ -d "$gstack_dir/browse/dist" ]; then
    _link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
  fi
  if [ -d "$gstack_dir/browse/bin" ]; then
    _link_or_copy "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin"
  fi
  _link_runtime_dists "$gstack_dir" "$opencode_gstack"
  if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then
    _copy_skill_md "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md"
  fi
  # plan-eng-review's inline office-hours step reads
  # $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the opencode-rendered
  # variant there so the documented path exists.
  if [ -f "${opencode_dir}/gstack-office-hours/SKILL.md" ]; then
    mkdir -p "${opencode_gstack}/office-hours"
    _copy_skill_md "${opencode_dir}/gstack-office-hours/SKILL.md" "${opencode_gstack}/office-hours/SKILL.md"
  fi
  for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
    if [ -f "$gstack_dir/review/$f" ]; then
      _link_or_copy "$gstack_dir/review/$f" "$opencode_gstack/review/$f"
    fi
  done
  if [ -d "$gstack_dir/review/specialists" ]; then
    _link_or_copy "$gstack_dir/review/specialists" "$opencode_gstack/review/specialists"
  fi
  if [ -d "$gstack_dir/qa/templates" ]; then
    _link_or_copy "$gstack_dir/qa/templates" "$opencode_gstack/qa/templates"
  fi
  if [ -d "$gstack_dir/qa/references" ]; then
    _link_or_copy "$gstack_dir/qa/references" "$opencode_gstack/qa/references"
  fi
  if [ -f "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" ]; then
    _link_or_copy "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" "$opencode_gstack/plan-devex-review/dx-hall-of-fame.md"
  fi
  if [ -f "$gstack_dir/ETHOS.md" ]; then
    _link_or_copy "$gstack_dir/ETHOS.md" "$opencode_gstack/ETHOS.md"
  fi
  # supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
  if [ -f "$gstack_dir/supabase/config.sh" ]; then
    mkdir -p "$opencode_gstack/supabase"
    _link_or_copy "$gstack_dir/supabase/config.sh" "$opencode_gstack/supabase/config.sh"
  fi
}

link_factory_skill_dirs() {
  local gstack_dir="$1"
  local skills_dir="$2"
  local factory_dir="$gstack_dir/.factory/skills"
  local linked=()

  if [ ! -d "$factory_dir" ]; then
    echo "  Generating .factory/ skill docs..."
    ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host factory ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
  fi

  if [ ! -d "$factory_dir" ]; then
    echo "  warning: .factory/skills/ generation failed — run 'bun run gen:skill-docs --host factory' manually" >&2
    return 1
  fi

  for skill_dir in "$factory_dir"/gstack*/; do
    if [ -f "$skill_dir/SKILL.md" ]; then
      skill_name="$(basename "$skill_dir")"
      case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac
      [ "$skill_name" = "gstack" ] && continue
      target="$skills_dir/$skill_name"
      # #2444: on Windows the installed target is a REAL directory copy, so
      # the symlink-or-missing guard skipped every re-run and SKILL.md never
      # refreshed after `git pull`. IS_WINDOWS bypasses the guard —
      # _link_or_copy rm -rf's the destination first, refreshing the copy.
      # #2142: a real dir may only be replaced when it is provably ours
      # (_owned_for_windows_refresh), never a user's own colliding dir.
      if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
        if _owned_for_windows_refresh "$target"; then
          _link_or_copy "$skill_dir" "$target"
          linked+=("$skill_name")
        else
          echo "  left in place (existing dir not gstack-managed — no generated banner): $target" >&2
        fi
      fi
    fi
  done
  if [ ${#linked[@]} -gt 0 ]; then
    echo "  linked skills: ${linked[*]}"
  fi
}

link_opencode_skill_dirs() {
  local gstack_dir="$1"
  local skills_dir="$2"
  local opencode_dir="$gstack_dir/.opencode/skills"
  local linked=()

  if [ ! -d "$opencode_dir" ]; then
    echo "  Generating .opencode/ skill docs..."
    ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host opencode ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
  fi

  if [ ! -d "$opencode_dir" ]; then
    echo "  warning: .opencode/skills/ generation failed — run 'bun run gen:skill-docs --host opencode' manually" >&2
    return 1
  fi

  for skill_dir in "$opencode_dir"/gstack*/; do
    if [ -f "$skill_dir/SKILL.md" ]; then
      skill_name="$(basename "$skill_dir")"
      case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac
      [ "$skill_name" = "gstack" ] && continue
      target="$skills_dir/$skill_name"
      # #2444: on Windows the installed target is a REAL directory copy, so
      # the symlink-or-missing guard skipped every re-run and SKILL.md never
      # refreshed after `git pull`. IS_WINDOWS bypasses the guard —
      # _link_or_copy rm -rf's the destination first, refreshing the copy.
      # #2142: a real dir may only be replaced when it is provably ours
      # (_owned_for_windows_refresh), never a user's own colliding dir.
      if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
        if _owned_for_windows_refresh "$target"; then
          _link_or_copy "$skill_dir" "$target"
          linked+=("$skill_name")
        else
          echo "  left in place (existing dir not gstack-managed — no generated banner): $target" >&2
        fi
      fi
    fi
  done
  if [ ${#linked[@]} -gt 0 ]; then
    echo "  linked skills: ${linked[*]}"
  fi
}

# ─── Helper: create a minimal ~/.cursor/skills/gstack runtime root ──────────
# Cursor scans ~/.cursor/skills. Same shape as the Codex/Factory/OpenCode
# runtime roots: root SKILL.md from the generated tree + runtime assets only.
# Contributed by @szsunyuan (PR #2547), re-derived onto the current installers.
create_cursor_runtime_root() {
  local gstack_dir="$1"
  local cursor_gstack="$2"
  local cursor_dir="$gstack_dir/.cursor/skills"
  local generated_root="$cursor_dir/gstack"

  if [ -L "$cursor_gstack" ]; then
    rm -f "$cursor_gstack"
  elif _sidecar_root_user_owned "$cursor_gstack"; then
    # #2142: a hand-written skill squatting on the canonical name is the
    # user's — never wipe it to make room for the runtime root.
    echo "  left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
    return 0
  elif [ -d "$cursor_gstack" ] && [ "$cursor_gstack" != "$gstack_dir" ] && [ "$cursor_gstack" != "$generated_root" ]; then
    rm -rf "$cursor_gstack"
  fi

  mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review"

  if [ -f "$cursor_dir/gstack/SKILL.md" ]; then
    _copy_skill_md "$cursor_dir/gstack/SKILL.md" "$cursor_gstack/SKILL.md"
  fi
  # bin scripts import shared modules via ../lib — bin and lib travel together.
  if [ -d "$gstack_dir/bin" ]; then
    _link_or_copy "$gstack_dir/bin" "$cursor_gstack/bin"
  fi
  if [ -d "$gstack_dir/lib" ]; then
    _link_or_copy "$gstack_dir/lib" "$cursor_gstack/lib"
  fi
  if [ -d "$gstack_dir/browse/dist" ]; then
    _link_or_copy "$gstack_dir/browse/dist" "$cursor_gstack/browse/dist"
  fi
  if [ -d "$gstack_dir/browse/bin" ]; then
    _link_or_copy "$gstack_dir/browse/bin" "$cursor_gstack/browse/bin"
  fi
  _link_runtime_dists "$gstack_dir" "$cursor_gstack"
  if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
    _copy_skill_md "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
  fi
  # Review runtime assets — the cursor host config ships the lean pair.
  for f in checklist.md TODOS-format.md; do
    if [ -f "$gstack_dir/review/$f" ]; then
      _link_or_copy "$gstack_dir/review/$f" "$cursor_gstack/review/$f"
    fi
  done
  if [ -f "$gstack_dir/ETHOS.md" ]; then
    _link_or_copy "$gstack_dir/ETHOS.md" "$cursor_gstack/ETHOS.md"
  fi
  # supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
  if [ -f "$gstack_dir/supabase/config.sh" ]; then
    mkdir -p "$cursor_gstack/supabase"
    _link_or_copy "$gstack_dir/supabase/config.sh" "$cursor_gstack/supabase/config.sh"
  fi
}

# Plant runtime assets into the repo-local generated skill dir so in-repo
# GSTACK_ROOT (preamble prefers $_ROOT/.cursor/skills/gstack) has bin/.
# NEVER wipe this directory — it holds the generated SKILL.md files.
create_cursor_sidecar() {
  local repo_root="$1"
  local cursor_gstack="$repo_root/.cursor/skills/gstack"
  local cursor_dir="$repo_root/.cursor/skills"

  # #2142: same user-ownership gate as create_agents_sidecar — but the
  # generated tree's own root (cursor_dir/gstack carries the banner) always
  # passes, so normal installs refresh as before.
  if _sidecar_root_user_owned "$cursor_gstack"; then
    echo "  left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
    return 0
  fi

  mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review"

  if [ -d "$repo_root/bin" ]; then
    if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/bin" ] || [ ! -e "$cursor_gstack/bin" ]; then
      _link_or_copy "$repo_root/bin" "$cursor_gstack/bin"
    fi
  fi
  if [ -d "$repo_root/lib" ]; then
    if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/lib" ] || [ ! -e "$cursor_gstack/lib" ]; then
      _link_or_copy "$repo_root/lib" "$cursor_gstack/lib"
    fi
  fi
  if [ -d "$repo_root/browse/dist" ]; then
    if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/dist" ] || [ ! -e "$cursor_gstack/browse/dist" ]; then
      _link_or_copy "$repo_root/browse/dist" "$cursor_gstack/browse/dist"
    fi
  fi
  if [ -d "$repo_root/browse/bin" ]; then
    if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/bin" ] || [ ! -e "$cursor_gstack/browse/bin" ]; then
      _link_or_copy "$repo_root/browse/bin" "$cursor_gstack/browse/bin"
    fi
  fi
  _link_runtime_dists "$repo_root" "$cursor_gstack"
  if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
    if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/gstack-upgrade/SKILL.md" ] || [ ! -e "$cursor_gstack/gstack-upgrade/SKILL.md" ]; then
      _link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
    fi
  fi
  for f in checklist.md TODOS-format.md; do
    if [ -f "$repo_root/review/$f" ]; then
      if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/review/$f" ] || [ ! -e "$cursor_gstack/review/$f" ]; then
        _link_or_copy "$repo_root/review/$f" "$cursor_gstack/review/$f"
      fi
    fi
  done
  if [ -f "$repo_root/ETHOS.md" ]; then
    if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/ETHOS.md" ] || [ ! -e "$cursor_gstack/ETHOS.md" ]; then
      _link_or_copy "$repo_root/ETHOS.md" "$cursor_gstack/ETHOS.md"
    fi
  fi
}

link_cursor_skill_dirs() {
  local gstack_dir="$1"
  local skills_dir="$2"
  local cursor_dir="$gstack_dir/.cursor/skills"
  local linked=()

  if [ ! -d "$cursor_dir" ]; then
    echo "  Generating .cursor/ skill docs..."
    ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host cursor ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
  fi

  if [ ! -d "$cursor_dir" ]; then
    echo "  warning: .cursor/skills/ generation failed — run 'bun run gen:skill-docs --host cursor' manually" >&2
    return 1
  fi

  for skill_dir in "$cursor_dir"/gstack*/; do
    if [ -f "$skill_dir/SKILL.md" ]; then
      skill_name="$(basename "$skill_dir")"
      case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac
      [ "$skill_name" = "gstack" ] && continue
      target="$skills_dir/$skill_name"
      # #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
      # skipped the refresh. Only replace a symlink, a missing path, or a
      # PROVABLY gstack-managed real dir; never a user's own Cursor skill
      # dir that merely starts with gstack (#2142).
      if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
        if _owned_for_windows_refresh "$target"; then
          _link_or_copy "$skill_dir" "$target"
          linked+=("$skill_name")
        else
          echo "  left in place (existing dir not gstack-managed — no generated banner): $target" >&2
        fi
      fi
    fi
  done
  if [ ${#linked[@]} -gt 0 ]; then
    echo "  linked skills: ${linked[*]}"
  fi
}

# ─── Helper: OpenCode slash commands for gstack skills (#2629) ───────────────
# Without a command file OpenCode's /<skill> routes to the `plan` primary agent
# and fails ("Task cancelled"). One managed command per skill runs it on the
# `build` agent in the current session. A command file without the managed
# marker is the user's and is never touched; managed files whose skill is gone
# or disabled are removed.
write_opencode_commands() {
  local skills_dir="$1/.opencode/skills" cmd_dir="$2" d name file desc marker skill
  marker='<!-- gstack-managed command (./setup --host opencode): edits are overwritten -->'
  mkdir -p "$cmd_dir"
  for d in "$skills_dir"/gstack-*/; do
    [ -f "$d/SKILL.md" ] || continue
    name="$(basename "$d")"
    file="$cmd_dir/$name.md"
    if [ -e "$file" ] && ! grep -qF "$marker" "$file" 2>/dev/null; then
      echo "  kept $file: an OpenCode command you own already uses this name" >&2
      continue
    fi
    case "${_DISABLED_SKILLS:- }" in *" ${name#gstack-} "*) rm -f "$file"; continue ;; esac
    desc="$(awk '/^description:/ { f = 1; next } f && /^[^ ]/ { exit } f && NF { sub(/^ +/, ""); print; exit }' "$d/SKILL.md" | sed 's/\\/\\\\/g; s/"/\\"/g')"
    # The skill tool knows a skill by its frontmatter name (gstack-review's is
    # `review`, which OpenCode's builtin /review shadows as a command, #2651).
    skill="$(awk '/^---$/ { fm++; next } fm == 1 && $1 == "name:" { print $2; exit }' "$d/SKILL.md")"
    printf -- '---\ndescription: "%s"\nagent: build\nsubtask: false\n---\n%s\nLoad the `%s` skill with the skill tool and follow its instructions.\n\n$ARGUMENTS\n' \
      "${desc:-gstack $name}" "$marker" "${skill:-$name}" > "$file.tmp.$$" && mv -f "$file.tmp.$$" "$file"
  done
  for file in "$cmd_dir"/gstack-*.md; do
    [ -f "$file" ] && grep -qF "$marker" "$file" 2>/dev/null || continue
    [ -f "$skills_dir/$(basename "$file" .md)/SKILL.md" ] || rm -f "$file"
  done
}

# ─── Helper: create a minimal ~/.copilot/skills/gstack runtime root ─────────
# Copilot scans ~/.copilot/skills recursively. Same shape as the Cursor root:
# root SKILL.md from the generated tree + runtime assets only, plus
# .source-path so /gstack-upgrade can find the source checkout (upgrade-path
# idea from @andrey-esipov, PR #2323). Built through _activate_runtime_root.
create_copilot_runtime_root() {
  local gstack_dir="$1"
  local copilot_gstack="$2"
  local copilot_dir="$gstack_dir/.copilot/skills"
  local asset f

  mkdir -p "$copilot_gstack" "$copilot_gstack/browse" "$copilot_gstack/gstack-upgrade" "$copilot_gstack/review"
  printf '%s\n' "$gstack_dir" > "$copilot_gstack/.source-path"
  if [ -f "$copilot_dir/gstack/SKILL.md" ]; then
    _copy_skill_md "$copilot_dir/gstack/SKILL.md" "$copilot_gstack/SKILL.md"
  fi
  for asset in bin lib browse/dist browse/bin; do
    if [ -d "$gstack_dir/$asset" ]; then
      _link_or_copy "$gstack_dir/$asset" "$copilot_gstack/$asset"
    fi
  done
  _link_runtime_dists "$gstack_dir" "$copilot_gstack"
  if [ -f "$copilot_dir/gstack-upgrade/SKILL.md" ]; then
    _copy_skill_md "$copilot_dir/gstack-upgrade/SKILL.md" "$copilot_gstack/gstack-upgrade/SKILL.md"
  fi
  if [ -f "$copilot_dir/gstack-office-hours/SKILL.md" ]; then
    mkdir -p "$copilot_gstack/office-hours"
    _copy_skill_md "$copilot_dir/gstack-office-hours/SKILL.md" "$copilot_gstack/office-hours/SKILL.md"
  fi
  for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
    if [ -f "$gstack_dir/review/$f" ]; then
      _link_or_copy "$gstack_dir/review/$f" "$copilot_gstack/review/$f"
    fi
  done
  if [ -f "$gstack_dir/ETHOS.md" ]; then
    _link_or_copy "$gstack_dir/ETHOS.md" "$copilot_gstack/ETHOS.md"
  fi
  if [ -f "$gstack_dir/supabase/config.sh" ]; then
    mkdir -p "$copilot_gstack/supabase"
    _link_or_copy "$gstack_dir/supabase/config.sh" "$copilot_gstack/supabase/config.sh"
  fi
}

# Copilot invokes skills by frontmatter name, and /review is a built-in
# Copilot command, so names get the gstack- prefix to match their dirs.
link_copilot_skill_dirs() {
  local gstack_dir="$1"
  local skills_dir="$2"
  local copilot_dir="$gstack_dir/.copilot/skills"
  local linked=()

  if [ ! -d "$copilot_dir" ]; then
    echo "  Generating .copilot/ skill docs..."
    ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host copilot ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
  fi
  if [ ! -d "$copilot_dir" ]; then
    echo "  warning: .copilot/skills/ generation failed — run 'bun run gen:skill-docs --host copilot' manually" >&2
    return 1
  fi
  "$gstack_dir/bin/gstack-patch-names" "$copilot_dir" 1

  for skill_dir in "$copilot_dir"/gstack*/; do
    if [ -f "$skill_dir/SKILL.md" ]; then
      skill_name="$(basename "$skill_dir")"
      [ "$skill_name" = "gstack" ] && continue
      case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac
      target="$skills_dir/$skill_name"
      if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
        if _owned_for_windows_refresh "$target"; then
          _link_or_copy "$skill_dir" "$target"
          linked+=("$skill_name")
        else
          echo "  left in place (existing dir not gstack-managed — no generated banner): $target" >&2
        fi
      fi
    fi
  done
  if [ ${#linked[@]} -gt 0 ]; then
    echo "  linked skills: ${linked[*]}"
  fi
}

# 4. Install for Claude (default)
# _claude_scope SKILLS_DIR — "global" for the user's Claude skills dir, else
# "project <root>".
_claude_scope() {
  case "$1" in
    "$HOME/.claude/skills"|"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills") echo "global -" ;;
    *) echo "project $(dirname "$(dirname "$1")")" ;;
  esac
}
_claude_prefix_setting() { [ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false; }
if [ "$INSTALL_CLAUDE" -eq 1 ]; then
  if [ "$SKILLS_BASENAME" = "skills" ]; then
    read -r _CL_SCOPE _CL_PROJECT <<EOF
$(_claude_scope "$INSTALL_SKILLS_DIR")
EOF
    _setup_arm_begin claude "$_CL_SCOPE" "$INSTALL_SKILLS_DIR"
    # Clean up stale symlinks from the opposite prefix mode
    if [ "$SKILL_PREFIX" -eq 1 ]; then
      cleanup_old_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
    else
      cleanup_prefixed_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
    fi
    # Patch name: fields BEFORE creating symlinks so link_claude_skill_dirs
    # reads the correct (patched) name: values for symlink naming
    "$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
    _render_claude_install "$INSTALL_GSTACK_DIR" || true
    link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
    link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
    _CLAUDE_SKILLS_LINKED=1
    # Self-healing: re-run gstack-relink to ensure name: fields and directory
    # names are consistent with the config. This catches cases where an interrupted
    # setup, stale git state, or gen:skill-docs left name: fields out of sync.
    GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
    if [ -x "$GSTACK_RELINK" ]; then
      _run_relink_quiet
    fi
    # Backwards-compat alias: /connect-chrome → /open-gstack-browser
    # Rewritten copy, not a symlink: a symlinked alias re-serves the canonical
    # name: open-gstack-browser, so one of the two silently shadows the other
    # (#2201) — and duplicate names can drop the whole skill set (#2511).
    _OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome"
    _OGB_ALIAS_NAME="connect-chrome"
    if [ "$SKILL_PREFIX" -eq 1 ]; then
      _OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome"
      _OGB_ALIAS_NAME="gstack-connect-chrome"
    fi
    _install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME"
    _setup_arm_publish claude "$_CL_SCOPE" "$_CL_PROJECT" "$INSTALL_SKILLS_DIR" "$INSTALL_GSTACK_DIR" "$(_claude_prefix_setting)" "${_CLAUDE_INSTALL_RENDER:-committed}"
    if [ "$LOCAL_INSTALL" -eq 1 ]; then
      log "gstack ready (project-local)."
      log "  skills: $INSTALL_SKILLS_DIR"
    else
      log "gstack ready (claude)."
    fi
    log "  browse: $BROWSE_BIN"
    _browser_hint
  else
    # Not inside a skills/ directory — would symlink the source into
    # ~/.claude/skills/gstack/ and register from there.
    CLAUDE_SKILLS_DIR="$HOME/.claude/skills"
    CLAUDE_GSTACK_LINK="$CLAUDE_SKILLS_DIR/gstack"

    # Conductor worktree guard: if ~/.claude/skills/gstack is already a real
    # (non-symlink) directory pointing to a *different* install, refuse to plant
    # a symlink there. On macOS/BSD, `ln -snf SRC DST` won't replace a real DST;
    # it creates DST/$(basename SRC) → SRC inside it. The result is per-worktree
    # symlinks leaking into the global install that Claude Code picks up as
    # separate top-level skills (dublin-v1, lincoln-v2, ...). Typical trigger:
    # running ./setup from a Conductor worktree of the gstack repo itself.
    _SKIP_CLAUDE_REGISTER=0
    if [ -d "$CLAUDE_GSTACK_LINK" ] && [ ! -L "$CLAUDE_GSTACK_LINK" ]; then
      _EXISTING_REAL=$(cd "$CLAUDE_GSTACK_LINK" 2>/dev/null && pwd -P || echo "")
      if [ -n "$_EXISTING_REAL" ] && [ "$_EXISTING_REAL" != "$SOURCE_GSTACK_DIR" ]; then
        _SKIP_CLAUDE_REGISTER=1
      fi
    fi
    # Ownership rule 1 (docs/ADDING_A_HOST.md): a global install that links to
    # a different, still-present gstack checkout is never silently repointed.
    _CLAUDE_REPOINT_REFUSED=0
    if [ -L "$CLAUDE_GSTACK_LINK" ] && [ "$GLOBAL_INSTALL" -eq 0 ]; then
      _EXISTING_REAL=$(cd "$CLAUDE_GSTACK_LINK" 2>/dev/null && pwd -P || echo "")
      if [ -n "$_EXISTING_REAL" ] && [ "$_EXISTING_REAL" != "$SOURCE_GSTACK_DIR" ] \
         && [ -f "$_EXISTING_REAL/setup" ] && [ -f "$_EXISTING_REAL/VERSION" ]; then
        _CLAUDE_REPOINT_REFUSED=1
      fi
    fi

    if [ "$_SKIP_CLAUDE_REGISTER" -eq 1 ]; then
      log ""
      log "  $CLAUDE_GSTACK_LINK already exists as a separate global install."
      log "  Skipping Claude skill registration to avoid polluting it with"
      log "  per-worktree symlinks. (Binaries still built locally for dev.)"
      log ""
      log "    Global install:  $CLAUDE_GSTACK_LINK"
      log "    This worktree:   $SOURCE_GSTACK_DIR"
      log ""
      log "  To register this worktree as the active gstack, remove the global"
      log "  install first:  rm -rf $CLAUDE_GSTACK_LINK"
      log ""
      log "gstack built (claude registration skipped)."
      log "  browse: $BROWSE_BIN"
      _browser_hint
      _setup_row claude global "$CLAUDE_SKILLS_DIR" - "$SETUP_VERSION" skipped "left alone: $CLAUDE_GSTACK_LINK is a separate global install (rule: setup never replaces a global install it does not own)"
    elif [ "$_CLAUDE_REPOINT_REFUSED" -eq 1 ]; then
      echo "" >&2
      echo "Left alone: $CLAUDE_GSTACK_LINK, the global Claude install, which links to another gstack checkout:" >&2
      echo "  $_EXISTING_REAL (v$(cat "$_EXISTING_REAL/VERSION" 2>/dev/null || echo unknown))" >&2
      echo "  Rule: setup never silently replaces a global install from a different checkout." >&2
      echo "  To make this checkout ($SOURCE_GSTACK_DIR) the global install: ./setup --global" >&2
      echo "  To refresh the existing one instead: cd $_EXISTING_REAL && ./setup" >&2
      echo "  Docs: docs/ADDING_A_HOST.md#install-ownership-rules" >&2
      log "gstack built (claude registration skipped)."
      _setup_row claude global "$CLAUDE_SKILLS_DIR" - "$SETUP_VERSION" skipped "left alone: links to $_EXISTING_REAL; to replace it run ./setup --global from this checkout"
    else
      _setup_arm_begin claude global "$CLAUDE_SKILLS_DIR"
      mkdir -p "$CLAUDE_SKILLS_DIR"
      _link_or_copy "$SOURCE_GSTACK_DIR" "$CLAUDE_GSTACK_LINK"
      log "  symlinked $CLAUDE_GSTACK_LINK -> $SOURCE_GSTACK_DIR"
      INSTALL_SKILLS_DIR="$CLAUDE_SKILLS_DIR"
      INSTALL_GSTACK_DIR="$CLAUDE_GSTACK_LINK"
      # Clean up stale symlinks from the opposite prefix mode
      if [ "$SKILL_PREFIX" -eq 1 ]; then
        cleanup_old_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
      else
        cleanup_prefixed_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
      fi
      "$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
      link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
      link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
      _CLAUDE_SKILLS_LINKED=1
      GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
      if [ -x "$GSTACK_RELINK" ]; then
        _run_relink_quiet
      fi
      # Rewritten copy, not a symlink: a symlinked alias re-serves the
      # canonical name: open-gstack-browser, so one of the two silently
      # shadows the other (#2201) — and duplicate names can drop the whole
      # skill set (#2511).
      _OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome"
      _OGB_ALIAS_NAME="connect-chrome"
      if [ "$SKILL_PREFIX" -eq 1 ]; then
        _OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome"
        _OGB_ALIAS_NAME="gstack-connect-chrome"
      fi
      _install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME"
      _setup_arm_publish claude global - "$INSTALL_SKILLS_DIR" "$INSTALL_GSTACK_DIR" "$(_claude_prefix_setting)"
      log "gstack ready (claude)."
      log "  browse: $BROWSE_BIN"
      _browser_hint
    fi
  fi
fi

# 5. Install for Codex
if [ "$INSTALL_CODEX" -eq 1 ]; then
  if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then _CX_SCOPE=project; _CX_PROJECT="$_codex_project"; else _CX_SCOPE=global; _CX_PROJECT=-; fi
  _setup_arm_begin codex "$_CX_SCOPE" "$CODEX_SKILLS"
  if [ "$INSTALL_CLAUDE" -eq 0 ]; then
    case "$INSTALL_SKILLS_DIR" in
      "$HOME/.claude/skills"|"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills")
        echo "  note: this checkout lives in Claude Code's skills directory ($INSTALL_GSTACK_DIR), so Claude Code" >&2
        echo "  still discovers it as a skill. For a Codex-only install, move it: mv $INSTALL_GSTACK_DIR ~/gstack && cd ~/gstack && ./setup --host codex" >&2 ;;
    esac
  fi
  mkdir -p "$CODEX_SKILLS"

  CODEX_RUNTIME_IN_PLACE=0
  if [ "$CODEX_REPO_LOCAL" -eq 1 ] && [ -d "$CODEX_GSTACK" ]; then
    _codex_runtime="$(cd "$CODEX_GSTACK" && pwd -P)"
    _codex_render="$(cd "$SOURCE_GSTACK_DIR/.agents/skills/gstack" 2>/dev/null && pwd -P || true)"
    if [ "$_codex_runtime" = "$SOURCE_GSTACK_DIR" ] || [ "$_codex_runtime" = "$_codex_render" ]; then
      CODEX_RUNTIME_IN_PLACE=1
    fi
  fi
  # CODEX_HOME (a global root other than ~/.codex/skills/gstack): serve a
  # per-install render whose runtime root names this install. Repo-local
  # installs resolve their root at run time and keep the committed render.
  _CODEX_RENDER_ROOT=""
  if [ "$CODEX_REPO_LOCAL" -eq 0 ] && _render_install codex "$CODEX_GSTACK" && [ -n "$_INSTALL_RENDER" ]; then
    _CODEX_RENDER_ROOT="$_INSTALL_RENDER"
  fi
  if [ "$CODEX_RUNTIME_IN_PLACE" -eq 0 ]; then
    if [ "$CODEX_REPO_LOCAL" -eq 1 ] && _sidecar_root_user_owned "$CODEX_GSTACK"; then
      echo "  left in place (existing dir not gstack-managed — no generated banner): $CODEX_GSTACK" >&2
    else
      _activate_runtime_root create_codex_runtime_root "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK"
    fi
  fi
  # Install generated Codex-format skills (not Claude source dirs)
  _prune_stale_generated "$SOURCE_GSTACK_DIR" "${_CODEX_RENDER_ROOT:-$SOURCE_GSTACK_DIR}/.agents/skills" "$CODEX_SKILLS"
  link_codex_skill_dirs "${_CODEX_RENDER_ROOT:-$SOURCE_GSTACK_DIR}" "$CODEX_SKILLS"
  _SETUP_ARM=""

  log "gstack ready (codex)."
  log "  browse: $BROWSE_BIN"
  _browser_hint
  log "  codex skills: $CODEX_SKILLS"
  log "  model profile: $CODEX_GENERATION_MODEL ($CODEX_GENERATION_MODEL_SOURCE)"
  log "  model changes: rerun ./setup --host codex"
  if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then
    log "  note: --model applies to this run only. To persist across upgrades,"
    log "  set model = \"$MODEL_OVERRIDE\" in \${CODEX_HOME:-~/.codex}/config.toml."
  fi
fi

# 6. Install for Kiro CLI from its own host render
if [ "$INSTALL_KIRO" -eq 1 ]; then
  KIRO_DIR="$SOURCE_GSTACK_DIR/.kiro/skills"
  KIRO_GSTACK="$KIRO_SKILLS/gstack"
  # Host identity controls outside-review routing as well as skill availability.
  # Never borrow .agents or temporarily replace a live Codex model profile.
  ( cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host kiro ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
  mkdir -p "$KIRO_SKILLS"
  if _sidecar_root_user_owned "$KIRO_GSTACK"; then
    echo "  left in place (existing Kiro runtime root is not gstack-managed): $KIRO_GSTACK" >&2
    _setup_row kiro global "$KIRO_SKILLS" - "$SETUP_VERSION" skipped "left alone: $KIRO_GSTACK is not gstack-managed (no generated banner)"
  else
    _setup_arm_begin kiro global "$KIRO_SKILLS"
    [ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK"
    mkdir -p "$KIRO_GSTACK" "$KIRO_GSTACK/browse" "$KIRO_GSTACK/gstack-upgrade" "$KIRO_GSTACK/review"
    _link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin"
    _link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib"
    _link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist"
    _link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin"
    _link_runtime_dists "$SOURCE_GSTACK_DIR" "$KIRO_GSTACK"
    if [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ]; then
      _link_or_copy "$SOURCE_GSTACK_DIR/ETHOS.md" "$KIRO_GSTACK/ETHOS.md"
    fi
    if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then
      mkdir -p "$KIRO_GSTACK/supabase"
      _link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$KIRO_GSTACK/supabase/config.sh"
    fi
    if ! _preserve_skill_copy_edits "$KIRO_GSTACK"; then
      echo "  error: could not back up an edited SKILL.md copy under $KIRO_GSTACK; Kiro install stopped before overwriting it" >&2
      exit 1
    fi
    if [ -f "$KIRO_DIR/gstack-upgrade/SKILL.md" ]; then
      _copy_skill_md "$KIRO_DIR/gstack-upgrade/SKILL.md" "$KIRO_GSTACK/gstack-upgrade/SKILL.md"
    fi
    if [ -f "$KIRO_DIR/gstack/SKILL.md" ]; then
      _copy_skill_md "$KIRO_DIR/gstack/SKILL.md" "$KIRO_GSTACK/SKILL.md"
    fi
    if [ -f "$KIRO_DIR/gstack-office-hours/SKILL.md" ]; then
      mkdir -p "$KIRO_GSTACK/office-hours"
      _copy_skill_md "$KIRO_DIR/gstack-office-hours/SKILL.md" "$KIRO_GSTACK/office-hours/SKILL.md"
    fi
    for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
      if [ -f "$SOURCE_GSTACK_DIR/review/$f" ]; then
        _link_or_copy "$SOURCE_GSTACK_DIR/review/$f" "$KIRO_GSTACK/review/$f"
      fi
    done
    for skill_dir in "$KIRO_DIR"/gstack*/; do
      [ -f "$skill_dir/SKILL.md" ] || continue
      skill_name="$(basename "$skill_dir")"
      [ "$skill_name" = "gstack" ] && continue
      target_dir="$KIRO_SKILLS/$skill_name"
      case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$target_dir"; continue ;; esac
      if { [ -e "$target_dir" ] || [ -L "$target_dir" ]; } && ! _claude_entry_is_ours "$target_dir" "$skill_dir/SKILL.md" "$SOURCE_GSTACK_DIR"; then
        echo "  skipped $skill_name: existing entry is not gstack-managed — left untouched" >&2
        continue
      fi
      # Existing real copy installs retain unrelated files alongside SKILL.md.
      if [ -L "$target_dir" ]; then rm -f "$target_dir"; fi
      mkdir -p "$target_dir"
      if [ -f "$target_dir/SKILL.md" ] && [ ! -L "$target_dir/SKILL.md" ] \
         && ! _claude_entry_owned_strongly "$target_dir" "$SOURCE_GSTACK_DIR" \
         && ! _skill_copy_unmodified "$target_dir/SKILL.md" \
         && ! cmp -s "$target_dir/SKILL.md" "$skill_dir/SKILL.md"; then
        if ! _backup_skill_md "$target_dir/SKILL.md" "$skill_name"; then
          echo "  skipped $skill_name: could not back up its customized SKILL.md — left untouched" >&2
          continue
        fi
      fi
      _copy_skill_md "$skill_dir/SKILL.md" "$target_dir/SKILL.md"
      _record_skill_copies "$target_dir" || true
      # Native sections already contain Kiro paths/provider markers. Refresh
      # generated files individually so user assets next to them survive.
      if [ -d "$skill_dir/sections" ]; then
        if [ -L "$target_dir/sections" ]; then
          section_target="$(_gstack_link_target_abs "$target_dir/sections")"
          if ! _gstack_target_is_ours "$section_target" "$SOURCE_GSTACK_DIR"; then
            echo "  kept $skill_name/sections: directory link is not gstack-managed" >&2
            continue
          fi
          rm -f "$target_dir/sections"
        fi
        mkdir -p "$target_dir/sections"
        for section_file in "$skill_dir/sections"/*; do
          [ -f "$section_file" ] || continue
          section_dest="$target_dir/sections/$(basename "$section_file")"
          if [ -L "$section_dest" ]; then
            section_target="$(_gstack_link_target_abs "$section_dest")"
            _gstack_target_is_ours "$section_target" "$SOURCE_GSTACK_DIR" || continue
          elif [ -e "$section_dest" ] && ! _gstack_generated_header "$section_dest"; then
            echo "  kept $skill_name/sections/$(basename "$section_file"): existing file is not gstack-managed" >&2
            continue
          fi
          _link_or_copy "$section_file" "$section_dest"
        done
      fi
      if [ "$skill_name" = "gstack-qa" ] && [ -f "$skill_dir/templates/functional-report-template.md" ]; then
        if [ -L "$target_dir/templates" ]; then
          template_target="$(_gstack_link_target_abs "$target_dir/templates")"
          if ! _gstack_target_is_ours "$template_target" "$SOURCE_GSTACK_DIR"; then
            echo "  kept $skill_name/templates: directory link is not gstack-managed" >&2
            continue
          fi
          rm -f "$target_dir/templates"
        elif [ -e "$target_dir/templates" ] && [ ! -d "$target_dir/templates" ]; then
          echo "  kept $skill_name/templates: existing entry is not a directory" >&2
          continue
        fi
        mkdir -p "$target_dir/templates"
        template_dest="$target_dir/templates/functional-report-template.md"
        if [ -L "$template_dest" ]; then
          template_target="$(_gstack_link_target_abs "$template_dest")"
          if ! _gstack_target_is_ours "$template_target" "$SOURCE_GSTACK_DIR"; then
            echo "  kept $skill_name/templates/functional-report-template.md: file link is not gstack-managed" >&2
            continue
          fi
        elif [ -e "$template_dest" ] && ! _gstack_generated_header "$template_dest"; then
          echo "  kept $skill_name/templates/functional-report-template.md: existing file is not gstack-managed" >&2
          continue
        fi
        _link_or_copy "$skill_dir/templates/functional-report-template.md" "$template_dest"
      fi
    done
    _prune_stale_generated "$SOURCE_GSTACK_DIR" "$KIRO_DIR" "$KIRO_SKILLS"
    _record_skill_copies "$KIRO_GSTACK" || echo "  warning: could not record SKILL.md copy hashes in $_SKILL_COPIES_FILE" >&2
    [ -n "$_COPIES_REFRESHED" ] && log "  refreshed SKILL.md copies in $KIRO_GSTACK: $_COPIES_REFRESHED"
    _setup_arm_publish kiro global - "$KIRO_SKILLS" "$KIRO_GSTACK" -
    echo "gstack ready (kiro)."
    echo "  browse: $BROWSE_BIN"
    _browser_hint
    echo "  kiro skills: $KIRO_SKILLS"
  fi
fi

# 6b. Install for Factory Droid
if [ "$INSTALL_FACTORY" -eq 1 ]; then
  _setup_arm_begin factory global "$FACTORY_SKILLS"
  mkdir -p "$FACTORY_SKILLS"
  _activate_runtime_root create_factory_runtime_root "$SOURCE_GSTACK_DIR" "$FACTORY_GSTACK"
  _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.factory/skills" "$FACTORY_SKILLS"
  link_factory_skill_dirs "$SOURCE_GSTACK_DIR" "$FACTORY_SKILLS"
  _setup_arm_publish factory global - "$FACTORY_SKILLS" "$FACTORY_GSTACK" -
  echo "gstack ready (factory)."
  echo "  browse: $BROWSE_BIN"
  _browser_hint
  echo "  factory skills: $FACTORY_SKILLS"
fi

# 6c. Install for OpenCode
if [ "$INSTALL_OPENCODE" -eq 1 ]; then
  _setup_arm_begin opencode global "$OPENCODE_SKILLS"
  mkdir -p "$OPENCODE_SKILLS"
  _activate_runtime_root create_opencode_runtime_root "$SOURCE_GSTACK_DIR" "$OPENCODE_GSTACK"
  _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.opencode/skills" "$OPENCODE_SKILLS"
  link_opencode_skill_dirs "$SOURCE_GSTACK_DIR" "$OPENCODE_SKILLS"
  write_opencode_commands "$SOURCE_GSTACK_DIR" "$HOME/.config/opencode/commands"
  _setup_arm_publish opencode global - "$OPENCODE_SKILLS" "$OPENCODE_GSTACK" -
  echo "gstack ready (opencode)."
  echo "  browse: $BROWSE_BIN"
  _browser_hint
  echo "  opencode skills: $OPENCODE_SKILLS"
fi

# 6d. Install for Cursor
if [ "$INSTALL_CURSOR" -eq 1 ]; then
  _setup_arm_begin cursor global "$CURSOR_SKILLS"
  mkdir -p "$CURSOR_SKILLS"
  _activate_runtime_root create_cursor_runtime_root "$SOURCE_GSTACK_DIR" "$CURSOR_GSTACK"
  # Link before sidecar. Sidecar mkdir -p creates .cursor/skills/gstack, which
  # would make link_cursor_skill_dirs' "[ ! -d generated ]" gen fallback a no-op.
  _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.cursor/skills" "$CURSOR_SKILLS"
  link_cursor_skill_dirs "$SOURCE_GSTACK_DIR" "$CURSOR_SKILLS"
  create_cursor_sidecar "$SOURCE_GSTACK_DIR"
  _setup_arm_publish cursor global - "$CURSOR_SKILLS" "$CURSOR_GSTACK" -
  echo "gstack ready (cursor)."
  echo "  browse: $BROWSE_BIN"
  _browser_hint
  echo "  cursor skills: $CURSOR_SKILLS"
fi

# 6e. Install for GitHub Copilot CLI (the Copilot app reads the same skills)
if [ "$INSTALL_COPILOT" -eq 1 ]; then
  if _sidecar_root_user_owned "$COPILOT_GSTACK"; then
    echo "  left in place (existing dir not gstack-managed — no generated banner): $COPILOT_GSTACK" >&2
    _setup_row copilot global "$COPILOT_SKILLS" - "$SETUP_VERSION" skipped "left alone: $COPILOT_GSTACK is not gstack-managed (no generated banner)"
  else
    _setup_arm_begin copilot global "$COPILOT_SKILLS"
    mkdir -p "$COPILOT_SKILLS"
    _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.copilot/skills" "$COPILOT_SKILLS"
    link_copilot_skill_dirs "$SOURCE_GSTACK_DIR" "$COPILOT_SKILLS"
    _activate_runtime_root create_copilot_runtime_root "$SOURCE_GSTACK_DIR" "$COPILOT_GSTACK"
    _setup_arm_publish copilot global - "$COPILOT_SKILLS" "$COPILOT_GSTACK" -
    echo "gstack ready (copilot)."
    echo "  browse: $BROWSE_BIN"
    _browser_hint
    echo "  copilot skills: $COPILOT_SKILLS (invoke as /gstack-<skill>)"
  fi
fi

# 7. Create .agents/ sidecar symlinks for the real Codex skill target.
# The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack,
# so the runtime assets must live there for both global and repo-local installs.
if [ "$INSTALL_CODEX" -eq 1 ]; then
  _setup_arm_begin codex "$_CX_SCOPE" "$CODEX_SKILLS"
  create_agents_sidecar "$SOURCE_GSTACK_DIR"
  _setup_arm_publish codex "$_CX_SCOPE" "$_CX_PROJECT" "$CODEX_SKILLS" "$CODEX_GSTACK" - "${_CODEX_RENDER_ROOT:-committed}"
fi

# 8. Run pending version migrations
# Migrations handle state fixes that ./setup alone can't cover (stale config,
# orphaned files, directory structure changes). Each migration is idempotent.
# They are STATE-ROOT migrations: their completion marker lives in the state
# root they repaired ($GSTACK_STATE_ROOT/.last-setup-version), not in a global
# marker, and it only advances past a migration that succeeded. A failed
# migration is reported as a failed row and is retried by the next ./setup.
# Install/host migrations are tracked per install by the registry row version.
MIGRATIONS_DIR="$SOURCE_GSTACK_DIR/gstack-upgrade/migrations"
CURRENT_VERSION="$SETUP_VERSION"
MIGRATION_MARKER="$GSTACK_STATE_ROOT/.last-setup-version"
_LEGACY_MARKER="$HOME/.gstack/.last-setup-version"
if [ -f "$MIGRATION_MARKER" ]; then
  LAST_SETUP_VERSION="$(cat "$MIGRATION_MARKER" 2>/dev/null || echo 0.0.0.0)"
elif [ -f "$_LEGACY_MARKER" ]; then
  LAST_SETUP_VERSION="$(cat "$_LEGACY_MARKER" 2>/dev/null || echo 0.0.0.0)"
else
  LAST_SETUP_VERSION=""
fi
_MIGRATED_TO="$LAST_SETUP_VERSION"
_MIGRATION_FAILED=""
if [ -d "$MIGRATIONS_DIR" ] && [ "$CURRENT_VERSION" != "unknown" ] && [ -n "$LAST_SETUP_VERSION" ] && [ "$LAST_SETUP_VERSION" != "$CURRENT_VERSION" ]; then
  while IFS= read -r migration; do
    [ -n "$migration" ] || continue
    m_ver="$(basename "$migration" .sh | sed 's/^v//')"
    # Run if migration is newer than last setup version AND not newer than current version
    if [ "$(printf '%s\n%s' "$LAST_SETUP_VERSION" "$m_ver" | sort -V | head -1)" = "$LAST_SETUP_VERSION" ] && [ "$LAST_SETUP_VERSION" != "$m_ver" ] \
       && [ "$(printf '%s\n%s' "$m_ver" "$CURRENT_VERSION" | sort -V | tail -1)" = "$CURRENT_VERSION" ]; then
      echo "  running migration $m_ver..."
      # GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just
      # ~/.gstack state) default to ~/.claude/skills/gstack when unset —
      # a repo-local ./setup would silently no-op them against the wrong
      # tree without this.
      if CODEX_HOME="$(dirname "$CODEX_SKILLS")" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" GSTACK_STATE_ROOT="$GSTACK_STATE_ROOT" bash "$migration"; then
        _MIGRATED_TO="$m_ver"
      else
        _MIGRATION_FAILED="$m_ver"
        break
      fi
    fi
  done <<EOF
$(find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/dev/null | sort -V)
EOF
fi
mkdir -p "$GSTACK_STATE_ROOT"
if [ -n "$_MIGRATION_FAILED" ]; then
  [ -n "$_MIGRATED_TO" ] && printf '%s\n' "$_MIGRATED_TO" > "$MIGRATION_MARKER"
  echo "  warning: migration $_MIGRATION_FAILED failed; the state root stays marked at ${_MIGRATED_TO:-its previous version}, so the next ./setup retries it" >&2
  _SETUP_ROWS="$_SETUP_ROWS""state	global	$SOURCE_GSTACK_DIR	$GSTACK_STATE_ROOT	${_MIGRATED_TO:--}	$CURRENT_VERSION	failed	migration $_MIGRATION_FAILED failed; installs are active, the migration is retried by: cd $SOURCE_GSTACK_DIR && ./setup
"
elif [ "$CURRENT_VERSION" != "unknown" ]; then
  echo "$CURRENT_VERSION" > "$MIGRATION_MARKER"
fi

# 9. First-time welcome + legacy cleanup
if [ ! -f "$HOME/.gstack/.welcome-seen" ]; then
  log ""
  log "  gstack is ready. First move:"
  log "    New idea / empty repo?   /office-hours  or  /spec"
  log "    Existing code?           /qa  to see it work, or  /investigate"
  log "  (Run /gstack-upgrade anytime to stay current)"
  log ""
  # Best-effort onboarding telemetry (respects telemetry!=off; never blocks setup).
  if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then
    "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_welcome --outcome shown --no-sweep >/dev/null 2>&1 || true
  fi
  touch "$HOME/.gstack/.welcome-seen"
fi
rm -f /tmp/gstack-latest-version

# 10. Team mode: register/unregister SessionStart hook
SETTINGS_HOOK="$SOURCE_GSTACK_DIR/bin/gstack-settings-hook"
# Claude Code hooks belong to the Claude install. A setup that did not select
# Claude (--host codex, kiro, …) never heals, registers or removes them (#2347).
if [ "$INSTALL_CLAUDE" -ne 1 ]; then
  SETTINGS_HOOK=""
fi

# ─── Canonical hook paths + self-heal (phantom-hooks fix) ─────────────────────
# Hook commands written to GLOBAL settings.json must survive deletion of the
# tree setup ran from: SOURCE_GSTACK_DIR is `pwd -P` of the running tree, which
# for Conductor workspaces / manual worktrees / temp clones is EPHEMERAL —
# baking it produced dead hooks erroring on every AskUserQuestion until v1.67.
# Hook registration is therefore CANONICAL-ONLY: the stable install path below,
# or no registration at all. By this point setup has already installed/linked
# the canonical tree, so a missing canonical hook means "don't register", never
# "fall back to the running tree". The canonical path is symlink-preserving, so
# re-pointing ~/.claude/skills/gstack at a new clone heals every hook with zero
# settings writes. Repo-local --local installs don't register global Claude
# hooks (by design).
#
# WARNING for future code AND migrations (the v1.58.0.0.sh defect class):
# NEVER register ${SCRIPT_DIR}/$SOURCE_GSTACK_DIR-relative hook paths.
CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack"
# Split-brain guard: the installer currently hardcodes $HOME/.claude/skills
# (setup:1601 TODO), so a CLAUDE_CONFIG_DIR override can name a root that was
# never installed. Fall back to where the install actually lives — both are
# stable, neither is the running tree, so canonical-only still holds.
if [ ! -x "$CANONICAL_GSTACK_ROOT/bin/gstack-session-update" ] \
   && [ -x "$HOME/.claude/skills/gstack/bin/gstack-session-update" ]; then
  CANONICAL_GSTACK_ROOT="$HOME/.claude/skills/gstack"
fi

# Echo the canonical path for a hook (repo-relative arg); fails when the hook
# is not executable at the canonical install — callers must skip + log.
# A hook the parse gate below refused is never registered.
_HOOK_REFUSED=""
_hook_refused() {
  case " $_HOOK_REFUSED " in *" $1 "*) return 0 ;; esac
  return 1
}
_hook_command_path() {
  _hook_refused "$1" && return 1
  if [ -x "$CANONICAL_GSTACK_ROOT/$1" ]; then
    printf '%s\n' "$CANONICAL_GSTACK_ROOT/$1"
    return 0
  fi
  return 1
}

# Hook parse gate: Claude Code runs these shims through /bin/sh, and a shell
# file that does not parse exits 2, which for a PreToolUse hook blocks the tool
# call in every session on the machine. bin/gstack-hook-check parses every hook
# path this file resolves for registration, and the TypeScript each one runs.
# The canonical root is the checkout Claude Code runs hooks from, so its files
# are already live: there is no staged swap to abort here. Setup registers the
# hooks that parse, refuses the ones that do not (the resolver skips them), and
# exits non-zero at the end with the file and line. The auto-updater runs the
# same check before the checkout moves.
if [ -d "$CANONICAL_GSTACK_ROOT" ]; then
  _HOOK_CHECK_RC=0
  _HOOK_CHECK_OUT=$(GSTACK_HOOK_CHECK_BUN="$BUN_CMD" "$SOURCE_GSTACK_DIR/bin/gstack-hook-check" \
    --setup "$SOURCE_GSTACK_DIR/setup" "$CANONICAL_GSTACK_ROOT") || _HOOK_CHECK_RC=$?
  _HOOK_REFUSED=$(printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^fail \([^ ]*\) .*/\1/p' | awk '!seen[$0]++' | tr '\n' ' ')
  _HOOK_PASSED=$(printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^ok //p' | tr '\n' ' ')
  _HOOK_REFUSED_AT=$(printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^fail [^ ]* \([^ ]*\): .*/\1/p' | head -1)
  if [ -n "$_HOOK_REFUSED" ]; then
    {
      echo "gstack setup: refusing to register hooks that do not parse (Claude Code would block tool calls with them):"
      printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^fail [^ ]* /  /p'
      echo "  Hooks that parse register as usual: ${_HOOK_PASSED:-none}"
      echo "  Skipped: $_HOOK_REFUSED"
      echo "  This is a gstack bug; report $_HOOK_REFUSED_AT at https://github.com/garrytan/gstack/issues"
      echo "  Claude Code runs hooks straight from $CANONICAL_GSTACK_ROOT, so a skipped hook that an earlier setup registered, or that a skill file runs, keeps running the broken file until it is fixed."
      echo "  Fix: repair the file (git -C \"$CANONICAL_GSTACK_ROOT\" status shows half-applied edits or merge conflicts), then re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-hook-does-not-parse"
    } >&2
  elif [ "$_HOOK_CHECK_RC" -ne 0 ]; then
    echo "gstack setup: warning: the hook parse check could not run (exit $_HOOK_CHECK_RC); hooks are registered unchecked." >&2
  fi
fi

# Heal-first: prune dead gstack hook entries and re-point survivors at the
# stable install BEFORE any tag-presence guard below (a dead entry carrying the
# tag otherwise blocks re-registration forever — the missing-Stop-hook failure
# mode). Runs on EVERY setup, including --no-team, so upgrades self-heal
# without migrations. One log line only when something actually changed; stderr
# passes through uncaptured (zero silent failures).
if [ -x "$SETTINGS_HOOK" ]; then
  if [ -d "$CANONICAL_GSTACK_ROOT" ]; then
    _HEAL_OUT=$("$SETTINGS_HOOK" prune-stale --repoint "$CANONICAL_GSTACK_ROOT" || true)
  else
    _HEAL_OUT=$("$SETTINGS_HOOK" prune-stale || true)
  fi
  _HEAL_REMOVED=$(printf '%s' "$_HEAL_OUT" | sed -n 's/^OK: removed \([0-9]*\).*/\1/p')
  _HEAL_REPOINTED=$(printf '%s' "$_HEAL_OUT" | sed -n 's/.*repointed \([0-9]*\).*/\1/p')
  if [ "${_HEAL_REMOVED:-0}" -gt 0 ] 2>/dev/null || [ "${_HEAL_REPOINTED:-0}" -gt 0 ] 2>/dev/null; then
    log "  healed hook registrations: removed ${_HEAL_REMOVED:-0}, repointed ${_HEAL_REPOINTED:-0} (backup: settings.json.bak.<ts>; note: later registrations in this run move the rollback pointer — restore the heal's own .bak file directly if needed)"
  fi
  # Explicit opt-out + live plan-tune hooks is a contradiction worth surfacing:
  # the heal honors the opt-out (dead plan-tune entries pruned, never
  # re-pointed) but live hooks stay until the user removes them.
  if "$GSTACK_CONFIG" has plan_tune_hooks 2>/dev/null; then
    _PT_CFG_VAL=$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true)
    case "$(printf '%s' "$_PT_CFG_VAL" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in
      n|no|false|skip|off|0)
        if "$SETTINGS_HOOK" list-sources 2>/dev/null | grep -q "plan-tune-cathedral"; then
          log "  note: plan_tune_hooks is 'no' in config but live plan-tune hooks exist — remove with ./setup --no-team or $SETTINGS_HOOK remove-source --source plan-tune-cathedral"
        fi
        ;;
    esac
  fi
fi

# On Windows (Git Bash / MSYS2 / Cygwin), extensionless scripts can't be
# launched directly by the OS — the file-association dialog appears instead.
# Prefix with 'bash' so Claude Code's hook runner invokes Git Bash explicitly.
# Paths with whitespace are quoted so the hook command survives shell parsing.
SESSION_UPDATE_CMD="$(_hook_command_path bin/gstack-session-update || true)"
HOOK_CMD=""
if [ -n "$SESSION_UPDATE_CMD" ]; then
  # No caller-side quoting: add-event is the single quoting authority — it
  # normalizes every registered command through the same gsQuoteCmd round-trip
  # the healer uses, so metachar/space paths cannot drift per call site.
  if [ "$IS_WINDOWS" -eq 1 ]; then
    HOOK_CMD="bash $SESSION_UPDATE_CMD"
  else
    HOOK_CMD="$SESSION_UPDATE_CMD"
  fi
fi

if [ "$TEAM_MODE" -eq 1 ]; then
  "$GSTACK_CONFIG" set auto_upgrade true 2>/dev/null || true
  "$GSTACK_CONFIG" set team_mode true 2>/dev/null || true

  # Register SessionStart hook in Claude Code settings (schema-aware: the
  # legacy `add` action's substring dedupe bypasses the KNOWN_HOOKS identity
  # system; add-event re-points stale paths in place instead of appending).
  # stderr stays attached (zero silent settings mutations — a fail-closed
  # parse error or lock give-up must reach the user).
  if [ -x "$SETTINGS_HOOK" ] && [ -n "$HOOK_CMD" ]; then
    "$SETTINGS_HOOK" add-event --event SessionStart --command "$HOOK_CMD" --source gstack-session-update >/dev/null || true
  elif [ -z "$HOOK_CMD" ]; then
    if _hook_refused bin/gstack-session-update; then
      log "  SessionStart hook not registered: bin/gstack-session-update does not parse (see above)"
    else
      log "  SessionStart hook not registered: bin/gstack-session-update missing at $CANONICAL_GSTACK_ROOT (no stable install)"
    fi
  fi

  log ""
  if [ -n "$HOOK_CMD" ]; then
    log "Team mode enabled: gstack will auto-update at the start of each Claude Code session."
    log "  Hook: $HOOK_CMD"
  else
    log "Team mode enabled (auto-update hook pending a stable install — re-run ./setup after installing globally)."
  fi
  log "  To disable: ./setup --no-team"
  log ""
  log "Bootstrap your repo:"
  log "  cd <your-repo> && $SOURCE_GSTACK_DIR/bin/gstack-team-init required"
fi

if [ "$NO_TEAM_MODE" -eq 1 ]; then
  "$GSTACK_CONFIG" set auto_upgrade false 2>/dev/null || true
  "$GSTACK_CONFIG" set team_mode false 2>/dev/null || true

  # Remove SessionStart hook from Claude Code settings
  if [ -x "$SETTINGS_HOOK" ]; then
    "$SETTINGS_HOOK" remove "$HOOK_CMD" 2>/dev/null || true
  fi

  log "Team mode disabled: auto-update hook removed."
fi

# ─── GBrain detection + conditional SKILL.md render ─────────────────────
#
# Detect whether gbrain is installed and persist the result to
# ~/.gstack/gbrain-detection.json so gen-skill-docs can decide whether to
# render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks. If detected,
# render the Claude-host :user variant (un-suppressed brain-aware blocks)
# into an UNTRACKED out-dir — ${GSTACK_HOME}/render/claude — and repoint the
# installed skills at it (#2569). The old in-place render wrote into TRACKED
# files of the install checkout, so a global-git install stayed permanently
# dirty and every upgrade stashed 16 files of generated dirt.
#
# If gbrain is not detected, the canonical no-gbrain SKILL.md files stay
# 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"
GBRAIN_STATE_DIR="$GSTACK_STATE_ROOT"
DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json"
_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
    mv "$DETECTION_TMP" "$DETECTION_FILE"
    # Single source of truth for "is gbrain usable" — `--is-ok` runs live
    # detection (exit 0 iff ok), so setup, bin/dev-setup, and gstack-config
    # all gate on the same check instead of re-grepping the JSON.
    if [ -n "$_CLAUDE_INSTALL_RENDER" ] && [ "$INSTALL_CLAUDE" -eq 1 ]; 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
        # untracked dir (.claude/gstack-rendered), and other projects get
        # blocks via `gstack-config gbrain-refresh`.
        log "gbrain detected — GSTACK_SKIP_GBRAIN_REGEN set: leaving tracked SKILL.md canonical (dev/source tree)."
      elif [ "$INSTALL_CLAUDE" -ne 1 ]; then
        log "gbrain detected — the Claude render is left as is: Claude is not selected for this setup (rule: an explicit --host never changes another host)."
      else
        log "gbrain detected — rendering brain-aware Claude SKILL.md into $_GSTACK_RENDER_DIR (~250 token overhead per planning skill; source checkout stays clean)..."
        # Render into a tmp dir and swap it in only on SUCCESS. Installed
        # skills SYMLINK into the render dir (relink prefers it), so wiping
        # it before the render meant one transient failure left every
        # brain-aware SKILL.md link dangling — the whole skill set vanished
        # from Claude Code until a successful re-render.
        _GSTACK_RENDER_TMP="$_GSTACK_RENDER_DIR.tmp.$$"
        rm -rf "$_GSTACK_RENDER_TMP"
        if (
          cd "$SOURCE_GSTACK_DIR"
          # 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" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} 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"
          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. 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
      fi
    fi
  else
    rm -f "$DETECTION_TMP"
    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
# ~/.claude/settings.json must survive deletion of the directory setup ran
# from, and no heuristic can enumerate every ephemeral tree (manual worktrees,
# temp clones, CI checkouts) — so there is deliberately NO fallback to
# $SOURCE_GSTACK_DIR here. A missing canonical hook means "skip registration
# with a log line", never "bake the running tree's path".

# 11. Plan-tune cathedral hook install (T8).
#
# Registers PostToolUse (deterministic AUQ capture) + PreToolUse (preference
# enforcement) hooks in ~/.claude/settings.json so /plan-tune actually does
# something at runtime instead of being agent-convention. Explicit consent UX
# per D4 + Codex: never mutate settings.json silently.
#
# Idempotent via _gstack_source tag = 'plan-tune-cathedral'. If both hooks
# already registered under that tag, the install skips the consent prompt and
# only refreshes the registered command paths in place (ensure-event is a
# no-op when they already match).
PLAN_TUNE_LOG_HOOK="$(_hook_command_path hosts/claude/hooks/question-log-hook || true)"
PLAN_TUNE_PREF_HOOK="$(_hook_command_path hosts/claude/hooks/question-preference-hook || true)"
AUQ_ERROR_FALLBACK_HOOK="$(_hook_command_path hosts/claude/hooks/auq-error-fallback-hook || true)"
# Windows: extensionless bash shims need the explicit 'bash ' prefix (same
# rationale as HOOK_CMD above — the OS file-association dialog otherwise).
# KNOWN_HOOKS identity round-trips the prefix, so healing preserves it.
if [ "$IS_WINDOWS" -eq 1 ]; then
  [ -n "$PLAN_TUNE_LOG_HOOK" ] && PLAN_TUNE_LOG_HOOK="bash $PLAN_TUNE_LOG_HOOK"
  [ -n "$PLAN_TUNE_PREF_HOOK" ] && PLAN_TUNE_PREF_HOOK="bash $PLAN_TUNE_PREF_HOOK"
  [ -n "$AUQ_ERROR_FALLBACK_HOOK" ] && AUQ_ERROR_FALLBACK_HOOK="bash $AUQ_ERROR_FALLBACK_HOOK"
fi
PLAN_TUNE_INSTALL_MARKER="$HOME/.gstack/.plan-tune-hooks-prompted"

# Canonical-only: an ephemeral tree with no stable install gets a visible skip,
# never a baked worktree path.
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] \
   && { { [ -z "$PLAN_TUNE_LOG_HOOK" ] && ! _hook_refused hosts/claude/hooks/question-log-hook; } \
        || { [ -z "$PLAN_TUNE_PREF_HOOK" ] && ! _hook_refused hosts/claude/hooks/question-preference-hook; }; }; then
  log "  AskUserQuestion hooks not registered: hooks missing at $CANONICAL_GSTACK_ROOT (no stable install)"
fi

# A hook refused by the parse gate leaves its variable empty; the others in
# this block still register.
if [ "$NO_TEAM_MODE" -ne 1 ] \
   && [ -x "$SETTINGS_HOOK" ] \
   && { [ -n "$PLAN_TUNE_LOG_HOOK" ] || _hook_refused hosts/claude/hooks/question-log-hook; } \
   && { [ -n "$PLAN_TUNE_PREF_HOOK" ] || _hook_refused hosts/claude/hooks/question-preference-hook; } \
   && [ -n "$PLAN_TUNE_LOG_HOOK$PLAN_TUNE_PREF_HOOK$AUQ_ERROR_FALLBACK_HOOK" ]; then

  # Already installed? Require BOTH the plan-tune source AND the AUQ-error-fallback
  # source — so an existing install that predates the fallback hook re-runs the
  # install (which is idempotent for the plan-tune hooks) and picks up the new one.
  ALREADY_INSTALLED=0
  _HOOK_SOURCES=$("$SETTINGS_HOOK" list-sources 2>/dev/null || true)
  if printf '%s' "$_HOOK_SOURCES" | grep -q "plan-tune-cathedral" \
     && printf '%s' "$_HOOK_SOURCES" | grep -q "auq-error-fallback"; then
    ALREADY_INSTALLED=1
  fi

  # Resolve the desired action without ever blocking.
  # Priority: CLI flag (--plan-tune-hooks / --no-plan-tune-hooks)
  #         > env (GSTACK_PLAN_TUNE_HOOKS=yes|no)
  #         > saved config (plan_tune_hooks)
  #         > smart default ("prompt" → timed prompt on a real TTY, else skip).
  # This guarantees scripted/workspace setups (conductor, CI) are never
  # interactive: pass --no-plan-tune-hooks (or --plan-tune-hooks) and the
  # block runs to completion with no `read`.
  # PT_EXPLICIT provenance: an EXPLICIT decision (CLI flag, env var, or a key
  # literally present in the config file) must never be overridden by the
  # Conductor auto-opt-in below. `gstack-config get` returns the default
  # "prompt" for absent keys, so provenance uses `gstack-config has` (which
  # resolves GSTACK_STATE_ROOT/GSTACK_HOME/GSTACK_STATE_DIR the same way get
  # does — never grep a hardcoded ~/.gstack/config.yaml).
  PT_EXPLICIT=0
  if [ -n "$PLAN_TUNE_HOOKS_MODE" ]; then
    PT_DECISION="$PLAN_TUNE_HOOKS_MODE"
    PT_EXPLICIT=1
  elif [ -n "${GSTACK_PLAN_TUNE_HOOKS:-}" ]; then
    PT_DECISION="${GSTACK_PLAN_TUNE_HOOKS}"
    PT_EXPLICIT=1
  else
    PT_DECISION="$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true)"
    if "$GSTACK_CONFIG" has plan_tune_hooks 2>/dev/null; then
      PT_EXPLICIT=1
    fi
  fi
  # Normalize: strip whitespace + lowercase so "YES", "Yes", " yes" from a flag
  # or env var all resolve correctly (an unrecognized opt-in must NOT silently
  # downgrade to skip). Unknown values fall through to "prompt".
  PT_DECISION=$(printf '%s' "$PT_DECISION" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
  case "$PT_DECISION" in
    y|yes|true|install|on|1) PT_DECISION="yes" ;;
    n|no|false|skip|off|0)   PT_DECISION="no" ;;
    *)                       PT_DECISION="prompt" ;;
  esac

  # Conductor (Q3, #2207): the PreToolUse preference hook breaks Conductor's
  # native AskUserQuestion round-trip even when it only defers, so setup never
  # installs it in a Conductor workspace unless the user explicitly opted in,
  # and removes one an earlier setup added. The other plan-tune hooks are
  # unaffected.
  _PT_SKIP_PREF=0
  if { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; } \
     && ! { [ "$PT_EXPLICIT" -eq 1 ] && [ "$PT_DECISION" = "yes" ]; }; then
    _PT_SKIP_PREF=1
    if [ -n "$("$SETTINGS_HOOK" list-items --event PreToolUse --owned-by plan-tune-cathedral 2>/dev/null)" ]; then
      if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral --event PreToolUse >/dev/null 2>&1; then
        log "  removed the AskUserQuestion preference hook: it breaks Conductor's native AskUserQuestion (#2207). Keep it anyway: gstack-config set plan_tune_hooks yes, then ./setup"
      else
        log "  warning: could not remove the AskUserQuestion preference hook, which breaks Conductor's native AskUserQuestion. Fix: $SETTINGS_HOOK remove-source --source plan-tune-cathedral --event PreToolUse"
      fi
    fi
  fi

  _install_plan_tune_hooks() {
    # ensure-event (not add-event): registers when missing, RE-POINTS a stale
    # command path in place when the registration differs, and is a true no-op
    # (no write, no backup churn) when it already matches.
    # Returns non-zero if ANY registration was skipped (lock contention or a
    # fail-closed settings error) so callers log honestly instead of claiming
    # success for a mutation that never happened.
    local _pt_install_rc=0
    if [ -n "$PLAN_TUNE_LOG_HOOK" ]; then
      "$SETTINGS_HOOK" ensure-event \
        --event PostToolUse \
        --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
        --command "$PLAN_TUNE_LOG_HOOK" \
        --source plan-tune-cathedral \
        --timeout 5 || _pt_install_rc=1
    fi
    if [ "$_PT_SKIP_PREF" -eq 0 ] && [ -n "$PLAN_TUNE_PREF_HOOK" ]; then
      "$SETTINGS_HOOK" ensure-event \
        --event PreToolUse \
        --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
        --command "$PLAN_TUNE_PREF_HOOK" \
        --source plan-tune-cathedral \
        --timeout 5 || _pt_install_rc=1
    fi
    # AskUserQuestion-failure prose-fallback reliability hook (OV3:B). Fires only when
    # an AskUserQuestion call returns an error/missing result; inert on success and
    # inert if the platform doesn't invoke PostToolUse on tool errors. MUST use its
    # OWN source tag: gstack-settings-hook dedupes by (event, matcher, source) and
    # REPLACES the entry's hooks, so sharing 'plan-tune-cathedral' would overwrite the
    # question-log capture hook (same event+matcher). A distinct source = a second
    # PostToolUse entry; both run in parallel.
    if [ -n "$AUQ_ERROR_FALLBACK_HOOK" ]; then
      "$SETTINGS_HOOK" ensure-event \
        --event PostToolUse \
        --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
        --command "$AUQ_ERROR_FALLBACK_HOOK" \
        --source auq-error-fallback \
        --timeout 5 || _pt_install_rc=1
    fi
    return $_pt_install_rc
  }

  if [ "$ALREADY_INSTALLED" -eq 1 ]; then
    # Consent already recorded — no prompt. But a registration from an earlier
    # setup may carry a stale absolute path (a since-deleted dev worktree);
    # ensure-event re-points it in place and no-ops when everything matches.
    # Non-fatal to setup, but never silent: the hardened settings-hook refuses
    # to rewrite a corrupt settings.json (exit 1), and swallowing that refusal
    # left users with stale hooks and no signal.
    if ! _PT_ENSURE_ERR=$(_install_plan_tune_hooks 2>&1 >/dev/null); then
      log "  warning: settings hook update failed: $(printf '%s\n' "$_PT_ENSURE_ERR" | head -1) — run $SETTINGS_HOOK manually"
    fi
    log ""
    log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect."
  elif [ "$PT_DECISION" = "yes" ]; then
    # Explicit opt-in (flag / env / config). Non-interactive.
    if _install_plan_tune_hooks; then
      log ""
      log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
    else
      log ""
      log "  warning: some AskUserQuestion hooks were NOT registered (settings lock contention or a settings error above) — re-run ./setup to complete."
    fi
    touch "$PLAN_TUNE_INSTALL_MARKER"
  elif [ "$PT_DECISION" = "no" ]; then
    # Explicit opt-out (flag / env / config). Non-interactive.
    log ""
    log "Plan-tune cathedral hooks not installed (opted out)."
    log "Install later with: ./setup --plan-tune-hooks  (or /update-config)."
    touch "$PLAN_TUNE_INSTALL_MARKER"
  elif [ -f "$PLAN_TUNE_INSTALL_MARKER" ]; then
    # Previously declined. Don't re-ask. User can re-enable via /update-config.
    :
  elif [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then
    # Real interactive terminal with no recorded preference: ask, with explicit
    # consent + diff preview. The read is time-bounded and defaults to "skip" so
    # it can never hang an automated/forwarded TTY (the conductor failure mode).
    _PT_PROMPT_TIMEOUT=10  # single source of truth for the read + the countdown text
    log ""
    log "──────────────────────────────────────────────────────────"
    log "Plan-tune cathedral: install Claude Code hooks?"
    log "──────────────────────────────────────────────────────────"
    log ""
    log "These hooks make /plan-tune settings actually bind at runtime:"
    log "  • PostToolUse hook captures every AskUserQuestion fire (no agent"
    log "    compliance required). Today it's agent-convention and the log"
    log "    is empty in dogfood."
    log "  • PreToolUse hook enforces 'never-ask' preferences via Claude Code's"
    log "    permissionDecision protocol. Today preferences are agent-honored"
    log "    convention; this makes them binding."
    log ""
    log "Diff preview (PostToolUse capture hook):"
    "$SETTINGS_HOOK" diff-event \
      --event PostToolUse \
      --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
      --command "$PLAN_TUNE_LOG_HOOK" \
      --source plan-tune-cathedral \
      --timeout 5 2>/dev/null || true
    log ""
    log "Backup: settings.json.bak.<ts> written before any mutation."
    log "Rollback: $SETTINGS_HOOK rollback"
    log ""
    printf "Install both hooks now? [y/N] (default: N, auto-skips in %ss): " "$_PT_PROMPT_TIMEOUT"
    read -t "$_PT_PROMPT_TIMEOUT" -r PLAN_TUNE_INSTALL_REPLY </dev/tty 2>/dev/null || PLAN_TUNE_INSTALL_REPLY=""
    case "$PLAN_TUNE_INSTALL_REPLY" in
      y|Y)
        if _install_plan_tune_hooks; then
          log ""
          log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
        else
          log ""
          log "  warning: some AskUserQuestion hooks were NOT registered (settings lock contention or a settings error above) — re-run ./setup to complete."
        fi
        touch "$PLAN_TUNE_INSTALL_MARKER"
        ;;
      n|N)
        log ""
        log "Skipped. Re-run ./setup --plan-tune-hooks or use /update-config to install later."
        touch "$PLAN_TUNE_INSTALL_MARKER"
        ;;
      *)
        # Empty / timed out — treat as "ask me again" (don't persist a decline).
        log ""
        log "No response — skipped for now. Re-run ./setup --plan-tune-hooks to install."
        ;;
    esac
  else
    # Non-interactive (CI, scripted/workspace setup, quiet). Never prompt.
    log ""
    log "Plan-tune cathedral hooks not installed (non-interactive setup)."
    log "Install with: ./setup --plan-tune-hooks"
    log "  (or set GSTACK_PLAN_TUNE_HOOKS=yes, or run the commands below)"
    log "  $SETTINGS_HOOK add-event --event PostToolUse \\"
    log "    --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \\"
    log "    --command $PLAN_TUNE_LOG_HOOK --source plan-tune-cathedral --timeout 5"
    log "  $SETTINGS_HOOK add-event --event PreToolUse \\"
    log "    --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \\"
    log "    --command $PLAN_TUNE_PREF_HOOK --source plan-tune-cathedral --timeout 5"
  fi
fi

# ─── Timeline Stop hook (#2553) ──────────────────────────────────────────────
# The preamble writes event:"started" to the project timeline at every skill
# start; the completion write lives in end-of-workflow prose and is
# unenforceable — interrupted sessions leaked started > completed forever.
# Register a Stop-event hook that closes dangling entries. FAIL-OPEN contract
# (F5): the hook always exits 0 and repairs best-effort — it can never block
# a session. Removed by --no-team and gstack-uninstall.
#
# The command path is canonical-only (see _hook_command_path): a dev-worktree
# setup used to bake its own absolute dir into settings.json, so deleting the
# worktree left a dead hook erroring on every session stop — and the old
# presence-only dedup (list-sources | grep) never re-pointed it on a re-run.
# ensure-event registers when missing, replaces a stale path in place (one
# atomic write — never zero or two registrations), and no-ops when the
# registration already matches.
TIMELINE_STOP_HOOK="$(_hook_command_path hosts/claude/hooks/timeline-stop-hook || true)"
if [ "$IS_WINDOWS" -eq 1 ] && [ -n "$TIMELINE_STOP_HOOK" ]; then
  TIMELINE_STOP_HOOK="bash $TIMELINE_STOP_HOOK"
fi
# #2677: PERSISTENT gate, mirroring the plan_tune_hooks pattern. --no-team is
# (and stays) a one-shot teardown — every later bare ./setup, including the
# ones /gstack-upgrade runs, re-registered the hook with no way to say
# "never". Resolution: flag > env (GSTACK_TIMELINE_STOP_HOOK) > saved config
# (timeline_stop_hook) > default yes. An explicit FLAG persists to config so
# the decision survives upgrades; env stays session-scoped. Do NOT initialize
# NO_TEAM_MODE from config — that would silently change --no-team semantics.
if [ -n "$TIMELINE_STOP_HOOK_MODE" ]; then
  TL_DECISION="$TIMELINE_STOP_HOOK_MODE"; TL_SOURCE="flag"
elif [ -n "${GSTACK_TIMELINE_STOP_HOOK:-}" ]; then
  TL_DECISION="${GSTACK_TIMELINE_STOP_HOOK}"; TL_SOURCE="env GSTACK_TIMELINE_STOP_HOOK"
else
  TL_DECISION="$("$GSTACK_CONFIG" get timeline_stop_hook 2>/dev/null || true)"
  TL_SOURCE="config timeline_stop_hook"
fi
TL_DECISION=$(printf '%s' "$TL_DECISION" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
TL_UNRECOGNIZED=0
case "$TL_DECISION" in
  n|no|false|skip|off|0) TL_DECISION="no" ;;
  y|yes|true|on|1|"")    TL_DECISION="yes" ;;
  *)
    # A typo'd value (--timeline-stop-hook=noo) must not silently become a
    # PERSISTED "yes" — warn, apply the default for this run only.
    log "  WARNING: unrecognized timeline-stop-hook value '$TL_DECISION' (from $TL_SOURCE) — using default 'yes' for this run, not persisting"
    TL_DECISION="yes"; TL_UNRECOGNIZED=1 ;;
esac
if [ -n "$TIMELINE_STOP_HOOK_MODE" ] && [ "$TL_UNRECOGNIZED" -eq 0 ]; then
  "$GSTACK_CONFIG" set timeline_stop_hook "$TL_DECISION" >/dev/null 2>&1 || true
fi
if [ "$TL_DECISION" = "no" ] && [ -x "$SETTINGS_HOOK" ]; then
  # Reconciliation arm: an explicit "no" with a live registration removes it —
  # the opt-out works even when the hook was registered by an older setup.
  "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop >/dev/null 2>&1 || true
  log "  timeline Stop hook disabled (via $TL_SOURCE) — removed its registration if one existed"
fi
if [ "$NO_TEAM_MODE" -ne 1 ] && [ "$TL_DECISION" != "no" ] && [ -x "$SETTINGS_HOOK" ] && [ -n "$TIMELINE_STOP_HOOK" ]; then
  if _TL_ENSURE_OUT=$("$SETTINGS_HOOK" ensure-event \
    --event Stop \
    --command "$TIMELINE_STOP_HOOK" \
    --source gstack-timeline-stop \
    --timeout 5 2>&1); then
    case "$_TL_ENSURE_OUT" in
      *unchanged*)
        : # already registered with the canonical command — quiet no-op
        ;;
      *re-pointed*)
        log "  re-pointed Stop hook to $TIMELINE_STOP_HOOK (previous registration held a stale path)"
        ;;
      *)
        log "  registered Stop hook: session timeline entries now close even when a skill is interrupted (backup: settings.json.bak.<ts>; remove: $SETTINGS_HOOK remove-source --source gstack-timeline-stop)"
        ;;
    esac
  else
    # Non-fatal to setup, but never silent: the hardened settings-hook refuses
    # to mutate a corrupt settings.json (exit 3) or under a held lock (exit 5),
    # and swallowing that refusal left the Stop hook unregistered with no signal.
    log "  warning: settings hook update failed: $(printf '%s\n' "$_TL_ENSURE_OUT" | head -1) — run $SETTINGS_HOOK manually"
  fi
fi

# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
# Tag-only remove-source misses untagged entries (Claude Code strips
# _gstack_source), so the identity sweep (prune-stale --all) finishes the job.
# stderr stays attached on every call: a lock give-up or fail-closed parse
# error during TEARDOWN must be visible — "the next setup retries" does not
# apply when the user is turning the hooks off.
if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then
  "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral >/dev/null || true
  "$SETTINGS_HOOK" remove-source --source auq-error-fallback >/dev/null || true
  "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop >/dev/null || true
  # verify-gate and gstack-memorable are user-registered opt-ins unrelated to
  # team mode -- turning team mode off must not delete them (uninstall still
  # sweeps both, correctly, because there the binaries themselves are removed).
  GSTACK_SWEEP_EXCLUDE_SOURCES="verify-gate,gstack-memorable" "$SETTINGS_HOOK" prune-stale --all >/dev/null || true
fi

# ─── Redact pre-push guard consent (#1946) ───────────────────────────────────
# The credential pre-push hook is per-REPO state — setup runs in the gstack
# checkout, the wrong repo to install it into, so setup NEVER installs the
# hook itself. /ship installs it silently in any repo where
# redact_prepush_hook=true. What setup owns is CONSENT: on a real interactive
# terminal it asks ONCE whether pushes should be scanned, recording the
# answer to the existing redact_prepush_hook key (default stays false — a
# timeout or non-interactive run changes nothing and keeps the hint-only
# posture). An explicit answer is persisted and never re-asked; an explicit
# "false" is a recorded decline (adversarial review finding 11).
# `gstack-config get` defaults absent keys to "false", which is
# indistinguishable from a decline — test key presence in the config file
# of the resolved state root (the twin beside gstack-config).
. "$(dirname "$GSTACK_CONFIG")/gstack-state-root.sh" 2>/dev/null && gstack_state_root_select
_GSTACK_CFG_FILE="${_gstack_sr_root:-$GSTACK_STATE_ROOT}/config.yaml"
if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then
  if [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then
    _REDACT_PROMPT_TIMEOUT=10
    log ""
    log "Credential push guard: gstack can block pushes containing credentials"
    log "(a per-repo git pre-push hook; /ship installs it automatically in every"
    log "repo you ship from — nothing is installed right now)."
    printf "Enable the pre-push credential guard? [y/N] (default: N, auto-skips in %ss): " "$_REDACT_PROMPT_TIMEOUT"
    read -t "$_REDACT_PROMPT_TIMEOUT" -r _REDACT_REPLY </dev/tty 2>/dev/null || _REDACT_REPLY=""
    case "$_REDACT_REPLY" in
      y|Y)
        "$GSTACK_CONFIG" set redact_prepush_hook true 2>/dev/null || true
        log "Enabled. /ship will install the guard in each repo at first push."
        ;;
      n|N)
        "$GSTACK_CONFIG" set redact_prepush_hook false 2>/dev/null || true
        log "Declined — recorded. Re-enable anytime: gstack-config set redact_prepush_hook true"
        ;;
      *)
        # Timed out / empty: don't persist a decline — hint and ask next time.
        log ""
        log "Skipped for now. Enable anytime: gstack-config set redact_prepush_hook true"
        ;;
    esac
  else
    log ""
    log "Tip: gstack can block pushes containing credentials (per-repo git hook)."
    log "     Enable once: gstack-config set redact_prepush_hook true — /ship"
    log "     installs the hook automatically in every repo you ship from."
  fi
fi

_setup_print_summary

# ─── CSO native-helper summary ────────────────────────────────────────────────
if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then
  case "$CSO_FAIL_REASON" in
    bun-compile-flags) _CSO_PREREQ="upgrade Bun to a release supporting all four --no-compile-autoload-* build flags" ;;
    c-compiler) _CSO_PREREQ="install a C compiler (cc, Clang, or GCC)" ;;
    static-c-toolchain) _CSO_PREREQ="install a C toolchain capable of static linking" ;;
    macos-codesign|macos-native-toolchain) _CSO_PREREQ="install the macOS compiler and codesign command-line tools" ;;
    windows-shell-toolchain) _CSO_PREREQ="run setup from Git Bash with Windows PowerShell available" ;;
    windows-msvc-toolchain) _CSO_PREREQ="install Visual Studio 2022 Build Tools with Desktop development with C++" ;;
    windows-msvc-compile) _CSO_PREREQ="fix the MSVC compile error its toolchain probe reported (${CSO_PROBE_DETAIL:-see scripts/build-cso-windows.ps1 -CheckOnly}); Visual Studio is already installed" ;;
    windows-git) _CSO_PREREQ="install Git for Windows and run setup from its Git Bash" ;;
    unsupported-platform) _CSO_PREREQ="use a supported macOS, Linux, or Windows host" ;;
    skipped-by-request) _CSO_PREREQ="re-run without GSTACK_SETUP_SKIP_CSO_BUILD=1" ;;
    *) _CSO_PREREQ="install the native CSO build prerequisites" ;;
  esac
  log ""
  log "CSO unavailable: its native helper was not built ($CSO_FAIL_REASON)."
  log "  /cso will report not assessed and the install prerequisite; it will not use repository tooling."
  log "  Everything else is installed and works. To enable /cso, $_CSO_PREREQ, then re-run ./setup."
fi

# ─── Chromium bootstrap summary (best-effort browser, see # 2) ───────────────
# Printed LAST so it is the thing the user sees, after every skill registered.
# The skills that drive Aside first and use the bundled browser only as fallback
# (/pair-agent is not among them: it always runs on gstack's own browser). The
# Aside-absent list is derived from it so the two never drift.
_PW_ASIDE_SKILLS="/qa, /qa-only, /design-review, /browse, /scrape, /benchmark, /canary, make-pdf, /diagram"
_PW_BROWSER_SKILLS="$_PW_ASIDE_SKILLS, /pair-agent, and any other skill that drives the browser"
if [ "${_PW_FAIL_REASON:-}" = "skipped" ]; then
  # An explicit opt-out is not a failure: say what is unavailable and stop.
  log ""
  log "Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1)."
  log "  Browser skills ($_PW_BROWSER_SKILLS) need it; re-run ./setup without the flag when you want them."
elif [ -n "${_PW_FAIL_REASON:-}" ]; then
  log ""
  log "Browser unavailable: Chromium bootstrap did not complete ($_PW_FAIL_REASON)."
  if [ "${GSTACK_SKIP_ASIDE:-}" != "1" ] && command -v aside >/dev/null 2>&1; then
    # Aside is the primary driver; the bundled browser is its fallback. Say so,
    # instead of telling an Aside user their browser skills are gone.
    log "  Aside is installed, so $_PW_ASIDE_SKILLS keep running there; only their bundled fallback is missing."
    log "  /pair-agent needs the bundled browser itself."
  else
    log "  Skills that need it: $_PW_BROWSER_SKILLS."
  fi
  log "  Everything else is installed and works. Fix the cause and re-run ./setup."
  case "$_PW_FAIL_REASON" in
    *chromium-install-timeout*) log "  Slow link? Raise the bound: GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800 ./setup" ;;
  esac
  case "$_PW_FAIL_REASON" in
    *post-install-launch*) log "  Ubuntu 24.04+ (AppArmor user namespaces): GSTACK_CHROMIUM_NO_SANDBOX=1 ./setup (#2157)" ;;
  esac
  # Reason code only — never a path, hostname, or the installer's output. The
  # event is a one-shot with no session of its own, so --no-sweep keeps it
  # from finalizing other live sessions' in-flight .pending markers as
  # outcome:unknown. Telemetry-gated inside gstack-telemetry-log.
  if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then
    "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_playwright --outcome "$_PW_FAIL_REASON" --no-sweep >/dev/null 2>&1 || true
  fi
fi
if [ ${#_FOREIGN_SKIPPED_ENTRIES[@]} -gt 0 ]; then
  log ""
  log "Not registered (a skill you own already uses the name; left untouched): ${_FOREIGN_SKIPPED_ENTRIES[*]}"
  log "  Rename or move yours, or switch modes (./setup --prefix / --no-prefix) so the names no longer collide."
fi
if [ ${#_BACKED_UP_SKILL_MDS[@]} -gt 0 ]; then
  log ""
  log "Moved ${#_BACKED_UP_SKILL_MDS[@]} customized SKILL.md file(s) to $_SKILL_BACKUP_ROOT before installing gstack's: ${_BACKED_UP_SKILL_MDS[*]}"
  log "  Those were gstack-generated files you had edited; restore anything you meant to keep under a different skill name."
fi
if [ -n "${_HOOK_REFUSED:-}" ]; then
  echo "" >&2
  echo "gstack setup: everything else is installed, but these hooks were not registered because they do not parse: $_HOOK_REFUSED(first: $_HOOK_REFUSED_AT). This is a gstack bug; report $_HOOK_REFUSED_AT. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-hook-does-not-parse" >&2
  exit 1
fi
