#!/usr/bin/env bash # gstack-skill-start — the skill preamble's runtime, consolidated (token-reduction Phase 1). # # Absorbs the bash that every generated SKILL.md used to inline twice over # (the "Preamble (run first)" bootstrap fence and the "Artifacts Sync" fence, # ~13KB per skill x 50 skills). The generated skill now carries a one-line # invocation; this script emits the SAME `KEY: value` STATUS lines the prose # rules interpret. The contract is pinned by test/gstack-skill-start.test.ts # (every KEY the rendered prose references must be emitted here). # # Enumerated divergences from the inline original (plan EOV5): # - Paths resolve $0-relative (works for every host + install layout) instead # of gen-time interpolation. # - Session identity is the HARNESS pid, derived here: the nearest non-shell # ancestor ($PPID inside this script is the ephemeral tool-call shell — # wrong identity). --parent-pid still overrides it. The rendered command # passes no "$PPID" (#2763: worktree-isolated Claude Code sessions refuse # variables and fallbacks in the start command). # - State paths honor ${GSTACK_HOME} (EOV7); the inline original hardcoded # ~/.gstack in the bootstrap half. # - SESSION_ID / TEL_START are echoed (the inline shell vars never survived # across Bash tool calls, so skill-end durations were already broken; the # echo makes them real). # - SKILL_START_PROTO handshake (OV5): prose treats a missing/unexpected # proto as a degraded install and applies safe defaults. # - Passthrough sub-command output is sanitized (OV4): GSTACK_INSTRUCTION # markers cannot be injected into the blessed tool result by learnings, # update-check, or first-task text. # # Error style (F3): per-line `|| true`, never `set -e` — a mid-script failure # must not drop later STATUS lines. # Heredoc bodies in the 512B-64KiB window can deadlock bash 5.1+'s pipe-backed # heredoc path when the reader stalls; compat level 50 restores the tempfile # path. This script is bash-3.2-clean, so the compat level costs nothing # (same guard as bin/gstack-brain-sync; pinned by # test/heredoc-pipe-deadlock.test.ts). BASH_COMPAT=50 SKILL_NAME="" MODEL_OVERLAY="none" PARENT_PID="" BRAIN_HEALTH="no" while [ $# -gt 0 ]; do case "$1" in --skill) SKILL_NAME="$2"; shift 2 ;; --model) MODEL_OVERLAY="$2"; shift 2 ;; --parent-pid) PARENT_PID="$2"; shift 2 ;; --brain-health) BRAIN_HEALTH="yes"; shift ;; -h|--help) echo 'Usage: gstack-skill-start --skill NAME [--model OVERLAY] [--parent-pid PID] [--brain-health]' echo 'Starts a skill session and prints its STATUS lines. --help starts nothing.' exit 0 ;; *) shift ;; esac done [ -n "$SKILL_NAME" ] || SKILL_NAME="unknown" # Harness pid: walk up from the calling shell past shell wrappers # (bin/gstack-harness-pid.sh, shared with bin/gstack-lease so the install # lease and the session marker name the same pid). Without the helper the # immediate parent stands. # shellcheck source=bin/gstack-harness-pid.sh . "$(dirname "$0")/gstack-harness-pid.sh" 2>/dev/null || gstack_harness_pid() { printf '%s\n' "$1"; } if [ -z "$PARENT_PID" ]; then PARENT_PID=$(gstack_harness_pid "$PPID") fi _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _BIN="$_SCRIPT_DIR" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select _GH="$_gstack_sr_root" # OV4: strip instruction markers from any passthrough text before echoing it # into the blessed tool result. Prior-session/repo content must not be able to # mint directive blocks — nor forge a SESSION_ID: status line (the token that # authenticates instruction blocks), so line-leading spoofs are neutralized too. _sanitize() { sed -e 's/GSTACK_INSTRUCTION/GSTACK-INSTRUCTION-(stripped)/g' -e 's/^SESSION_ID:/SESSION-ID-(stripped):/'; } echo "SKILL_START_PROTO: 1" # Session kind resolves FIRST (env-only checks, cheap). Spawned sessions skip # the network-bound update-check: its only consumer (the upgrade-flow block) # is emission-gated on kind != spawned below, so the work would be discarded — # and running it would consume the one-shot just-upgraded marker meant for the # next human session (#2733 review). _SESSION_KIND=$("$_BIN/gstack-session-kind" 2>/dev/null || echo "interactive") # Unknown kinds coerce to interactive (the degrade-safe default) but say so # (plan B1): a mixed-version fleet whose newer parent sets a kind this install # does not know must be loud, never silently interactive. _SESSION_KIND_NOTE="" case "$_SESSION_KIND" in unattended|spawned|headless|interactive) ;; *) _SESSION_KIND_NOTE=" (unknown kind '$(printf '%s' "$_SESSION_KIND" | tr -cd 'a-zA-Z0-9._-')')"; _SESSION_KIND="interactive" ;; esac # gstack-session-kind ignores an unrecognized GSTACK_SESSION_KIND value (it # falls through to ambient detection); name it here so the override is visible. case "${GSTACK_SESSION_KIND:-}" in ''|unattended|spawned|headless|interactive) ;; *) [ -z "$_SESSION_KIND_NOTE" ] && _SESSION_KIND_NOTE=" (unknown kind '$(printf '%s' "$GSTACK_SESSION_KIND" | tr -cd 'a-zA-Z0-9._-')')" ;; esac # Unattended (plan B1): a parent agent drives this run and reads its artifacts. # No prompts, no publishing, no sync: the same suppression spawned gets, plus # the writers and sync entrypoints below skip with an explicit line. Keyed on # the resolved kind, so only the explicit GSTACK_SESSION_KIND override lights it. _NO_HUMAN="no" case "$_SESSION_KIND" in spawned|unattended) _NO_HUMAN="yes" ;; esac # Durability of the state root (plan A3): GSTACK_EPHEMERAL=1 is the host's # marker for a disk that vanishes with the machine (a Capy cloud box). Printed # once per session so writers that skip on an ephemeral root are explained. _DURABLE="yes" [ "${GSTACK_EPHEMERAL:-}" = "1" ] && _DURABLE="no" _UPD="" if [ "$_NO_HUMAN" != "yes" ]; then _UPD=$("$_BIN/gstack-update-check" 2>/dev/null || true) [ -n "$_UPD" ] && printf '%s\n' "$_UPD" | _sanitize || true fi # Session markers: local functional state (live-session count), never sent and # not gated on telemetry (#1150). README "Privacy & Telemetry" describes them. mkdir -p "$_GH/sessions" 2>/dev/null || true touch "$_GH/sessions/$PARENT_PID" 2>/dev/null || true find "$_GH/sessions" -mmin +120 -type f -exec rm {} + 2>/dev/null || true # The install-scoped twin (bin/gstack-lease): what bin/gstack-upgrade reads # before moving a checkout other sessions share. Fail-open, but a lease the # held lock kept from being written is reported. "$_BIN/gstack-lease" touch --pid "$PARENT_PID" >/dev/null 2>&1 || { [ $? -eq 3 ] && echo "gstack-lease: the lease directory stayed locked by a live process; this session holds no workflow lease, so bin/gstack-upgrade cannot see it" >&2 true } _SESSIONS=$(find "$_GH/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ') _PROACTIVE=$("$_BIN/gstack-config" get proactive 2>/dev/null || echo "core") # A hand-edited config can hold anything: drop quotes and a trailing comment, # lowercase, read the legacy true/false and the YAML yes/no family as all/off # (an opt-out must never fail open), and anything else as core, so STATUS only # ever says core | all | off. _PROACTIVE=$(printf '%s' "$_PROACTIVE" | sed -e 's/[[:space:]]*#.*$//' -e "s/^[[:space:]]*[\"']*//" -e "s/[\"']*[[:space:]]*$//" | tr '[:upper:]' '[:lower:]') case "$_PROACTIVE" in true|yes|y|on|1) _PROACTIVE="all" ;; false|no|n|none|0|disabled|never) _PROACTIVE="off" ;; core|all|off) ;; *) _PROACTIVE="core" ;; esac # off silences every unasked surface below: the tips, the one-time prompt and # the CLAUDE.md routing offer. A stored off also answers the one-time prompt, # so the marker is written here, before STATUS reports it. case "$_PROACTIVE" in off) _PROACTIVE_OFF="yes" ;; *) _PROACTIVE_OFF="no" ;; esac _PROACTIVE_PROMPTED=$([ -f "$_GH/.proactive-prompted" ] && echo "yes" || echo "no") if [ "$_PROACTIVE_PROMPTED" = "no" ] && [ "$_PROACTIVE_OFF" = "yes" ]; then touch "$_GH/.proactive-prompted" 2>/dev/null || true _PROACTIVE_PROMPTED="yes" fi # Branch name is embedded in concatenated JSON below; strip anything outside # the gstack-slug BRANCH alphabet so a hostile ref name can't forge fields. _BRANCH=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-') _BRANCH=${_BRANCH:-unknown} echo "BRANCH: $_BRANCH" _SKILL_PREFIX=$("$_BIN/gstack-config" get skill_prefix 2>/dev/null || echo "false") echo "PROACTIVE: $_PROACTIVE" echo "PROACTIVE_PROMPTED: $_PROACTIVE_PROMPTED" echo "SKILL_PREFIX: $_SKILL_PREFIX" REPO_MODE="" eval "$("$_BIN/gstack-repo-mode" 2>/dev/null)" 2>/dev/null || true REPO_MODE=${REPO_MODE:-unknown} echo "REPO_MODE: $REPO_MODE" echo "SESSION_KIND: $_SESSION_KIND$_SESSION_KIND_NOTE" # Capy (capy/skill/gstack/captain.md): GSTACK_CAPY=1 comes from the Capy launch # line. On, every decision goes through Capy (Captain or person) and the # capy-asks block below says how; GSTACK_CAPY_ASKS=0 keeps the line but drops # the block. _CAPY=off [ "${GSTACK_CAPY:-}" = "1" ] && _CAPY=on if [ "$_CAPY" = "on" ] && [ "${GSTACK_CAPY_ASKS:-}" = "0" ]; then echo "CAPY: on (asks block off: GSTACK_CAPY_ASKS=0)"; else echo "CAPY: $_CAPY"; fi echo "STATE_ROOT: $_GH durable=$_DURABLE" echo "SESSIONS: ${_SESSIONS:-0}" # Install admission (field wave A2): an unattended or Capy-spawned run on an # unsupported Bun, or on a Capy machine without a matching install verdict, # refuses here instead of running anyway. Rules: bin/gstack-install-verdict.sh. if . "$_BIN/gstack-install-verdict.sh" 2>/dev/null && . "$_BIN/gstack-bun-version.sh" 2>/dev/null && . "$_BIN/gstack-doctor-components.sh" 2>/dev/null; then gstack_install_admission "$_BIN" "$SKILL_NAME" "$_SESSION_KIND" [ -n "$_ia_line" ] && echo "$_ia_line" if [ "$_ia_refuse" = 1 ]; then # The preamble already honors instruction blocks bound to this run's # SESSION_ID, so the stop rule rides here instead of growing every skill. _ia_sid="$PARENT_PID-$(date +%s)-$(od -An -N4 -tx4 /dev/urandom 2>/dev/null | tr -d ' \n')" _ia_result="GSTACK_RESULT: skill=$(printf '%s' "$SKILL_NAME" | tr -cd 'a-zA-Z0-9._-') status=refused run=-" echo "SESSION_ID: $_ia_sid" echo "GSTACK_INSTRUCTION_BEGIN: install-refused $_ia_sid" echo "This workflow refused to start (the INSTALL: row above). STOP: report that row and its fix: command to the parent, run nothing else from this skill, and end with \`$_ia_result\`." echo "GSTACK_INSTRUCTION_END" echo "$_ia_result" exit 3 fi fi # Tamper visibility (#2733 review): a repo-provided settings env block or a CI # wrapper could set GSTACK_SESSION_KIND or OPENCLAW_SESSION for a real human's # session, silently flipping every gate to auto-choose. Surface the env-driven # override loudly, naming the driver, so the transcript shows WHY this session # is spawned. Gated on the RESOLVED kind so a broken session-kind fallback # never prints a contradictory override line next to SESSION_KIND: interactive. if [ "$_SESSION_KIND" = "spawned" ]; then if [ "${GSTACK_SESSION_KIND:-}" = "spawned" ]; then echo "SPAWNED_OVERRIDE: env (GSTACK_SESSION_KIND)" elif [ -n "${OPENCLAW_SESSION:-}" ]; then echo "SPAWNED_OVERRIDE: env (OPENCLAW_SESSION)" fi fi # Conductor host: AskUserQuestion is unreliable there (native disabled, MCP # variant flaky); skills render decisions as prose. Gated on !headless so an # eval/CI run INSIDE Conductor still BLOCKs rather than rendering prose to # nobody, and on !spawned so an orchestrator- or parent-skill-spawned session # inside a Conductor workspace auto-chooses per its spawned-session block # instead of rendering prose to nobody (#2733). if [ "$_SESSION_KIND" != "headless" ] && [ "$_SESSION_KIND" != "spawned" ] && [ "$_SESSION_KIND" != "unattended" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then echo "CONDUCTOR_SESSION: true" fi _ACTIVATED=$([ -f "$_GH/.activated" ] && echo "yes" || echo "no") _FIRST_LOOP_SHOWN=$([ -f "$_GH/.first-loop-tip-shown" ] && echo "yes" || echo "no") echo "ACTIVATED: $_ACTIVATED" echo "FIRST_LOOP_SHOWN: $_FIRST_LOOP_SHOWN" # First-run project detection: only on the first-ever skill run (off the hot # path after). Spawned excluded like headless (#2733 review): spawned sessions # never write .activated, so without the exclusion the ~17-subprocess probe # would re-run on EVERY spawned start while its only consumers stay suppressed. _FIRST_TASK="" if [ "$_ACTIVATED" = "no" ] && [ "$_SESSION_KIND" != "headless" ] && [ "$_NO_HUMAN" != "yes" ]; then _FIRST_TASK=$("$_BIN/gstack-first-task-detect" 2>/dev/null || true) fi printf 'FIRST_TASK: %s\n' "$_FIRST_TASK" | _sanitize _LAKE_SEEN=$([ -f "$_GH/.completeness-intro-seen" ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" _TEL=$("$_BIN/gstack-config" get telemetry 2>/dev/null || true) _TEL_PROMPTED=$([ -f "$_GH/.telemetry-prompted" ] && echo "yes" || echo "no") _TEL_START=$(date +%s) # PID+epoch alone is guessable; a random suffix makes the block-binding token # unforgeable by content reflected into the same tool result. Falls back to # the plain form when urandom is unavailable. _SID_RAND=$(od -An -N4 -tx4 /dev/urandom 2>/dev/null | tr -d ' \n' || true) _SESSION_ID="$PARENT_PID-$_TEL_START${_SID_RAND:+-$_SID_RAND}" echo "TELEMETRY: ${_TEL:-off}" echo "TEL_PROMPTED: $_TEL_PROMPTED" echo "SESSION_ID: $_SESSION_ID" echo "TEL_START: $_TEL_START" _EXPLAIN_LEVEL=$("$_BIN/gstack-config" get explain_level 2>/dev/null || echo "default") if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL" _QUESTION_TUNING=$("$_BIN/gstack-config" get question_tuning 2>/dev/null || echo "false") echo "QUESTION_TUNING: $_QUESTION_TUNING" _UPDATE_CHECK=$("$_BIN/gstack-config" get update_check 2>/dev/null || echo "true") echo "UPDATE_CHECK: $_UPDATE_CHECK" mkdir -p "$_GH/analytics" 2>/dev/null || true # Privacy at the writer (plan B1): an unattended session never records usage or # sends telemetry, even with an inherited enabled setting or pending queue work; # the pending files stay for the next human session to drain. if [ "$_SESSION_KIND" = "unattended" ]; then echo "TELEMETRY_WRITE: skipped (unattended session)" else if [ "$_TEL" != "off" ]; then echo '{"skill":"'"$SKILL_NAME"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> "$_GH/analytics/skill-usage.jsonl" 2>/dev/null || true fi for _PF in $(find "$_GH/analytics" -maxdepth 1 -name '.pending-*' 2>/dev/null); do if [ -f "$_PF" ]; then if [ "$_TEL" != "off" ] && [ -x "$_BIN/gstack-telemetry-log" ]; then "$_BIN/gstack-telemetry-log" --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true fi rm -f "$_PF" 2>/dev/null || true fi # Deliberate throttle: drain at most ONE orphaned pending file per skill # start so a backlog can't stall the preamble. break done fi eval "$("$_BIN/gstack-slug" 2>/dev/null)" 2>/dev/null || true for _former in $("$_BIN/gstack-slug" --get FORMER_SLUGS 2>/dev/null || true); do echo "gstack: earlier data for this project is in projects/$_former (a worktree's old bucket). Merge it: gstack-slug --adopt-legacy --from $_former (or stop this reminder: gstack-slug --adopt-legacy --dismiss $_former)" done _LEARN_FILE="$_GH/projects/${SLUG:-unknown}/learnings.jsonl" if [ -f "$_LEARN_FILE" ]; then _LEARN_COUNT=$(wc -l < "$_LEARN_FILE" 2>/dev/null | tr -d ' ') echo "LEARNINGS: $_LEARN_COUNT entries loaded" if [ "$_LEARN_COUNT" -gt 5 ] 2>/dev/null; then _LEARN_ERR=$(mktemp "${TMPDIR:-/tmp}/gstack-learn-err.XXXXXX" 2>/dev/null) || _LEARN_ERR=/dev/null if ! "$_BIN/gstack-learnings-search" --limit 3 2>"$_LEARN_ERR" | _sanitize; then echo "LEARNINGS: unavailable ($(head -n 1 "$_LEARN_ERR" 2>/dev/null))" fi [ "$_LEARN_ERR" = /dev/null ] || rm -f "$_LEARN_ERR" fi else echo "LEARNINGS: 0" fi # Plan A3: on an ephemeral root an unattended run's learnings would be written # to a disk that vanishes; say so instead of writing silently. if [ "$_SESSION_KIND" = "unattended" ] && [ "$_DURABLE" = "no" ]; then echo "learnings: skipped (state root is ephemeral; set GSTACK_STATE_ROOT)" fi # Plan B1: the autoplan publication hook executes only inside Claude Code # (CLAUDECODE is set there; GSTACK_ACTIVE_HOST names an env-var host). On # every other host, and in every unattended session, the hook is rendered but # never runs, so publication order rests on the per-phase snapshot hashes. # Claude Code's deny-on-absent behavior is untouched: this line is printed, # never used to allow anything. B0's validator requires it in an unattended # run's review-record.md. if [ "$SKILL_NAME" = "autoplan" ]; then if [ "$_SESSION_KIND" = "unattended" ] || [ -z "${CLAUDECODE:-}" ] || { [ -n "${GSTACK_ACTIVE_HOST:-}" ] && [ "${GSTACK_ACTIVE_HOST:-}" != "claude" ]; }; then echo "autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED)" fi fi "$_BIN/gstack-timeline-log" '{"skill":"'"$SKILL_NAME"'","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & _HAS_ROUTING="no" for _RF in CLAUDE.md AGENTS.md; do if [ -f "$_RF" ] && grep -q "## Skill routing" "$_RF" 2>/dev/null; then _HAS_ROUTING="yes" fi done _ROUTING_DECLINED=$("$_BIN/gstack-config" get routing_declined 2>/dev/null || echo "false") echo "HAS_ROUTING: $_HAS_ROUTING" echo "ROUTING_DECLINED: $_ROUTING_DECLINED" _VENDORED="no" if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then if [ -f ".claude/skills/gstack/VERSION" ] || [ -d ".claude/skills/gstack/.git" ]; then _VENDORED="yes" fi fi echo "VENDORED_GSTACK: $_VENDORED" echo "MODEL_OVERLAY: $MODEL_OVERLAY" # Plan-mode hint for skills that branch on plan-mode state. Detected best-effort # from CLAUDE_PLAN_FILE (set by the harness when plan mode is active); "inactive" # is the safe default (file+execute pipeline). if [ -n "${CLAUDE_PLAN_FILE:-}${GSTACK_PLAN_MODE_FORCE:-}" ]; then GSTACK_PLAN_MODE="active" elif [ "${GSTACK_PLAN_MODE:-}" = "active" ]; then GSTACK_PLAN_MODE="active" else GSTACK_PLAN_MODE="inactive" fi echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE" # Keyed on the resolved kind, not raw OPENCLAW_SESSION (#2733): the explicit # GSTACK_SESSION_KIND=spawned override must light this up too. [ "$_SESSION_KIND" = "spawned" ] && echo "SPAWNED_SESSION: true" || true [ "$_SESSION_KIND" = "unattended" ] && echo "UNATTENDED_SESSION: true" || true [ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no new PR; an open PR at HEAD gets its body updated (pr-body section, receipt first), else Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md; end with gstack-ship-receipt result" || true # --------------------------------------------------------------------------- # Artifacts sync (the former "Artifacts Sync (skill start)" fence, verbatim # modulo GSTACK_HOME + $0-relative bins). # --------------------------------------------------------------------------- if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then _BRAIN_REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" else _BRAIN_REMOTE_FILE="$HOME/.gstack-brain-remote.txt" fi _BRAIN_SYNC_BIN="$_BIN/gstack-brain-sync" _BRAIN_CONFIG_BIN="$_BIN/gstack-config" # /sync-gbrain context-load hint. Per-worktree pin via kubectl-style # .gbrain-source at the git toplevel; empty output when gbrain is not # configured (zero context cost for non-gbrain users). _GBRAIN_CONFIG="$HOME/.gbrain/config.json" if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || true) if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then _GBRAIN_PIN_PATH="" _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" fi if [ -n "$_GBRAIN_PIN_PATH" ]; then echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." echo "Run /sync-gbrain to refresh." else echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" echo "before relying on \`gbrain search\` for code questions in this worktree." echo "Falls back to Grep until pinned." fi fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) # Remote-MCP mode detection (Path 4 of /setup-gbrain): read claude.json # directly (no subprocess to claude CLI on the hot path). Both registration # scopes are read (#2499): nearest-ancestor project scope first, then user # scope; project-local BEATS user scope; the ancestor match accepts both path # separators (Windows project keys are backslash-formed). _GBRAIN_MCP_MODE="none" _GBRAIN_MCP_ENTRY="" # Cheap substring pre-filter: ~/.claude.json can be megabytes on real installs; # skip the full jq parse when no gbrain server is registered at all. if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ] && grep -q '"gbrain"' "$HOME/.claude.json" 2>/dev/null; then _GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null) _GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null) case "$_GBRAIN_MCP_TYPE" in url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;; stdio) _GBRAIN_MCP_MODE="local-stdio" ;; esac fi if [ -f "$_BRAIN_REMOTE_FILE" ] && [ ! -d "$_GH/.git" ] && [ "$_BRAIN_SYNC_MODE" = "off" ]; then _BRAIN_NEW_URL=$(head -1 "$_BRAIN_REMOTE_FILE" 2>/dev/null | tr -d '[:space:]') if [ -n "$_BRAIN_NEW_URL" ]; then printf 'ARTIFACTS_SYNC: artifacts repo detected: %s\n' "$_BRAIN_NEW_URL" | _sanitize echo "ARTIFACTS_SYNC: run 'gstack-brain-restore' to pull your cross-machine artifacts (or 'gstack-config set artifacts_sync_mode off' to dismiss forever)" fi fi # Privacy at the sync entrypoint (plan B1): an unattended session neither pulls # nor pushes the artifacts repo, whatever the inherited mode or queue depth. if [ "$_SESSION_KIND" = "unattended" ]; then : elif [ -d "$_GH/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_LAST_PULL_FILE="$_GH/.brain-last-pull" _BRAIN_NOW=$(date +%s) _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi if [ "$_BRAIN_DO_PULL" = "1" ]; then # Daily artifacts pull is a brain-sync-class sink: receipt-before-send, # fail-closed (same wiring as bin/gstack-brain-sync's fetch/push). . "$_BIN/gstack-egress-lib.sh" 2>/dev/null || true _PULL_HOST=$(cd "$_GH" 2>/dev/null && git remote get-url origin 2>/dev/null | sed -E 's|^[a-z+]+://([^/@]*@)?([^/:]+).*|\2|; s|^([^@]+@)?([^:]+):.*|\2|' | head -1) # The merge writes the artifacts repo's git state, so it takes the drain # lock every gstack writer shares; when a drain holds it, the pull waits # for the next start (no stamp). . "$_BIN/gstack-brain-lock.sh" 2>/dev/null || true if command -v _receipted_git >/dev/null 2>&1 && command -v gstack_brain_lock_acquire >/dev/null 2>&1 \ && gstack_brain_lock_acquire "$_GH" try; then # Non-interactive + slow-network bounded (same guards as gstack-update-check): # a hung remote or a credential prompt must not stall the preamble. ( cd "$_GH" && GSTACK_HOME="$_GH" GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \ _receipted_git closed brain-sync "${_PULL_HOST:-unknown}" curated-memory-git-fetch "artifacts_sync_mode!=off" \ git fetch origin >/dev/null 2>&1 && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" >/dev/null 2>&1 ) || true gstack_brain_lock_release # Stamp only when the receipted path actually ran — a missing egress lib # must surface as a retry next start, not a silent 24h suppression. echo "$_BRAIN_NOW" > "$_BRAIN_LAST_PULL_FILE" fi fi "$_BRAIN_SYNC_BIN" --once 2>/dev/null || true fi if [ "$_SESSION_KIND" = "unattended" ]; then _BRAIN_QUEUE_DEPTH=0 [ -d "$_GH/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GH/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ') [ -f "$_GH/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GH/.brain-queue.jsonl" | tr -d ' ') )) echo "ARTIFACTS_SYNC: skipped (unattended session; mode=$_BRAIN_SYNC_MODE queue=$_BRAIN_QUEUE_DEPTH retained)" elif [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op by design (the brain # admin's server pulls from GitHub/GitLab). _GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GH/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 # Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines # counted too until the drain migrates them. [ -d "$_GH/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GH/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ') [ -f "$_GH/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GH/.brain-queue.jsonl" | tr -d ' ') )) [ -f "$_GH/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GH/.brain-queue.jsonl.migrating" | tr -d ' ') )) _BRAIN_LAST_PUSH="never" # First line only + charset clamp: this file lives in a git checkout pulled # from the artifacts remote, so its content is not trusted for STATUS output. [ -f "$_GH/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(head -1 "$_GH/.brain-last-push" 2>/dev/null | tr -cd 'A-Za-z0-9._:+-' || echo never) _BRAIN_LAST_PUSH=${_BRAIN_LAST_PUSH:-never} echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH" # A stuck sync (scanner hold, failed push or commit, no push or no drain for # 24 hours) prints fixed attention lines; a healthy sync prints none. "$_BRAIN_SYNC_BIN" --attention 2>/dev/null | grep '^ARTIFACTS_SYNC: attention: ' || true else echo "ARTIFACTS_SYNC: off" fi # --------------------------------------------------------------------------- # Instruction-emission layer (token-reduction Phase 2). One-time onboarding # text used to be inlined unconditionally in every SKILL.md (~7KB/skill); # it now appears ONLY when its runtime gate fires, wrapped in blocks the # preamble prose tells the model to follow. Security scoping (F4/OV4): each # header carries this run's SESSION_ID; the prose rule requires the match and # accepts blocks only from this command's direct tool result, so page/file # content cannot mint directives (passthrough output above is _sanitize-d). # Ack ownership (OV6): display-only tips write their done-marker here at # emit; interactive flows carry their ack commands inside the block for the # model to run after the interaction. # --------------------------------------------------------------------------- _ROOT_DIR=$(dirname "$_BIN") _emit_block() { echo "GSTACK_INSTRUCTION_BEGIN: $1 $_SESSION_ID"; cat; echo "GSTACK_INSTRUCTION_END"; } # Spawned sessions get NO interactive-onboarding blocks (#2733): no human is # watching, so an emitted prompt gets auto-answered (config writes nobody # approved) and ack-at-emit markers get consumed invisibly — the next HUMAN # session would never see the one-time prompt. Gating EMISSION here (not just # the prose-skip inside the spawned-session block) also gates the script-side # marker/state writes: .activated, .first-loop-tip-shown, and scaffold # telemetry stay untouched, so onboarding fires intact on the next human run. # The spawned-session block and the privacy-stop-gate below sit OUTSIDE this # guard: the former is the gate's inverse, the latter is interactive-only. if [ "$_NO_HUMAN" != "yes" ]; then # Upgrade flow (gated: update-check emitted something above). if [ -n "$_UPD" ]; then _emit_block upgrade-flow < \`: read \`$_ROOT_DIR/gstack-upgrade/SKILL.md\` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If it shows \`JUST_UPGRADED \`: print "Running gstack v{to} (just updated!)". If \`SPAWNED_SESSION\` is true, skip feature discovery. After upgrade prompts, continue the workflow. EOI fi # Feature discovery (one prompt per session, marker-gated). These are # machine-local UX acknowledgements, so keep them in GSTACK_HOME rather than # beside an install path that may resolve through a project-local symlink. if [ ! -f "$_GH/.feature-prompted-model-overlay" ]; then _emit_block feature-overlay < v1 prompts are simpler: first-use jargon glosses, outcome-framed questions, shorter prose. Keep default or restore terse? Options: - A) Keep the new default (recommended — good writing helps everyone) - B) Restore V0 prose — set \`explain_level: terse\` If A: leave \`explain_level\` unset (defaults to \`default\`). If B: run \`$_BIN/gstack-config set explain_level terse\`. Always run (regardless of choice): \`rm -f "$_GH/.writing-style-prompt-pending" && touch "$_GH/.writing-style-prompted"\`. EOI fi # Lake intro (one-time; the offer is interactive, so the model acks). if [ "$_LAKE_SEEN" = "no" ]; then _emit_block lake-intro < Help gstack get better. Yes sends, per skill run: skill name, outcome, duration, gstack version, OS, a redacted error summary and a stable device ID. Never code, file paths, prompts, repo or branch names. Your repo and branch are also logged on this machine (analytics/skill-usage.jsonl) and stripped before upload; off logs nothing. Not covered by this answer: the update check asks GitHub for the latest version about hourly (\`gstack-config set update_check false\`), and outside reviews send code to Codex or Claude Code (\`gstack-config set codex_reviews disabled\`). Details: README "Privacy & Telemetry". Options: - A) Help gstack get better! (recommended) - B) No thanks If A: run \`$_BIN/gstack-config set telemetry community\`. If B, ask the follow-up: > Anonymous mode sends only aggregate usage, no unique ID. Options: A) Sure, anonymous is fine B) No thanks, fully off If B→A: \`$_BIN/gstack-config set telemetry anonymous\`. If B→B: \`$_BIN/gstack-config set telemetry off\`. Always run: \`touch "$_GH/.telemetry-prompted"\`. EOI fi # Proactive-suggestions opt-in (interactive; a stored off answered it above). if [ "$_PROACTIVE_PROMPTED" = "no" ] && [ "$_TEL_PROMPTED" = "yes" ]; then _emit_block proactive-prompt < Let gstack proactively suggest skills? By default it suggests only the four core ones: /office-hours for a new idea, /review for a diff, /qa for "does this work?", /ship to land it. Options: A) Keep the core four (recommended) B) Suggest every matching skill C) Turn it off — I'll type /commands myself If A: \`$_BIN/gstack-config set proactive core\`. If B: \`$_BIN/gstack-config set proactive all\`. If C: \`$_BIN/gstack-config set proactive off\`. Always run: \`touch "$_GH/.proactive-prompted"\`. EOI fi # First-run guidance (display-only tips: marker + scaffold telemetry written # HERE at emit, per OV6 — a lost tip is acceptable; a re-firing loop is not). if [ "$_ACTIVATED" = "no" ]; then _FT_TIP="" case "$_FIRST_TASK" in greenfield) _FT_TIP="Fresh repo — shape it first with \`/office-hours\`." ;; code_node|code_python|code_rust|code_go|code_ruby|code_ios) _FT_TIP="There's code here — \`/qa\` to see it work, or \`/review\` on your latest diff." ;; branch_ahead) _FT_TIP="Unshipped work on this branch — \`/review\` then \`/ship\`." ;; dirty_default) _FT_TIP="Uncommitted changes — \`/review\` before committing." ;; clean_default) _FT_TIP="Pick one: \`/office-hours\`, \`/qa\`, or \`/review\`." ;; esac # Under off the tips are the suggestions the user turned off. [ "$_PROACTIVE_OFF" = "yes" ] && _FT_TIP="" if [ -n "$_FT_TIP" ]; then _emit_block first-run-tip </dev/null || true fi touch "$_GH/.activated" 2>/dev/null || true elif [ "$_FIRST_LOOP_SHOWN" = "no" ]; then if [ "$_PROACTIVE_OFF" = "no" ]; then _emit_block first-loop-tip </dev/null || true fi fi # Routing injection (interactive: AUQ + CLAUDE.md append + commit). The body # is a quoted heredoc (markdown backticks must stay literal), so the resolved # bin path is substituted via sed on the __BIN__ placeholder. Under off the # routing rules are the auto-invocation the user turned off, so no offer. if [ "$_HAS_ROUTING" = "no" ] && [ "$_ROUTING_DECLINED" = "false" ] && [ "$_PROACTIVE_PROMPTED" = "yes" ] && [ "$_PROACTIVE_OFF" = "no" ]; then sed "s|__BIN__|$_BIN|g" <<'EOI' | _emit_block routing-injection gstack works best when the project CLAUDE.md includes skill routing rules. If no CLAUDE.md exists in the project root, create it. Use AskUserQuestion: > gstack works best when your project's CLAUDE.md includes skill routing rules. Options: A) Add routing rules to CLAUDE.md (recommended) B) No thanks, I'll invoke skills manually If A: append this section to the end of CLAUDE.md, then commit it (`git add CLAUDE.md && git commit -m "chore: add gstack skill routing rules to CLAUDE.md"`): ## Skill routing When the user's request matches an available skill, invoke it via the Skill tool. Route only to skills in the session's available-skills list; answer directly for quick questions or small scoped edits. Key routing rules: - Product ideas/brainstorming → invoke /office-hours - Strategy/scope → invoke /plan-ceo-review - Architecture → invoke /plan-eng-review - Design system/plan review → invoke /design-consultation or /plan-design-review - Full review pipeline → invoke /autoplan - Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test) - QA/testing site behavior → invoke /qa or /qa-only - Code review/diff check → invoke /review - Visual polish → invoke /design-review - Ship/deploy/PR → invoke /ship or /land-and-deploy - Save progress → invoke /context-save - Resume context → invoke /context-restore - Author a backlog-ready spec/issue → invoke /spec If B: run `__BIN__/gstack-config set routing_declined true` and say they can re-enable with `__BIN__/gstack-config set routing_declined false`. This only happens once per project. EOI fi # Vendoring deprecation (interactive; slug-scoped marker). if [ "$_VENDORED" = "yes" ] && [ ! -f "$_GH/.vendoring-warned-${SLUG:-unknown}" ]; then _emit_block vendoring-deprecation < Migrate to team mode? Options: A) Yes, migrate to team mode now B) No, I'll handle it myself If A: 1) \`git rm -r .claude/skills/gstack/\` 2) \`echo '.claude/skills/gstack/' >> .gitignore\` 3) \`$_BIN/gstack-team-init required\` (or \`optional\`) 4) \`git add .claude/ .gitignore CLAUDE.md && git commit -m "chore: migrate gstack from vendored to team mode"\` 5) Tell the user: "Done. Each developer now runs: \`cd ~/.claude/skills/gstack && ./setup --team\`" If B: say "OK, you're on your own to keep the vendored copy up to date." Always run (regardless of choice): \`touch "$_GH/.vendoring-warned-${SLUG:-unknown}"\`. EOI fi fi # end interactive-onboarding guard (kind != spawned/unattended) # Unattended-session rules (plan B1, decision D6; every unattended session gets # them, like the spawned block). This is the runtime text that says how a # parent-driven run decides: recommended options are taken and logged, the # final gate is written and never approved, nothing is published or synced. # It also carries the host adaptation a harness without a Skill tool, slash # commands or AskUserQuestion needs, printed here so every host's render has it # without growing any skill's prose. The record sentence is per skill: only # skills with steps that write GSTACK_RESULT are told to end with it, and the # rest name the record the parent reads instead (docs/unattended.md, #3281). if [ "$_SESSION_KIND" = "unattended" ]; then _UA_NONE="This skill writes no run directory and prints no GSTACK_RESULT line, so do not invent either; end at its first pending item and list that item, with every recommended option you took, in your final report." case "$SKILL_NAME" in autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=\`." ;; eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=\`, where is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in ." ;; ship) _UA_RECORD="This skill ends with \`gstack-ship-receipt result\` (Step 22), which writes /ship/runs/-/ship-run.json (a /ship record, not a run manifest) and prints \`GSTACK_RESULT: skill=ship status=complete run=\`; a run that stops earlier prints \`GSTACK_RESULT: skill=ship status=refused run=-\` itself. The one publication allowed here: when an open PR for this branch exists and HEAD is its pushed head, update that PR's body and title with the receipt through the pr-body section's existing-PR path (never pr-create, never a push, and never \`gh pr ready\`: the draft-state items there concern an early PR this run never opened); otherwise the handoff section writes the receipt to .gstack/tmp/ship-receipt.md and the parent reads it there." ;; review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;; qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;; *) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;; esac _emit_block unattended-session <\` (before -> after, why it matters, what caused this work, big decisions, what we need) and relayed whole; fix the plan first if it exits CAPTAIN_BRIEF_INCOMPLETE. Record every answer with the person's exact words: \`gstack-approvals add\` (or \`gstack-approvals import --autoplan \` after an autoplan answer). Cite standing approvals your Captain lists instead of asking again; always ask fresh for a product or behaviour decision, spend over a stated cap, anything published outside the repository, a threshold, budget or trial-count change, closing someone's contribution without landing it, and anything destructive. A pull request opened by /ship follows its Capy section (What changed with before/after images, Why, Approvals). The launch environment reaches a command, your own or a child's, through \`. \` in the shell that runs it; never \`export \$(...)\`, \`env\`, \`printenv\` or a bare \`set\` in a tool fence: a bare \`export\` prints every variable, credentials included. Guide: capy/skill/gstack/captain.md in the gstack checkout. EOI fi # Spawned-session rules (NOT one-time — every spawned session gets the full # behavioral instruction, per plan OV6 move-with-care). Keyed on the resolved # kind, not raw OPENCLAW_SESSION (#2733): the GSTACK_SESSION_KIND=spawned # override must emit this block too — it is the only runtime text that # actually says "auto-choose". if [ "$_SESSION_KIND" = "spawned" ]; then _emit_block spawned-session </dev/null || echo "false") if [ "$_SESSION_KIND" = "interactive" ] && [ "$_BRAIN_SYNC_MODE" = "off" ] && [ "$_ARTIFACTS_PROMPTED" != "true" ] && command -v gbrain >/dev/null 2>&1; then _emit_block privacy-stop-gate < gstack can publish your artifacts (CEO plans, designs, reports) to a private GitHub repo that GBrain indexes across machines. How much should sync? Options: A) Everything allowlisted (recommended) B) Only artifacts C) Decline, keep everything local After answer run: \`$_BIN/gstack-config set artifacts_sync_mode \` and \`$_BIN/gstack-config set artifacts_sync_mode_prompted true\`. If A/B and \`~/.gstack/.git\` is missing, ask whether to run \`gstack-artifacts-init\`. Do not block the skill. EOI fi # BRAIN_HEALTH block: only for hosts whose render passes --brain-health # (gbrain/hermes) — gen-time host conditional preserved as a flag. if [ "$BRAIN_HEALTH" = "yes" ] && command -v gbrain >/dev/null 2>&1; then _BRAIN_JSON=$(gbrain doctor --fast --json 2>/dev/null || echo '{}') _BRAIN_SCORE=$(echo "$_BRAIN_JSON" | grep -o '"health_score":[0-9]*' | cut -d: -f2) _BRAIN_FAILS=$(echo "$_BRAIN_JSON" | grep -o '"status":"fail"' | wc -l | tr -d ' ') _BRAIN_WARNS=$(echo "$_BRAIN_JSON" | grep -o '"status":"warn"' | wc -l | tr -d ' ') echo "BRAIN_HEALTH: ${_BRAIN_SCORE:-unknown} (${_BRAIN_FAILS:-0} failures, ${_BRAIN_WARNS:-0} warnings)" if [ "${_BRAIN_SCORE:-100}" -lt 50 ] 2>/dev/null; then echo "$_BRAIN_JSON" | grep -o '"name":"[^"]*","status":"[^"]*","message":"[^"]*"' | _sanitize || true fi fi