#!/usr/bin/env bash # gstack-config — read/write ~/.gstack/config.yaml # # Usage: # gstack-config get — read a config value (falls back to DEFAULTS) # gstack-config set — write a config value # gstack-config list — show all config (values + defaults) # gstack-config defaults — show just the defaults table # # Env overrides (for testing): # GSTACK_STATE_ROOT — override ~/.gstack state directory (highest priority, # matches D16 cathedral isolation convention) # GSTACK_HOME — override ~/.gstack state directory (aligns with writer scripts) # GSTACK_STATE_DIR — legacy alias for GSTACK_HOME (kept for backwards compat) set -euo pipefail STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}" CONFIG_FILE="$STATE_DIR/config.yaml" # Annotated header for new config files. Written once on first `set`. # Default semantics: DEFAULTS table below is the canonical source. Header text # is documentation that must stay in sync with DEFAULTS. CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on next skill run. # Docs: https://github.com/garrytan/gstack # # ─── Behavior ──────────────────────────────────────────────────────── # proactive: true # Auto-invoke skills when your request matches one. # # Set to false to only run skills you type explicitly. # # routing_declined: false # Set to true to skip the CLAUDE.md routing injection # # prompt. Set back to false to be asked again. # # ─── Telemetry ─────────────────────────────────────────────────────── # telemetry: off # off | anonymous | community # # off — no data sent, no local analytics (default) # # anonymous — counter only, no device ID # # community — usage data + stable device ID # # ─── Updates ───────────────────────────────────────────────────────── # auto_upgrade: false # true = silently upgrade on session start # update_check: true # false = suppress version check notifications # # ─── Skill naming ──────────────────────────────────────────────────── # skill_prefix: false # true = namespace skills as /gstack-qa, /gstack-ship # # false = short names /qa, /ship # # ─── Checkpoint ────────────────────────────────────────────────────── # checkpoint_mode: explicit # explicit | continuous # # explicit — commit only when you run /ship or /checkpoint # # continuous — auto-commit after each significant change # # with WIP: prefix + [gstack-context] body # # checkpoint_push: false # true = push WIP commits to remote as you go # # false = keep WIP commits local only (default) # # Pushing can trigger CI/deploy hooks — opt in carefully. # # ─── Writing style (V1) ────────────────────────────────────────────── # explain_level: default # default = jargon-glossed, outcome-framed prose # # (V1 default — more accessible for everyone) # # terse = V0 prose style, no glosses, no outcome-framing layer # # (for power users who know the terms) # # Unknown values default to "default" with a warning. # # See docs/designs/PLAN_TUNING_V1.md for rationale. # # ─── Artifacts sync (renamed from gbrain_sync_mode in v1.27.0.0) ───── # artifacts_sync_mode: off # off | artifacts-only | full # # off — no sync (default) # # artifacts-only — sync plans/designs/retros/learnings only # # (skip behavioral data: question-log, # # developer-profile, timeline) # # full — sync everything allowlisted # # Set by the first-run privacy stop-gate. See docs/gbrain-sync.md. # # artifacts_sync_mode_prompted: false # # Set to true once the privacy gate has asked the user. # # Flip back to false to be re-prompted. # # ─── Plan-tune hooks ───────────────────────────────────────────────── # plan_tune_hooks: prompt # Controls whether ./setup installs the plan-tune # # Claude Code hooks (PostToolUse capture + # # PreToolUse preference enforcement). # # prompt — ask on a real TTY, skip otherwise (default) # # yes — install non-interactively # # no — skip non-interactively # # Override per-run: ./setup --plan-tune-hooks / # # --no-plan-tune-hooks, or env GSTACK_PLAN_TUNE_HOOKS. # # ─── Advanced ──────────────────────────────────────────────────────── # codex_reviews: enabled # disabled = skip Codex adversarial reviews in /ship # gstack_contributor: false # true = file field reports when gstack misbehaves # skip_eng_review: false # true = skip eng review gate in /ship (not recommended) # # ─── Workspace-aware ship ──────────────────────────────────────────── # workspace_root: $HOME/conductor/workspaces # Where /ship looks for sibling # # Conductor worktrees when picking a VERSION slot. # # Set to "null" to disable sibling scanning entirely. # # Non-Conductor users can point this at any directory # # that holds parallel worktrees of the same repo. # ' # DEFAULTS table — canonical default values for known keys. # `get ` returns DEFAULTS[key] when the key is absent from the config file # AND the env override is not set. Keep in sync with the CONFIG_HEADER comments. lookup_default() { case "$1" in proactive) echo "true" ;; routing_declined) echo "false" ;; telemetry) echo "off" ;; auto_upgrade) echo "false" ;; update_check) echo "true" ;; skill_prefix) echo "false" ;; checkpoint_mode) echo "explicit" ;; checkpoint_push) echo "false" ;; explain_level) echo "default" ;; codex_reviews) echo "enabled" ;; gstack_contributor) echo "false" ;; skip_eng_review) echo "false" ;; workspace_root) echo "$HOME/conductor/workspaces" ;; cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt artifacts_sync_mode) echo "off" ;; artifacts_sync_mode_prompted) echo "false" ;; plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection redact_prepush_hook) echo "false" ;; # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: # brain_trust_policy@ — unset on fresh install; setup-gbrain # writes 'personal' for local engines, # asks the user for remote-ambiguous. # salience_allowlist — empty falls through to # SALIENCE_DEFAULT_ALLOWLIST (D9). # user_slug_at_ — empty triggers resolve-user-slug # fallback chain (D4 A3) on first call. brain_trust_policy*) echo "unset" ;; salience_allowlist) echo "" ;; user_slug_at_*) echo "" ;; *) echo "" ;; esac } # ────────────────────────────────────────────────────────────────────── # Brain-integration helpers (T5+T10+T16) # ────────────────────────────────────────────────────────────────────── # Compute sha8 of a string. Used for endpoint hashing. sha8_of() { printf '%s' "$1" | shasum -a 256 | cut -c1-8 } # Detect the active brain endpoint hash. Reads ~/.claude.json for the gbrain # MCP server URL. Falls back to the literal 'local' when no MCP is configured. endpoint_hash() { _claude_json="$HOME/.claude.json" if [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then _url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null) if [ -n "$_url" ] && [ "$_url" != "null" ]; then sha8_of "$_url" return 0 fi fi printf '%s' "local" } # Detect endpoint hash collisions. When two distinct endpoints share the same # sha8 prefix (rare but possible), escalate to sha16 by emitting the longer # hash. Detection: scan config file for existing brain_trust_policy@ or # user_slug_at_ keys; if any non-active hash equals the active sha8 but # would differ at sha16, the active endpoint needs sha16. endpoint_hash_with_collision_check() { _active=$(endpoint_hash) if [ "$_active" = "local" ]; then printf '%s' "$_active" return 0 fi # If a different endpoint (different URL) shares this sha8, escalate. # We only catch this when the config has another endpoint recorded. _matching=$(grep -E "^(brain_trust_policy|user_slug_at)@${_active}" "$CONFIG_FILE" 2>/dev/null | head -1 || true) _claude_json="$HOME/.claude.json" if [ -n "$_matching" ] && [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then _url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null) _sha16=$(printf '%s' "$_url" | shasum -a 256 | cut -c1-16) # Look for any sha16-namespaced key that conflicts. If a stored sha16 exists # and differs from current sha16, that's the collision evidence; emit sha16. _stored16=$(grep -E "^(brain_trust_policy|user_slug_at)@${_sha16}" "$CONFIG_FILE" 2>/dev/null | head -1 || true) if [ -n "$_stored16" ]; then printf '%s' "$_sha16" return 0 fi fi printf '%s' "$_active" } # Resolve the user-slug per D4 A3 chain: # 1. mcp__gbrain__whoami.client_name (best effort via gbrain CLI shell-out) # 2. $USER env # 3. sha8($(git config user.email)) # 4. anonymous- # Persists result via gstack-config set user_slug_at_ on first call. resolve_user_slug() { _hash=$(endpoint_hash_with_collision_check) _stored=$(grep -E "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) if [ -n "$_stored" ]; then printf '%s' "$_stored" return 0 fi _slug="" # Layer 1: gbrain whoami if command -v gbrain >/dev/null 2>&1; then _whoami=$(gbrain whoami --json 2>/dev/null || true) if [ -n "$_whoami" ] && command -v jq >/dev/null 2>&1; then _client_name=$(printf '%s' "$_whoami" | jq -r '.client_name // .token_name // empty' 2>/dev/null || true) if [ -n "$_client_name" ] && [ "$_client_name" != "null" ]; then _slug=$(printf '%s' "$_client_name" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-') fi fi fi # Layer 2: $USER if [ -z "$_slug" ] && [ -n "${USER:-}" ]; then _slug=$(printf '%s' "$USER" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-') fi # Layer 3: sha8 of git email if [ -z "$_slug" ]; then _email=$(git config user.email 2>/dev/null || true) if [ -n "$_email" ]; then _slug="email-$(sha8_of "$_email")" fi fi # Layer 4: anonymous- if [ -z "$_slug" ]; then _slug="anonymous-$(sha8_of "$(hostname 2>/dev/null || echo unknown)")" fi # Persist via direct file write (avoid recursion into gstack-config set) mkdir -p "$STATE_DIR" if [ ! -f "$CONFIG_FILE" ]; then printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" fi if ! grep -qE "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null; then echo "user_slug_at_${_hash}: ${_slug}" >> "$CONFIG_FILE" fi printf '%s' "$_slug" } case "${1:-}" in get) KEY="${2:?Usage: gstack-config get }" # Validate key (alphanumeric + underscore + optional @ suffix for # endpoint-namespaced keys introduced by the brain-aware planning layer) if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi # Use literal match for keys containing @ (sha hashes), regex otherwise VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-f0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) if [ -z "$VALUE" ]; then VALUE=$(lookup_default "$KEY") fi printf '%s' "$VALUE" ;; set) KEY="${2:?Usage: gstack-config set }" VALUE="${3:?Usage: gstack-config set }" # Validate key (alphanumeric + underscore + optional @ suffix) if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi # Validate brain_trust_policy value domain (D4 / D11) if printf '%s' "$KEY" | grep -qE '^brain_trust_policy(@|$)' && \ [ "$VALUE" != "personal" ] && [ "$VALUE" != "shared" ] && [ "$VALUE" != "unset" ]; then echo "Warning: brain_trust_policy '$VALUE' not recognized. Valid values: personal, shared, unset. Using unset." >&2 VALUE="unset" fi # V1: whitelist values for keys with closed value domains. Unknown values warn + default. if [ "$KEY" = "explain_level" ] && [ "$VALUE" != "default" ] && [ "$VALUE" != "terse" ]; then echo "Warning: explain_level '$VALUE' not recognized. Valid values: default, terse. Using default." >&2 VALUE="default" fi if [ "$KEY" = "artifacts_sync_mode" ] && [ "$VALUE" != "off" ] && [ "$VALUE" != "artifacts-only" ] && [ "$VALUE" != "full" ]; then echo "Warning: artifacts_sync_mode '$VALUE' not recognized. Valid values: off, artifacts-only, full. Using off." >&2 VALUE="off" fi # redact_repo_visibility: a LOCAL override for repos gh/glab can't read (e.g. # self-hosted GitLab). It lives in ~/.gstack/config.yaml (never committed), so # it can't be used to weaken the gate repo-wide for other contributors. if [ "$KEY" = "redact_repo_visibility" ] && [ "$VALUE" != "public" ] && [ "$VALUE" != "private" ] && [ "$VALUE" != "unknown" ]; then echo "Warning: redact_repo_visibility '$VALUE' not recognized. Valid values: public, private, unknown. Using unknown." >&2 VALUE="unknown" fi if [ "$KEY" = "redact_prepush_hook" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2 VALUE="false" fi if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2 VALUE="prompt" fi mkdir -p "$STATE_DIR" # Write annotated header on first creation if [ ! -f "$CONFIG_FILE" ]; then printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" fi # Escape sed special chars in value and drop embedded newlines ESC_VALUE="$(printf '%s' "$VALUE" | head -1 | sed 's/[&/\]/\\&/g')" if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then # Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg) _tmpfile="$(mktemp "${CONFIG_FILE}.XXXXXX")" sed "/^${KEY}:/s/.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE" else echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE" fi # Auto-relink skills when prefix setting changes (skip during setup to avoid recursive call) if [ "$KEY" = "skill_prefix" ] && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then GSTACK_RELINK="$(dirname "$0")/gstack-relink" [ -x "$GSTACK_RELINK" ] && "$GSTACK_RELINK" || true fi ;; list) if [ -f "$CONFIG_FILE" ]; then cat "$CONFIG_FILE" fi echo "" echo "# ─── Active values (including defaults for unset keys) ───" for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) SOURCE="default" if [ -n "$VALUE" ]; then SOURCE="set" else VALUE=$(lookup_default "$KEY") fi printf ' %-24s %s (%s)\n' "$KEY:" "$VALUE" "$SOURCE" done ;; defaults) echo "# gstack-config defaults" for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; endpoint-hash) # Brain integration helper (T10): print active brain endpoint sha8 endpoint_hash_with_collision_check ;; resolve-user-slug) # Brain integration helper (T16 / D4 A3): resolve + persist user-slug resolve_user_slug ;; gbrain-refresh) # Brain integration helper: re-detect gbrain installation state and # persist to ~/.gstack/gbrain-detection.json. gen-skill-docs reads this # file (when invoked with --respect-detection) to decide whether to # render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks in # generated SKILL.md files. # # Run this after installing or uninstalling gbrain so your locally # generated SKILL.md files match your installation state. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect" DETECTION_FILE="$STATE_DIR/gbrain-detection.json" mkdir -p "$STATE_DIR" if [ ! -x "$DETECT_BIN" ]; then echo "gstack-gbrain-detect not found at $DETECT_BIN" >&2 exit 1 fi if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp" 2>/dev/null; then printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp" fi mv "$DETECTION_FILE.tmp" "$DETECTION_FILE" # Summarize for the user. Use python (already required elsewhere) to # parse the JSON portably; fall back to grep if python is unavailable. PYTHON_CMD=$(command -v python3 || command -v python || true) if [ -n "$PYTHON_CMD" ]; then STATUS=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_local_status','unknown'))" 2>/dev/null || echo unknown) VERSION=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_version') or 'unknown')" 2>/dev/null || echo unknown) else STATUS=$(grep -o '"gbrain_local_status":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/') VERSION=$(grep -o '"gbrain_version":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/') [ -z "$STATUS" ] && STATUS=unknown [ -z "$VERSION" ] && VERSION=unknown fi case "$STATUS" in ok) echo "Detected gbrain v$VERSION → brain-aware blocks will render in planning-skill SKILL.md files." echo "Run 'bun run gen:skill-docs' in the gstack repo (or re-run ./setup) to regenerate now." ;; *) echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files." echo "Install gbrain (see /setup-gbrain) and re-run 'gstack-config gbrain-refresh' once it's configured." ;; esac ;; *) echo "Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]" exit 1 ;; esac