#!/usr/bin/env bash # gstack-config — read/write /config.yaml (default ~/.gstack/config.yaml) # # Usage: # gstack-config get — read a config value (falls back to DEFAULTS) # gstack-config get --stored — the stored value or "unset", never DEFAULTS; # "unreadable" + exit 2 when config cannot be read # or a privacy key holds an unknown value # (the outside-review consent check reads # codex_reviews this way) # gstack-config has — exit 0 iff the key is literally present in the # config file (get returns DEFAULTS for absent keys, # so callers that need provenance use this instead) # gstack-config set — write a config value # gstack-config unset — remove a key from the config file # gstack-config list — show all config (values + defaults) # gstack-config defaults — show just the defaults table # # State root: GSTACK_STATE_ROOT → GSTACK_HOME → GSTACK_STATE_DIR → # CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT contains "gstack") → # ~/.gstack (bin/gstack-state-root.sh). Set GSTACK_HOME to relocate state; # GSTACK_STATE_ROOT is gstack-paths' output, also honored as input; # GSTACK_STATE_DIR is a legacy alias. # Privacy keys (telemetry, memorable_recall, codex_reviews, update_check) take # the most restrictive value across the resolved root and ~/.gstack; `set` # reports which root received the value and which root still overrides it. # Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md set -euo pipefail [ -r "$(dirname "$0")/gstack-state-root.sh" ] && . "$(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 STATE_DIR="$_gstack_sr_root" CONFIG_FILE="$STATE_DIR/config.yaml" # 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 setup'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" } # 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: core # core = auto-invoke only /office-hours, /review, /qa and /ship # # when your request matches one; every other skill waits # # for you to type it. all = auto-invoke any matching skill. # # off = only run skills you type explicitly. # # (true and false still work: true means all, false means off.) # # 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 # # Opted in, each skill run is also logged locally to # # analytics/skill-usage.jsonl under this state root # # with the repo name and branch; those two fields are # # stripped before upload. Off logs nothing there. # # ─── Session markers (local functional state, not telemetry) ───────── # sessions/ # One empty file per live agent session, touched at # # skill start and deleted after 120 minutes. The # # count tells skills when several sessions share a # # machine. Never sent anywhere; not gated on # # telemetry. # # ─── Updates ───────────────────────────────────────────────────────── # auto_upgrade: false # true = silently upgrade on session start # update_check: true # false = suppress version check notifications # update_channel: auto # auto = stable when team_mode is true, else main # # stable = the weekly tagged release (refs/heads/stable) # # main = every merge to main (docs/releases.md) # # ─── Skill naming ──────────────────────────────────────────────────── # skill_prefix: false # true = namespace skills as /gstack-qa, /gstack-ship # # false = short names /qa, /ship # disabled_skills: # comma-separated skills to leave unregistered, # # e.g. make-pdf,pair-agent ("" = all enabled) # # ─── 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. # plan_mode_preamble: on # on = every skill carries the plan-mode preamble # # off = rendered skills omit the "Plan Mode Safe # # Operations" and "Skill Invocation During # # Plan Mode" sections (~1.8 KB each), for # # users who never enter plan mode # # Applies on the next ./setup run. Unknown values # # default to "on" with a warning. # # ─── 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. # # artifacts_sync_removals: off # on | off — on: each sync also publishes the # # removal of synced files gone from disk; more than # # 20 at once is held for gstack-brain-sync # # --publish-removals. See docs/gbrain-sync.md. # # ─── Transcript ingest (gbrain memory) ─────────────────────────────── # transcript_ingest_mode: off # recent | all | off | new@ — consent for # # /sync-gbrain to ingest Claude Code and Codex # # session transcripts (every project repo policy # # allows) into your brain (local or Supabase). # # recent — last 90 days # # all — all history # # new@2026-10-03T17:00:00Z — only sessions that # # start after that UTC time # # off — never (other memory still syncs) # # A +repos suffix is kept automatically while # # transcript_repos is set. # # Absent, legacy (A-E, incremental) or unknown # # values are not consent: transcripts are skipped # # and /sync-gbrain asks once. An invalid value is # # REJECTED and the stored value kept. # transcript_repos: # comma-separated git remotes; only transcripts # # from these repos are ingested. Clear it with # # `gstack-config unset transcript_repos`. # # Docs: setup-gbrain/memory.md#transcripts # # ─── Timeline Stop hook ────────────────────────────────────────────── # timeline_stop_hook: yes # Controls whether ./setup registers the timeline # # Stop hook (closes dangling session entries). # # yes — register on every setup (default) # # no — never register; setup also removes a # # live registration (persistent opt-out; # # --no-team stays a one-shot teardown, #2677) # # ─── 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. # # ─── Memorable recall bridge (opt-in, third party) ────────────────── # memorable_recall: off # The gstack-side consent gate for the Memorable # # UserPromptSubmit bridge (bin/gstack-memorable). # # off — the hook does nothing, spawns nothing (default) # # on — the hook hands each prompt to the local # # `memorable` CLI, receipted as memorable-recall # # Written by `gstack-memorable enable|disable`. An # # invalid value is REJECTED and the stored value kept: # # a typo must never flip a third-party consent. # # The vendor capture consent (`memorable enable`) # # is separate; gstack never sets it. # # ─── Claude skill overlay ──────────────────────────────────────────── # claude_overlay_model: claude # Claude model ID whose skill overlay ./setup # # renders (e.g. claude-opus-4-8). Absent = the # # generic claude overlay. Set it with # # ./setup --claude-model , which validates the # # ID and re-renders; ./setup --claude-model claude # # returns to the generic overlay. # # ─── Model policy (gstack-models shows the effective choices) ─────── # Plan review tier: frontier | smart | host. host delegates to the CLI settings; # GSTACK_CLAUDE_MODEL / GSTACK_CODEX_MODEL still win. # plan_review_tier: frontier # Implementation recommendation: frontier | smart. Never switches your session. # implementation_tier: smart # Optional pins: model_frontier_claude, model_frontier_openai, # model_smart_claude and model_smart_openai. Leave pins absent for catalog defaults; # empty pins are invalid. gstack-models list prints copyable set/unset commands. # agent_model: the model for gstack agents (lanes, subagents, reviewers): host, # a family (opus, fable, sonnet, haiku, astra, sol, luna, terra) or /. # Absent = host. A preference, not a policy: gstack-models resolve --role agent. # Invalid writes are rejected and preserve the stored value. # codex_effort: # Codex reasoning effort for outside reviews and /codex: # # minimal | low | medium | high | xhigh | max | ultra. # # Absent = every gate keeps its effort. GSTACK_CODEX_EFFORT # # overrides it per run, /codex --effort per request; # # /spec keeps medium (120 s budget). # review_effort_tiers: on # on | off. on: audits, trail reviewers and watchers # # (nothing that can block a ship) run at light effort on # # the same model; every reviewer keeps full. # Docs: docs/model-policy.md # # ─── Advanced ──────────────────────────────────────────────────────── # codex_reviews: enabled # Outside review (Codex or Claude Code by harness): sends # # the review prompt and repository content to that model. # # enabled (default; the first send prints a notice once): # # the outside voice of every workflow may run. # # disabled: the send binaries refuse (not_run, # # consent_missing) and start no CLI and make no API call; # # /review, /ship, /autoplan keep native passes; plan and # # document reviews skip the extra step. Covers design # # review, /ship design-lite, office hours and /spec. # # /codex and /claude-code still run when you ask by name. # # An unreadable config counts as disabled. # # Invalid values preserve the old value. # browse_extension_id: # Forks and self-built extensions: the 32-letter (a-p) # # Chrome extension ID the browse sidebar terminal accepts. # # Empty = the gstack extension. "" clears it. # design_detector_install_prompted: false # # true once you answered the one-time offer from the # # design skills to download the impeccable engine with # # "never ask again"; flip back to false to be asked again. # design_detector: auto # Deterministic design pre-pass through a user-installed # # impeccable engine (/design-review, /review, /ship, # # /design-html). auto = use the engine when the probe # # finds one (gstack never installs or downloads it); # # off = no probe, no scan, no hint, no /impeccable # # handoff lines. An invalid value is REJECTED (existing # # value preserved) so a typo cannot silently disable it. # gstack_contributor: false # true = file field reports when gstack misbehaves # skip_eng_review: false # true = skip eng review gate in /ship (not recommended) # # ─── Browser extension ─────────────────────────────────────────────── # browse_extension_id: # Chrome extension ID the browse daemon trusts for # # /extension-token and the terminal-agent /ws # # (32 letters a-p). Empty = the published gstack # # extension. Set it only for a fork or a # # self-built extension; no environment variable # # or project .env can change it. An invalid value # # is REJECTED and the stored value kept. # # ─── /ship measure-then-fix loop ───────────────────────────────────── # A red eval case is measured alone before the full gate reruns # (scripts/ship-measure.ts; docs/TESTING_INTERNALS.md#ship-measure). # Bar (scripts/lib/measure-bar.ts): MEETS at 9/10 of the trials, MEETS-qualified # at 8/10 with every red qualifying, EXTEND at 7/10 (one more batch, pooled). # ship_measure_rule_trials: 10 # rule cases: trials per batch (MEETS 9 of 10) # ship_measure_behavior_panels: 4 # behavior cases: panels of 3 per batch # # (12 trials; MEETS 11 of 12) # ship_measure_judge_outputs: 10 # judge cases: outputs per batch, each scored # # by its median 3-sample panel (MEETS 9 of 10) # ship_measure_ask_per_trial_usd: 2 # ask first above this estimated cost per trial # ship_measure_budget_usd: 25 # estimated admission budget per red case # # (baseline, extension, redispatch, repair rounds) # ship_measure_max_rounds: 3 # repair rounds before a named-red stop # ship_measure_sweep_cases: 5 # weekly off-ship sweep: cases measured (K) # ship_measure_sweep_budget_usd: 150 # weekly off-ship sweep: spend cap per 7 days # ship_rerun_backend: local # local | ubicloud — where free-suite flake reruns # # run; ubicloud also needs UBICLOUD_API_KEY. # # Invalid values are REJECTED (value kept). # test_backend: local # local | ubicloud — where the full unit and E2E # # tiers run (plan E2). The gstack-doctor cores row # # recommends ubicloud under 8 cores; ubicloud # # needs UBICLOUD_API_KEY and scripts/ubicloud/. # # Invalid values are REJECTED (value kept). # ship_greptile_early: true # true | false — when the repo uses Greptile, /ship # # pushes and opens a draft PR after its free tests # # so Greptile reviews in parallel (a public repo # # asks once). false keeps the old order. # # Invalid values are REJECTED (value kept). # pr_wait_gating_bots: greptile,macroscope # review bots gstack-pr-wait # # --until ready waits for (comma-separated; a # # bot owns logins starting with its name and # # checks mentioning it). # pr_wait_advisory_bots: coderabbit # review bots gstack-pr-wait reports but # # never waits for. Both lists: lowercase names, # # letters, digits, . _ - only; none empties a list. # # Invalid values are REJECTED (value kept). # # ─── 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 "core" ;; # core | all | off (true = all, false = off); core suggests only /office-hours, /review, /qa, /ship routing_declined) echo "false" ;; telemetry) echo "off" ;; auto_upgrade) echo "false" ;; update_check) echo "true" ;; update_channel) echo "auto" ;; # auto | stable | main — bin/gstack-update-channel.sh resolves auto (docs/releases.md) skill_prefix) echo "false" ;; explain_level) echo "default" ;; plan_mode_preamble) echo "on" ;; # on | off — off drops the plan-mode preamble on the next ./setup (#3168) codex_reviews) echo "enabled" ;; design_detector) echo "auto" ;; # auto | off — impeccable engine pre-pass in the design skills design_detector_install_prompted) echo "false" ;; # true once the user answered the one-time engine install offer with "never ask again" 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" ;; artifacts_sync_removals) echo "off" ;; # on | off — publish removals of synced files gone from disk plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install timeline_stop_hook) echo "yes" ;; # yes | no — controls ./setup timeline Stop hook registration (#2677) redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection redact_prepush_hook) echo "false" ;; pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks memorable_recall) echo "off" ;; # on | off — Memorable bridge gate, fail-closed until `gstack-memorable enable` founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false browse_extension_id) echo "" ;; # empty → browse trusts gstack's own extension ID (D1) claude_overlay_model) echo "claude" ;; # set only by ./setup --claude-model; presence via `has` # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: # brain_trust_policy@ — unset on fresh install; setup-gbrain # writes 'personal' for local engines, # 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 "" ;; # Read by skill preambles but missing from this table, so they fell through # to the catch-all and came back "" with exit 0. Values below are the ones # the callers already assume in their own `|| echo ""` fallback. question_tuning) echo "false" ;; team_mode) echo "false" ;; transcript_ingest_mode) echo "off" ;; # recent | all | off | new@ [+repos] — transcript consent; absent = not consented (use `has`) transcript_repos) echo "" ;; # empty = every repo; comma-separated canonical remotes narrow transcript ingest # repo_mode: EMPTY is load-bearing — gstack-repo-mode treats any non-empty # answer as a user override and skips its own classification entirely, so # a synthesized "unknown" default turns the classifier into dead code. # Empty + exit 0 = "no override set, go classify". repo_mode) echo "" ;; # /ship measure-then-fix loop (scripts/ship-measure.ts reads these). ship_measure_rule_trials) echo "10" ;; ship_measure_behavior_panels) echo "4" ;; ship_measure_judge_outputs) echo "10" ;; ship_measure_ask_per_trial_usd) echo "2" ;; ship_measure_budget_usd) echo "25" ;; ship_measure_max_rounds) echo "3" ;; ship_measure_sweep_cases) echo "5" ;; ship_measure_sweep_budget_usd) echo "150" ;; ship_rerun_backend) echo "local" ;; # local | ubicloud (Ubicloud spend is opt-in) test_backend) echo "local" ;; # local | ubicloud — full unit/E2E tiers (plan E2) plan_review_tier) echo "frontier" ;; # frontier | smart | host — lib/model-policy.ts reads it strictly implementation_tier) echo "smart" ;; # frontier | smart model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai) echo "" ;; # empty = shipped catalog ID (gstack-models list) agent_model) echo "" ;; # empty = host (agents keep their own model); gstack-models resolve --role agent codex_effort) echo "" ;; # empty = each gate's own effort; /codex --effort, then GSTACK_CODEX_EFFORT, win (gstack-codex-probe run-with-timeout --effort) review_effort_tiers) echo "on" ;; # on | off — gstack-review-plan effort tiers (docs/model-policy.md#reasoning-effort) ship_greptile_early) echo "true" ;; # true | false — /ship's early Greptile PR pr_wait_gating_bots) echo "greptile,macroscope" ;; # review bots gstack-pr-wait --until ready waits for pr_wait_advisory_bots) echo "coderabbit" ;; # review bots gstack-pr-wait reports but never waits for # Unknown key: exit non-zero instead of printing "". The fallback pattern # the preambles use, # VAR=$(gstack-config get 2>/dev/null || echo "") # only fires on a non-zero exit, so a catch-all echoing "" with exit 0 left # VAR empty and the written default unreachable. # Deliberately *only* the unknown-key path: the keys above whose default is # intentionally empty (cross_project_learnings, salience_allowlist, # user_slug_at_*, redact_repo_visibility) keep exit 0, because "" is their # real answer and their callers rely on it. *) return 1 ;; esac } # ────────────────────────────────────────────────────────────────────── # Brain-integration helpers (T5+T10+T16) # ────────────────────────────────────────────────────────────────────── # Compute sha8 of a string. Used for endpoint hashing. # shasum is macOS/perl; most Linux distros ship only coreutils sha256sum — # resolve whichever exists (same fallback chain as the codex-probe timeout # wrapper). Without this, any Linux user with a git email hit exit 127 in # resolve-user-slug's Layer-3 fallback. sha8_of() { if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -c1-8 else printf '%s' "$1" | shasum -a 256 | cut -c1-8 fi } # 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) if command -v sha256sum >/dev/null 2>&1; then _sha16=$(printf '%s' "$_url" | sha256sum | cut -c1-16) else _sha16=$(printf '%s' "$_url" | shasum -a 256 | cut -c1-16) fi # 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 the locked read-modify-write (no recursion into `set`). config_lock config_edit_begin if [ -z "$(_cfg_get "user_slug_at_${_hash}")" ]; then _cfg_put "user_slug_at_${_hash}" "$_slug" config_edit_commit else rm -f "$_cfg_tmp" fi config_unlock printf '%s' "$_slug" } # The one reader (bin/gstack-state-root.sh): last `key:` line in the resolved # root, except privacy keys, which take the most restrictive value across roots. read_config_value() { gstack_read_config_key "$1" } # ── Config writes: one lock, one read-modify-write rename ────────────────── # Every mutation of config.yaml (set, unset, the transcript_repos pair, the # user-slug writer) holds an atomic mkdir lock and replaces the file with one # rename, so concurrent writers never lose each other's keys and an # interrupted write leaves the old file. The pid file holds " "; # a lock whose holder is dead or older than 10 s is taken over. No flock(1): # stock macOS does not ship it. CONFIG_LOCK="$STATE_DIR/.config-yaml.lock" _config_locked="" config_lock() { mkdir -p "$STATE_DIR" local waited=0 held pid ts stale while ! mkdir "$CONFIG_LOCK" 2>/dev/null; do if [ ! -w "$STATE_DIR" ]; then echo "Error: cannot write to $STATE_DIR (its config lock could not be created). Existing value left unchanged." >&2 exit 1 fi waited=$(( waited + 1 )) if [ "$waited" -gt 400 ]; then echo "Error: $CONFIG_FILE is locked by another gstack-config ($(cat "$CONFIG_LOCK/pid" 2>/dev/null || echo 'unknown holder')). Existing value left unchanged. fix: retry, or remove $CONFIG_LOCK if no gstack-config is running." >&2 exit 1 fi held=$(cat "$CONFIG_LOCK/pid" 2>/dev/null || true) pid=""; ts=""; stale="" case "$held" in *" "*) pid="${held%% *}"; ts="${held#* }"; ts="${ts%%[!0-9]*}" ;; esac if [ -n "$pid" ]; then if ! kill -0 "$pid" 2>/dev/null; then stale=1 elif [ -n "$ts" ] && [ $(( $(date +%s) - ts )) -gt 10 ]; then stale=1; fi elif [ "$waited" -gt 200 ]; then stale=1 fi # Take over only the lock we judged: a holder that changed meanwhile is live. if [ -n "$stale" ] && [ "$(cat "$CONFIG_LOCK/pid" 2>/dev/null || true)" = "$held" ]; then rm -rf "$CONFIG_LOCK" 2>/dev/null || true continue fi sleep 0.05 done printf '%s %s\n' "$$" "$(date +%s)" > "$CONFIG_LOCK/pid" _config_locked=1 trap 'config_unlock' EXIT } config_unlock() { [ -n "$_config_locked" ] || return 0 rm -rf "$CONFIG_LOCK" 2>/dev/null || true _config_locked="" } # config_edit_begin — copy config.yaml (or the annotated header for a new # file) to a temp file beside it; edits go there until config_edit_commit. config_edit_begin() { _cfg_tmp="$(mktemp "$STATE_DIR/.config.yaml.XXXXXX")" if [ -f "$CONFIG_FILE" ]; then cat "$CONFIG_FILE" > "$_cfg_tmp"; else printf '%s' "$CONFIG_HEADER" > "$_cfg_tmp"; fi } config_edit_commit() { mv "$_cfg_tmp" "$CONFIG_FILE" } # _cfg_get KEY — last value of KEY in the temp file ("" when absent). _cfg_get() { awk -v k="$1" 'BEGIN { n = length(k) + 1 } substr($0, 1, n) == k ":" { v = substr($0, n + 1) } END { sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v); print v }' "$_cfg_tmp" } # _cfg_put KEY VALUE — replace every KEY line in the temp file, or append one. # The value travels through ENVIRON so awk applies no escape processing. _cfg_put() { _CFG_V="$2" awk -v k="$1" 'BEGIN { n = length(k) + 1; v = ENVIRON["_CFG_V"] } substr($0, 1, n) == k ":" { print k ": " v; done = 1; next } { print } END { if (!done) print k ": " v }' "$_cfg_tmp" > "$_cfg_tmp.n" mv "$_cfg_tmp.n" "$_cfg_tmp" } _cfg_del() { awk -v k="$1" 'BEGIN { n = length(k) + 1 } substr($0, 1, n) != k ":"' "$_cfg_tmp" > "$_cfg_tmp.n" mv "$_cfg_tmp.n" "$_cfg_tmp" } # ── Transcript consent grammar ───────────────────────────────────────────── # transcript_ingest_mode = [@][+repos]; base is # recent, all, off or new. `@` is required for new and invalid otherwise; off # never carries +repos. `set` takes the canonical lowercase form (readers in # lib/transcript-consent.ts are case-insensitive). valid_utc_second() { [[ "$1" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z$ ]] || return 1 local y=$((10#${BASH_REMATCH[1]})) m=$((10#${BASH_REMATCH[2]})) d=$((10#${BASH_REMATCH[3]})) local hh=$((10#${BASH_REMATCH[4]})) mm=$((10#${BASH_REMATCH[5]})) ss=$((10#${BASH_REMATCH[6]})) dim=31 [ "$m" -ge 1 ] && [ "$m" -le 12 ] && [ "$hh" -le 23 ] && [ "$mm" -le 59 ] && [ "$ss" -le 59 ] || return 1 case "$m" in 4|6|9|11) dim=30 ;; 2) dim=28; if [ $((y % 4)) -eq 0 ] && { [ $((y % 100)) -ne 0 ] || [ $((y % 400)) -eq 0 ]; }; then dim=29; fi ;; esac [ "$d" -ge 1 ] && [ "$d" -le "$dim" ] } # valid_transcript_mode VALUE — canonical grammar check (exit 0 = valid). valid_transcript_mode() { local v="$1" base base="${v%+repos}" case "$base" in recent|all) ;; off) [ "$base" = "$v" ] || return 1 ;; new@*) valid_utc_second "${base#new@}" || return 1 ;; *) return 1 ;; esac } # canonical_remote URL — same rules as canonicalizeRemote in # lib/gstack-memory-helpers.ts (test/transcript-consent.test.ts keeps them equal). canonical_remote() { local s="$1" s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}" s="${s#[\"\']}"; s="${s%[\"\']}" if [[ "$s" =~ ^[^@[:space:]]+@([^:]+):(.+)$ ]]; then s="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" else s=$(printf '%s' "$s" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://##; s#^[^@/]+@##') fi printf '%s' "$s" | sed -E 's#/+$##; s#\.[gG][iI][tT]$##; s#/+$##; s#/{2,}#/#g' | tr '[:upper:]' '[:lower:]' } # transcript_marker_sync — keep the +repos marker on transcript_ingest_mode in # step with transcript_repos inside the temp file: present while an allowlist # exists and the base consents, absent otherwise. Unrecognized values are # left untouched (they are not consent either way). transcript_marker_sync() { local mode list base mode=$(_cfg_get transcript_ingest_mode) [ -n "$mode" ] || return 0 valid_transcript_mode "$mode" || return 0 list=$(_cfg_get transcript_repos) base="${mode%+repos}" if [ -n "$list" ] && [ "$base" != "off" ]; then _cfg_put transcript_ingest_mode "$base+repos" else _cfg_put transcript_ingest_mode "$base"; fi } _STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md" _SELF="$(cd "$(dirname "$0")" && pwd)/gstack-config" # One line when the root-selecting variables name different directories. report_root_disagreement() { local _v _val _set="" _first="" _disagrees=0 for _v in GSTACK_STATE_ROOT GSTACK_HOME GSTACK_STATE_DIR; do eval "_val=\${$_v:-}" [ -n "$_val" ] || continue _set="$_set $_v=$_val" if [ -z "$_first" ]; then _first="$_val"; elif [ "$_val" != "$_first" ]; then _disagrees=1; fi done [ "$_disagrees" -eq 1 ] || return 0 echo "# note: root-selecting variables disagree:$_set; using $STATE_DIR ($_gstack_sr_var wins). Explain: $(dirname "$_SELF")/gstack-paths --explain ($_STATE_DOC)" } case "${1:-}" in get) if [ "${2:-}" = "--stored" ]; then KEY="${3:-}" [ -n "$KEY" ] || { echo "Usage: gstack-config get --stored " >&2; exit 1; } if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi # Provenance without DEFAULTS: the stored value, or "unset". A config # file that exists but cannot be read prints "unreadable" (exit 2), and # a privacy key holding an unknown value is refused the same way, so a # consent check never turns an unreadable file into the default. _stored_roots="$STATE_DIR" _gstack_config_rank "$KEY" x if [ -n "$_gstack_cfg_rank" ]; then gstack_legacy_root_select [ -n "$_gstack_legacy_root" ] && _stored_roots="$_stored_roots $_gstack_legacy_root" fi while IFS= read -r _stored_root; do if [ -e "$_stored_root/config.yaml" ] && { [ ! -f "$_stored_root/config.yaml" ] || [ ! -r "$_stored_root/config.yaml" ]; }; then echo "gstack-config: cannot read $_stored_root/config.yaml, so $KEY is unknown. fix: make it a readable file, then re-run (https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#outside-review-consent-missing)" >&2 printf 'unreadable'; exit 2 fi done <<< "$_stored_roots" gstack_config_select "$KEY" || true VALUE="$_gstack_cfg_value" if [ -z "$VALUE" ]; then printf 'unset'; exit 0; fi _gstack_config_rank "$KEY" "$VALUE" if [ "$_gstack_cfg_rank" = "-1" ]; then echo "gstack-config: $KEY is '$VALUE' in ${_gstack_cfg_root:+$_gstack_cfg_root/}config.yaml, which is not a recognized value. fix: gstack-config set $KEY (or remove that line) (https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#outside-review-consent-missing)" >&2 printf 'unreadable'; exit 2 fi printf '%s' "$VALUE" exit 0 fi KEY="${2:?Usage: gstack-config get }" # Validate key (alphanumeric + underscore + optional @ suffix for # endpoint-namespaced keys introduced by the brain-aware planning layer). # Endpoint ids are sha8/sha16 hex for remote MCP URLs, or the literal # "local" for stdio/PGLite engines (see endpoint_hash). if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi VALUE=$(read_config_value "$KEY" || true) if [ -z "$VALUE" ]; then # lookup_default exits non-zero for a key it does not know. Propagate # that, so the caller's `|| echo ""` can fire. A known key whose # default is empty still exits 0 and prints "". if ! VALUE=$(lookup_default "$KEY"); then exit 1 fi fi printf '%s' "$VALUE" ;; has) KEY="${2:?Usage: gstack-config has }" if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null ;; set) KEY="${2:?Usage: gstack-config set }" if [ "$#" -lt 3 ]; then echo "Usage: gstack-config set " >&2 exit 1 fi VALUE="$3" # A config value is one non-empty line (disabled_skills may be empty: it # clears the list). Reject rather than truncate, so a bad value never # replaces the stored one. if [ -z "$VALUE" ] && [ "$KEY" != "disabled_skills" ] && [ "$KEY" != "browse_extension_id" ]; then echo "Error: $KEY value is empty. Existing value left unchanged." >&2 exit 1 fi case "$VALUE" in *$'\n'*) echo "Error: $KEY value contains a newline. Existing value left unchanged." >&2 exit 1 ;; esac # Validate key (alphanumeric + underscore + optional @ suffix). # Accepts hex hashes and the literal "local" from endpoint_hash. if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-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" = "proactive" ]; then # Case-insensitive, and the YAML yes/no family maps like gstack-skill-start # reads a hand-edited value: an opt-out must never fail open to core. VALUE=$(printf '%s' "$VALUE" | tr '[:upper:]' '[:lower:]') case "$VALUE" in yes|y|on|1) VALUE="all" ;; no|n|none|0|disabled|never) VALUE="off" ;; core|all|off|true|false) ;; *) echo "Warning: proactive '$VALUE' not recognized. Valid values: core, all, off (true = all, false = off). Using core." >&2 VALUE="core" ;; esac fi if [ "$KEY" = "plan_mode_preamble" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then echo "Warning: plan_mode_preamble '$VALUE' not recognized. Valid values: on, off. Using on." >&2 VALUE="on" 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 if [ "$KEY" = "artifacts_sync_removals" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then echo "Warning: artifacts_sync_removals '$VALUE' not recognized. Valid values: on, off. 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" = "pair_agent" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then echo "Warning: pair_agent '$VALUE' not recognized. Valid values: on, off. Using off." >&2 VALUE="off" fi if [ "$KEY" = "founder_resources" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then echo "Warning: founder_resources '$VALUE' not recognized. Valid values: true, false. Using true." >&2 VALUE="true" 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 if [ "$KEY" = "timeline_stop_hook" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then echo "Warning: timeline_stop_hook '$VALUE' not recognized. Valid values: yes, no. Using yes." >&2 VALUE="yes" fi # codex_reviews controls workflow outside CLI calls. Unlike the warn-and-default keys above, # an invalid value is REJECTED and the existing setting is left unchanged — a typo # must never silently flip the switch and turn paid Codex calls on or off. if [ "$KEY" = "codex_reviews" ] && [ "$VALUE" != "enabled" ] && [ "$VALUE" != "disabled" ]; then echo "Error: codex_reviews '$VALUE' not recognized. Valid values: enabled, disabled. Existing value left unchanged." >&2 exit 1 fi # codex_effort feeds model_reasoning_effort on paid Codex calls: reject a # typo instead of coercing it (the probe refuses an invalid stored value too). if [ "$KEY" = "review_effort_tiers" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then echo "Error: review_effort_tiers '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2 exit 1 fi if [ "$KEY" = "codex_effort" ]; then case "$VALUE" in minimal|low|medium|high|xhigh|max|ultra) ;; *) echo "Error: codex_effort '$VALUE' not recognized. Valid values: minimal, low, medium, high, xhigh, max, ultra. Existing value left unchanged. fix: gstack-config unset codex_effort restores each gate's own effort (https://github.com/garrytan/gstack/blob/main/docs/model-policy.md#reasoning-effort)" >&2 exit 1 ;; esac fi if [ "$KEY" = "agent_model" ]; then VALUE="${VALUE#"${VALUE%%[![:space:]]*}"}" VALUE="${VALUE%"${VALUE##*[![:space:]]}"}" case "$(printf '%s' "$VALUE" | LC_ALL=C tr 'A-Z' 'a-z')" in host|opus|fable|sonnet|haiku|astra|sol|luna|terra) ;; *) printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[a-z][a-z0-9-]{0,31}/[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$' || { echo "Error: agent_model '$VALUE' not recognized. Use host, a family (opus, fable, sonnet, haiku, astra, sol, luna, terra) or / such as anthropic/claude-opus-5-5. Existing value left unchanged. fix: gstack-config unset agent_model returns to host (https://github.com/garrytan/gstack/blob/main/docs/model-policy.md#your-model-preference)" >&2 exit 1; } ;; esac fi case "$KEY" in plan_review_tier|implementation_tier|model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai) _mp_doc="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#model-policy-config" VALUE="${VALUE#"${VALUE%%[![:space:]]*}"}" VALUE="${VALUE%"${VALUE##*[![:space:]]}"}" _mp_valid=1 case "$KEY" in plan_review_tier) case "$VALUE" in frontier|smart|host) ;; *) _mp_valid="" ; _mp_rule="Valid values: frontier, smart, host." ;; esac ;; implementation_tier) case "$VALUE" in frontier|smart) ;; *) _mp_valid="" ; _mp_rule="Valid values: frontier, smart." ;; esac ;; *_claude) { [ "${#VALUE}" -le 512 ] && printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[][A-Za-z0-9._:/@+-]+$'; } || { _mp_valid=""; _mp_rule="An Anthropic model ID is 1-512 characters from A-Z a-z 0-9 . _ : / @ + [ ] -."; } ;; *_openai) printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[A-Za-z0-9._:/-]{1,100}$' || { _mp_valid=""; _mp_rule="An OpenAI model ID is 1-100 characters from A-Z a-z 0-9 . _ : / -."; } ;; esac if [ -z "$VALUE" ]; then echo "Error: $KEY value is empty. Existing value left unchanged. fix: gstack-config unset $KEY restores the default ($_mp_doc)" >&2 exit 1 fi if [ -z "$_mp_valid" ]; then echo "Error: $KEY '$VALUE' not recognized. $_mp_rule Existing value left unchanged. fix: gstack-config unset $KEY restores the default ($_mp_doc)" >&2 exit 1 fi ;; esac # The measure-loop controls bound paid spend: reject a typo instead of # coercing it, so a bad value never changes how much /ship may spend. case "$KEY" in ship_measure_rule_trials|ship_measure_behavior_panels|ship_measure_judge_outputs|ship_measure_max_rounds|ship_measure_sweep_cases) if ! printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[1-9][0-9]*$'; then echo "Error: $KEY '$VALUE' is not a positive integer. Existing value left unchanged." >&2 exit 1 fi ;; ship_measure_ask_per_trial_usd|ship_measure_budget_usd|ship_measure_sweep_budget_usd) if ! printf '%s' "$VALUE" | LC_ALL=C grep -qE '^([0-9]+(\.[0-9]+)?|\.[0-9]+)$' || ! printf '%s' "$VALUE" | LC_ALL=C grep -qE '[1-9]'; then echo "Error: $KEY '$VALUE' is not a positive amount in USD (e.g. 25 or 2.50). Existing value left unchanged." >&2 exit 1 fi ;; ship_greptile_early) if [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then echo "Error: ship_greptile_early '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2 exit 1 fi ;; pr_wait_gating_bots|pr_wait_advisory_bots) # Comma-separated bot names gstack-pr-wait matches against logins and # check names. Reject anything else so a stray token never widens or # empties the gate by accident; the literal none is the empty list. if ! printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[a-z0-9._-]+(,[a-z0-9._-]+)*$'; then echo "Error: $KEY '$VALUE' is not a comma-separated list of bot names (lowercase letters, digits, . _ -; e.g. greptile,macroscope) or none. Existing value left unchanged." >&2 exit 1 fi ;; ship_rerun_backend|test_backend) if [ "$VALUE" != "local" ] && [ "$VALUE" != "ubicloud" ]; then echo "Error: $KEY '$VALUE' not recognized. Valid values: local, ubicloud. Existing value left unchanged." >&2 exit 1 fi ;; update_channel) case "$VALUE" in auto|stable|main) ;; *) echo "Error: update_channel '$VALUE' not recognized. Valid values: auto, stable, main. Existing value left unchanged. (https://github.com/garrytan/gstack/blob/main/docs/releases.md#channels)" >&2 exit 1 ;; esac ;; esac # design_detector_install_prompted records "never ask again" for the one-time # engine install offer. Rejecting a typo keeps the offer from silently coming # back (or never coming back) because of a mistyped value. if [ "$KEY" = "design_detector_install_prompted" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then echo "Error: design_detector_install_prompted '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2 exit 1 fi # design_detector gates a third-party binary the user installed. Reject a typo # rather than coerce it: "of" must not silently re-enable or disable the scan. if [ "$KEY" = "design_detector" ] && [ "$VALUE" != "auto" ] && [ "$VALUE" != "off" ]; then echo "Error: design_detector '$VALUE' not recognized. Valid values: auto, off. Existing value left unchanged." >&2 exit 1 fi # cross_project_learnings: empty get is the first-run prompt sentinel. # Skills enable only on the literal "true". A typo must not persist — that # keeps the feature off and suppresses the prompt. Reject, like # codex_reviews; do not coerce (a stored default still kills the sentinel). if [ "$KEY" = "cross_project_learnings" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then echo "Error: cross_project_learnings '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2 exit 1 fi # transcript_ingest_mode is a CONSENT key: recent/all/new@ let /sync-gbrain # ingest coding-agent transcripts. Reject a typo; never coerce or store it. if [ "$KEY" = "transcript_ingest_mode" ] && ! valid_transcript_mode "$VALUE"; then echo "Error: transcript_ingest_mode '$VALUE' not recognized. Valid values: recent, all, off. Existing value left unchanged." >&2 echo "Also valid: new@ (only sessions that start after that UTC time, e.g. new@$(date -u +%Y-%m-%dT%H:%M:%SZ)). The +repos suffix follows transcript_repos; off never carries it. Docs: setup-gbrain/memory.md#transcripts" >&2 exit 1 fi # transcript_repos narrows transcript consent to these repos. Each entry is # stored as its canonical remote (host/owner/repo); an empty list would # deny every repo, so it is rejected: `unset` clears the allowlist. if [ "$KEY" = "transcript_repos" ]; then _clean="" _ifs="$IFS"; IFS=',' for _x in $VALUE; do _x="$(canonical_remote "$_x")" [ -n "$_x" ] || continue case ",$_clean," in *",$_x,"*) ;; *) _clean="${_clean:+$_clean,}$_x" ;; esac done IFS="$_ifs" if [ -z "$_clean" ]; then echo "Error: transcript_repos '$VALUE' names no repo. Valid values: comma-separated git remotes (e.g. github.com/owner/repo). Existing value left unchanged. To allow every repo: gstack-config unset transcript_repos" >&2 exit 1 fi VALUE="$_clean" fi # memorable_recall is a CONSENT key: `on` lets a Claude Code hook hand every # prompt to a third-party binary. Reject like codex_reviews -- a typo must # never flip consent in either direction, so nothing is coerced or stored. if [ "$KEY" = "memorable_recall" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then echo "Error: memorable_recall '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2 exit 1 fi # browse_extension_id (D1) decides which Chrome extension may reach the # browse daemon's token route and terminal socket. Reject anything that is # not a Chrome extension ID; "" clears the override. if [ "$KEY" = "browse_extension_id" ] && [ -n "$VALUE" ] && ! printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[a-p]{32}$'; then echo "Error: browse_extension_id '$VALUE' is not a Chrome extension ID (32 letters a-p, shown on chrome://extensions). Existing value left unchanged." >&2 exit 1 fi # disabled_skills (#1206): comma-separated skill names, one value for the # key. Every name must be a real skill (typos get the closest name); # gstack and gstack-upgrade can never be disabled; "" re-enables all. # Rejected values leave the existing setting unchanged. if [ "$KEY" = "disabled_skills" ]; then _GSTACK_ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" _known=" " for _md in "$_GSTACK_ROOT_DIR"/*/SKILL.md.tmpl "$_GSTACK_ROOT_DIR"/*/SKILL.md; do [ -f "$_md" ] || continue _d="$(basename "$(dirname "$_md")")" _n="$(sed -n '2,/^---$/ s/^name:[[:space:]]*//p' "$_md" | head -1 | tr -d '[:space:]')" for _x in "$_d" "${_n:-$_d}"; do case "$_known" in *" $_x "*) ;; *) _known="$_known$_x " ;; esac done done _clean="" for _x in $(printf '%s' "$VALUE" | tr ',' ' ' | tr -d "\"'"); do _x="${_x#/}" case "$_x" in gstack|gstack-upgrade) echo "Error: $_x cannot be disabled (it is how gstack upgrades and routes). Existing value left unchanged." >&2 exit 1 ;; esac _bare="${_x#gstack-}" case "$_known" in *" $_bare "*|*" $_x "*) ;; *) _best="$(for _k in $_known; do printf '%s\n' "$_k"; done | awk -v a="$_bare" ' function lev(s, t, i, j, n, m, c, d, v) { n = length(s); m = length(t) 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(s, i, 1) == substr(t, 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 } return d[n, m] } { x = lev(a, $0); if (best == "" || x < bd) { bd = x; best = $0 } } END { print best }')" echo "Error: unknown skill '$_x' in disabled_skills. Did you mean '$_best'? Existing value left unchanged." >&2 exit 1 ;; esac case ",$_clean," in *",$_bare,"*) ;; *) _clean="${_clean:+$_clean,}$_bare" ;; esac done VALUE="$_clean" for _x in $(printf '%s' "$_clean" | tr ',' ' '); do for _tmpl in "$_GSTACK_ROOT_DIR"/*/SKILL.md.tmpl; do [ -f "$_tmpl" ] || continue _caller="$(basename "$(dirname "$_tmpl")")" [ "$_caller" = "$_x" ] && continue case ",$_clean," in *",$_caller,"*) continue ;; esac if grep -qE "INVOKE_SKILL:$_x([^a-z-]|\$)|skills/gstack/$_x/(SKILL\.md|sections)" "$_tmpl" 2>/dev/null; then echo "Warning: /$_caller uses /$_x. /$_x is no longer registered as a skill, but its files stay installed so /$_caller keeps working." >&2 fi done done fi # Locked read-modify-write; the annotated header is written on first creation. config_lock config_edit_begin if [ "$KEY" = "transcript_ingest_mode" ] && [ "${VALUE%+repos}" != "$VALUE" ] && [ -z "$(_cfg_get transcript_repos)" ]; then rm -f "$_cfg_tmp" echo "Error: transcript_ingest_mode '$VALUE' needs a repo allowlist, but transcript_repos is not set. Existing value left unchanged. fix: gstack-config set transcript_repos , then set transcript_ingest_mode ${VALUE%+repos}" >&2 exit 1 fi _cfg_put "$KEY" "$VALUE" # The allowlist and its +repos marker change together, in this one rename. if [ "$KEY" = "transcript_ingest_mode" ] || [ "$KEY" = "transcript_repos" ]; then transcript_marker_sync if [ "$KEY" = "transcript_ingest_mode" ]; then VALUE="$(_cfg_get transcript_ingest_mode)"; fi fi config_edit_commit config_unlock SAFE_VALUE="$VALUE" # Report only when another root still overrides the value just written. gstack_config_select "$KEY" if [ -n "$_gstack_cfg_root" ] && [ "$_gstack_cfg_root" != "$STATE_DIR" ] && [ "$_gstack_cfg_value" != "$SAFE_VALUE" ]; then echo "gstack-config: set $KEY in $CONFIG_FILE, but $KEY is still '$_gstack_cfg_value': $_gstack_cfg_root/config.yaml sets that more restrictive value, and for privacy keys the most restrictive value across state roots wins." >&2 printf 'fix: GSTACK_STATE_ROOT=%q %q set %q %q (%s)\n' "$_gstack_cfg_root" "$_SELF" "$KEY" "$SAFE_VALUE" "$_STATE_DOC" >&2 fi # Auto-relink skills when prefix setting changes (skip during setup to avoid recursive call) if { [ "$KEY" = "skill_prefix" ] || [ "$KEY" = "disabled_skills" ]; } && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then GSTACK_RELINK="$(dirname "$0")/gstack-relink" if [ -x "$GSTACK_RELINK" ] && ! "$GSTACK_RELINK"; then echo "gstack-config: $KEY saved, but relinking the installed skills failed (see above), so skill names may not match yet. Retry: $GSTACK_RELINK" >&2 fi # gstack-relink covers Claude installs; every other registered host # applies the change on its next setup run. if [ "$KEY" = "disabled_skills" ] && [ -f "$(dirname "$0")/gstack-install-registry.sh" ]; then ( GSTACK_STATE_ROOT="$STATE_DIR"; . "$(dirname "$0")/gstack-install-registry.sh" gstack_install_registry_rows | awk -F '\t' '$1 != "claude" { print " apply to " $1 " (" $4 "): cd " $6 " && ./setup --host " $1 }' ) fi fi # Installed skills are rendered by ./setup, so the preamble choice lands # on its next run (every registered host). if [ "$KEY" = "plan_mode_preamble" ] && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then printf 'gstack-config: plan_mode_preamble=%s applies on the next ./setup run. Apply now: cd %q && ./setup --refresh-registered\n' "$VALUE" "$(cd "$(dirname "$0")/.." && pwd)" fi ;; unset) KEY="${2:?Usage: gstack-config unset }" if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi [ -f "$CONFIG_FILE" ] || exit 0 config_lock config_edit_begin _cfg_del "$KEY" # Clearing the allowlist drops the +repos marker in the same write. if [ "$KEY" = "transcript_repos" ]; then transcript_marker_sync; fi config_edit_commit config_unlock ;; list) if [ -f "$CONFIG_FILE" ]; then cat "$CONFIG_FILE" fi echo "" echo "# ─── Active values (including defaults for unset keys) ───" report_root_disagreement for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \ skill_prefix explain_level plan_mode_preamble \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \ timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \ browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \ ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do gstack_config_select "$KEY" VALUE="$_gstack_cfg_value" SOURCE="default" if [ -n "$VALUE" ]; then SOURCE="set" _gstack_config_rank "$KEY" x [ -n "$_gstack_cfg_rank" ] && SOURCE="set, $_gstack_cfg_root" 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 update_channel \ skill_prefix explain_level plan_mode_preamble \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \ timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \ browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \ ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; 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)" . "$SCRIPT_DIR/gstack-render-claude.sh" 2>/dev/null || { echo "$0: $SCRIPT_DIR/gstack-render-claude.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; } _PLAN_MODE_ARG="" [ "$("$0" get plan_mode_preamble 2>/dev/null || true)" != off ] || _PLAN_MODE_ARG="--plan-mode=off" 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. # The file goes in on stdin (E6, #1967): bash opens the path, so a native # Windows Python under Git Bash never has to resolve an MSYS /c/... path. PYTHON_CMD=$(command -v python3 || command -v python || true) if [ -n "$PYTHON_CMD" ]; then STATUS=$("$PYTHON_CMD" -c "import json,sys; d=json.load(sys.stdin); print(d.get('gbrain_local_status','unknown'))" < "$DETECTION_FILE" 2>/dev/null || echo unknown) VERSION=$("$PYTHON_CMD" -c "import json,sys; d=json.load(sys.stdin); print(d.get('gbrain_version') or 'unknown')" < "$DETECTION_FILE" 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|timeout|db-unreachable|thin-client|engine-locked) # "timeout" = slow-but-healthy engine (#1964); "db-unreachable" = # network/DNS failure with an intact config, transient like timeout # (A2); "thin-client" = # remote-HTTP MCP brain, no local engine by design (#2051); # "engine-locked" = same class (#2456): PGLite is single-writer, so a # live `gbrain serve` (typically an MCP server) holds the embedded DB. # gbrain is installed and healthy; a transient lock must not strip # brain blocks out of every SKILL.md. All get the same treatment as # "ok", matching gstack-gbrain-detect --is-ok and gen-skill-docs. echo "Detected gbrain v$VERSION (local-status: $STATUS)." # Render brain-aware blocks into an UNTRACKED out-dir (#2569) and # repoint the installed skills at it — the old in-place render wrote # into TRACKED files of the global install checkout, so the checkout # stayed permanently dirty and every upgrade grew a redundant stash. # Guards (never mutate an arbitrary directory): the install must # exist, not be a symlink (a symlinked install points at a dev # worktree — bin/dev-setup owns that flow), and look like a real # gstack clone. INSTALL_DIR="$HOME/.claude/skills/gstack" RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$STATE_DIR/render/claude}" if [ ! -d "$INSTALL_DIR" ]; then echo "No global install at $INSTALL_DIR — nothing to render. (Dev workspaces get blocks via bin/dev-setup.)" elif [ -L "$INSTALL_DIR" ]; then echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Run bin/dev-setup in that worktree instead." elif [ ! -f "$INSTALL_DIR/VERSION" ] || [ ! -f "$INSTALL_DIR/package.json" ]; then echo "Skip: $INSTALL_DIR doesn't look like a gstack clone (missing VERSION/package.json) — refusing to modify it." elif ! command -v bun >/dev/null 2>&1; then echo "Skip: bun not on PATH — can't render. Install bun, then re-run 'gstack-config gbrain-refresh'." else # Render into a tmp dir and swap it in only on SUCCESS. Installed # skills SYMLINK into $RENDER_DIR (gstack-relink prefers it), so # wiping it before the render meant one transient failure (bun # error, disk full, broken template) left every brain-aware # SKILL.md link dangling — the whole skill set vanished from # Claude Code until a successful re-render. A failed render now # leaves the previous render fully intact. gstack_claude_overlay "$INSTALL_DIR" "$0" RENDER_TMP="$RENDER_DIR.tmp.$$" rm -rf "$RENDER_TMP" if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} >/dev/null 2>&1 ); then gstack_render_lock "$RENDER_DIR" _swap_in_render "$RENDER_DIR" "$RENDER_TMP" gstack_render_unlock "$RENDER_DIR" gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok rendered # Repoint installed skills at the render — gstack-relink prefers # the render dir when present. "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true echo "Rendered brain-aware blocks into $RENDER_DIR — now live across all your projects' Claude sessions." echo "The install checkout stays clean: upgrades no longer stash generated render dirt (#2569)." else rm -rf "$RENDER_TMP" echo "Warning: render failed — previous render (if any) left in place, links stay valid." echo "Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error." gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok failed "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true fi fi ;; *) echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files." echo "Install gbrain (see /setup-gbrain) and re-run 'gstack-config gbrain-refresh' once it's configured." # Drop a brain-aware render, or re-render a pinned overlay without # brain blocks, so removing gbrain keeps the chosen overlay. INSTALL_DIR="$HOME/.claude/skills/gstack" RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$STATE_DIR/render/claude}" if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model || [ -n "$_PLAN_MODE_ARG" ]; } \ && [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ] && [ -f "$INSTALL_DIR/VERSION" ] && [ -f "$INSTALL_DIR/package.json" ]; then if ! command -v bun >/dev/null 2>&1; then echo "Skip: bun not on PATH — the Claude render at $RENDER_DIR was not refreshed. Install bun, then re-run 'gstack-config gbrain-refresh'." else gstack_claude_overlay "$INSTALL_DIR" "$0" gstack_claude_render_action "$RENDER_DIR" absent if [ "$_GSTACK_RENDER_ACTION" = plain ]; then if gstack_claude_render_plain "$INSTALL_DIR" "$RENDER_DIR"; then gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent rendered else gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent failed fi else if [ -d "$RENDER_DIR" ]; then rm -rf "$RENDER_DIR" echo "Removed the brain-aware render at $RENDER_DIR; Claude skills serve the committed files." fi gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent removed fi "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true fi fi ;; esac ;; *) echo "Usage: gstack-config {get|has|set|unset|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]" exit 1 ;; esac