diff --git a/bin/gstack-analytics b/bin/gstack-analytics index ad06edd16..8e814bcc9 100755 --- a/bin/gstack-analytics +++ b/bin/gstack-analytics @@ -8,10 +8,12 @@ # gstack-analytics all # all time # # Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) set -uo pipefail -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +. "$(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" JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl" # ─── Parse time window ─────────────────────────────────────── diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index d2c1a8cf3..a82f9e7a4 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -46,7 +46,9 @@ BASH_COMPAT=50 set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" URL_BIN="$SCRIPT_DIR/gstack-artifacts-url" REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" diff --git a/bin/gstack-brain-context-load.ts b/bin/gstack-brain-context-load.ts index 647ceb9d4..4e41b4952 100644 --- a/bin/gstack-brain-context-load.ts +++ b/bin/gstack-brain-context-load.ts @@ -38,6 +38,7 @@ import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants import { join, dirname, basename, resolve, delimiter } from "path"; import { spawnSync } from "child_process"; import { homedir } from "os"; +import { resolveStateRoot } from "../lib/state-root"; import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers"; @@ -67,7 +68,7 @@ interface QueryResult { // ── Constants ────────────────────────────────────────────────────────────── const HOME = homedir(); -const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); +const GSTACK_HOME = resolveStateRoot(); // 500ms hard cap per Section 1C; overridable for slow/loaded environments // (test harnesses under CI load, cold CLI starts). const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500); diff --git a/bin/gstack-brain-enqueue b/bin/gstack-brain-enqueue index 815eff31b..e0e97fb8b 100755 --- a/bin/gstack-brain-enqueue +++ b/bin/gstack-brain-enqueue @@ -31,7 +31,9 @@ set -uo pipefail FILE="${1:-}" [ -z "$FILE" ] && exit 0 -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" SPOOL="$GSTACK_HOME/.brain-queue.d" SKIP_FILE="$GSTACK_HOME/.brain-skip.txt" diff --git a/bin/gstack-brain-restore b/bin/gstack-brain-restore index 781ba700a..f81a14d13 100755 --- a/bin/gstack-brain-restore +++ b/bin/gstack-brain-restore @@ -37,7 +37,9 @@ BASH_COMPAT=50 set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index fcdd48b94..c203937f6 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -29,7 +29,9 @@ BASH_COMPAT=50 set -uo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" # Maildir-style spool: one FILE per record, --.json. # Writers (gstack-brain-enqueue, --discover-new) create records via tmp-file # + atomic rename; the drain deletes exactly the files it snapshotted. No diff --git a/bin/gstack-brain-uninstall b/bin/gstack-brain-uninstall index a240a855e..cb2a289c9 100755 --- a/bin/gstack-brain-uninstall +++ b/bin/gstack-brain-uninstall @@ -41,7 +41,9 @@ set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" # v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration. diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 2c372b02e..4c58ebe08 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -63,8 +63,11 @@ _gstack_codex_model_probe() { # Only call this AFTER _gstack_codex_auth_probe passes — probing without # auth just measures the auth failure again. local _codex_home="${CODEX_HOME:-$HOME/.codex}" - local _gstack_home="${GSTACK_HOME:-$HOME/.gstack}" - local _cache="$_gstack_home/.codex-model-probe" + # State root from the shared twin (sourced in a subshell, so the caller's + # shell is untouched). A missing twin only disables the cache. + local _gstack_home _cache="" + _gstack_home="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || _gstack_home="" + [ -n "$_gstack_home" ] && _cache="$_gstack_home/.codex-model-probe" local _model="${GSTACK_CODEX_MODEL:-gpt-6-astra}" # Cache signature: config.toml + auth.json mtimes + gstack model selection. # Editing the model env/config or re-logging-in invalidates the cached result @@ -83,7 +86,7 @@ _gstack_codex_model_probe() { _sig="${_cfg_m}-${_auth_m}-${_model_sig}" local _now _now=$(date +%s 2>/dev/null || echo 0) - if [ -f "$_cache" ]; then + if [ -n "$_cache" ] && [ -f "$_cache" ]; then local _c_line _c_status _c_ts _c_sig _c_line=$(head -1 "$_cache" 2>/dev/null) _c_status=$(printf '%s' "$_c_line" | cut -d' ' -f1) @@ -105,14 +108,12 @@ _gstack_codex_model_probe() { _out=$(_gstack_codex_timeout_wrapper 30 codex exec --skip-git-repo-check -s read-only -c "model=\"$_model\"" "reply OK" &1) _code=$? if [ "$_code" -eq 0 ]; then - mkdir -p "$_gstack_home" 2>/dev/null || true - printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true + [ -n "$_cache" ] && { mkdir -p "$_gstack_home" 2>/dev/null; printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null; } echo "MODEL_OK" return 0 fi if printf '%s' "$_out" | grep -qiE 'model.{0,40}is not supported|"status":[[:space:]]*400'; then - mkdir -p "$_gstack_home" 2>/dev/null || true - printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true + [ -n "$_cache" ] && { mkdir -p "$_gstack_home" 2>/dev/null; printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null; } echo "MODEL_UNUSABLE" printf '%s\n' "$_out" | grep -i "model" | head -3 echo "HINT: gstack requested model '$_model'." @@ -221,12 +222,15 @@ _gstack_codex_log_event() { local _event="$1" local _duration="${2:-0}" [ "${_TEL:-off}" = "off" ] && return 0 - mkdir -p "$HOME/.gstack/analytics" 2>/dev/null || return 0 + local _root + _root="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || return 0 + [ -n "$_root" ] || return 0 + mkdir -p "$_root/analytics" 2>/dev/null || return 0 local _ts _ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown) printf '{"skill":"codex","event":"%s","duration_s":"%s","ts":"%s"}\n' \ "$_event" "$_duration" "$_ts" \ - >> "$HOME/.gstack/analytics/skill-usage.jsonl" 2>/dev/null || true + >> "$_root/analytics/skill-usage.jsonl" 2>/dev/null || true } # --- Learnings log on hang -------------------------------------------------- diff --git a/bin/gstack-codex-session-import b/bin/gstack-codex-session-import index 7b1c5f07c..b4236202e 100755 --- a/bin/gstack-codex-session-import +++ b/bin/gstack-codex-session-import @@ -21,7 +21,9 @@ # derive all apply uniformly. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" CODEX_SESSIONS_ROOT="${CODEX_SESSIONS_ROOT:-$HOME/.codex/sessions}" MODE="latest" diff --git a/bin/gstack-design-detect.ts b/bin/gstack-design-detect.ts index 079ac37a2..76a24224a 100755 --- a/bin/gstack-design-detect.ts +++ b/bin/gstack-design-detect.ts @@ -57,7 +57,7 @@ * * Scan hardening: every target, explicit or derived from `--changed`, must be an * existing regular file or directory whose realpath lies under the repo root (or - * cwd) or under ${GSTACK_HOME:-~/.gstack}/projects//designs/ (where design- + * cwd) or under /projects//designs/ (where design- * review keeps rendered-DOM dumps: `designs//dom/**` are page dumps and * scan with --no-inline-ignores, because an `impeccable-disable` comment there is * page-controlled; other designs/ files are gstack-authored artifacts and keep @@ -77,7 +77,7 @@ * whose realpath lies inside the repo or cwd are ignored (IMPECCABLE_ENV_IGNORED). * * Observability: one content-free JSON line per probe/scan appended to - * ${GSTACK_HOME:-~/.gstack}/analytics/design-detector.jsonl (local file, no egress). + * /analytics/design-detector.jsonl (local file, no egress). * * Non-sink: this spawns a third-party binary the user installed over local * paths; gstack does not audit that engine's network behavior (NOTICE.md). @@ -94,6 +94,7 @@ import { ENGINE_RELEASE_BASE, ENGINE_ASSETS, ENGINE_PINS, } from '../lib/design-detect-contract'; import { writeReceipt, writeOutcome } from '../lib/egress-receipt'; +import { readConfigKey, resolveStateRoot } from '../lib/state-root'; import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog'; import { isFrontendPath } from '../lib/frontend-scope'; @@ -104,14 +105,9 @@ const HOME = os.homedir(); const REAL_HOME = realpathOrNull(HOME) ?? HOME; const ENV = process.env; -/** Where config.yaml lives: the same precedence bin/gstack-config uses. */ -function gstackStateDir(): string { - return ENV.GSTACK_STATE_ROOT || ENV.GSTACK_HOME || ENV.GSTACK_STATE_DIR || path.join(HOME, '.gstack'); -} - -/** Existing local analytics location; design artifacts resolve separately below. */ +/** Config, analytics and design artifacts share the one state root (lib/state-root.ts). */ function gstackHome(): string { - return ENV.GSTACK_HOME || path.join(HOME, '.gstack'); + return resolveStateRoot(ENV); } function realpathOrNull(p: string): string | null { @@ -130,23 +126,10 @@ function gitTopLevel(cwd: string): string | null { return top ? realpathOrNull(top) : null; } -/** One flat key from config.yaml, read the way bin/gstack-config resolves it (same STATE_DIR precedence); '' when unset. */ +/** One flat key from config.yaml via readConfigKey (the bin/gstack-config reader); '' when unset. */ function configValue(key: string): string { - const file = path.join(gstackStateDir(), 'config.yaml'); - try { - const text = fs.readFileSync(file, 'utf-8'); - let value = ''; - const re = new RegExp(`^${key}:\\s*(.*?)\\s*$`); - for (const line of text.split('\n')) { - const m = line.match(re); - if (!m) continue; - // flat YAML: drop a trailing comment and surrounding quotes - value = m[1].replace(/\s+#.*$/, '').trim().replace(/^["'](.*)["']$/, '$1'); - } - return value; - } catch { - return ''; - } + // flat YAML: drop a trailing comment and surrounding quotes + return (readConfigKey(key, ENV) ?? '').replace(/\s+#.*$/, '').trim().replace(/^["'](.*)["']$/, '$1'); } /** design_detector: `Off` by hand must not silently re-enable a third-party binary. */ @@ -785,12 +768,7 @@ function refuse(target: string, why: string) { } function designsRoot(): string { - // Match the artifact producer's bin/gstack-paths precedence without changing - // config or analytics roots. Plugin data belongs to gstack only with its marker. - const stateRoot = ENV.GSTACK_HOME - || (ENV.CLAUDE_PLUGIN_DATA && /gstack/i.test(ENV.CLAUDE_PLUGIN_ROOT || '') ? ENV.CLAUDE_PLUGIN_DATA : '') - || (ENV.HOME ? path.join(ENV.HOME, '.gstack') : '.gstack'); - return path.join(stateRoot, 'projects'); + return path.join(resolveStateRoot(ENV), 'projects'); } type TargetClass = 'project' | 'artifact' | 'dom-dump'; diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index 57ba9adb9..e5f4197f8 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -29,7 +29,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" # Windows git-bash: GSTACK_HOME resolves to an MSYS path (/c/Users/...), which # Bun on Windows cannot open as a filesystem path (bites --derive). Normalize # once, here, before PROFILE_FILE/LEGACY_FILE are derived from it below — diff --git a/bin/gstack-distill-apply b/bin/gstack-distill-apply index 5b97da0aa..d87128d40 100755 --- a/bin/gstack-distill-apply +++ b/bin/gstack-distill-apply @@ -24,7 +24,9 @@ # gstack-distill-apply --list # show pending proposals set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" diff --git a/bin/gstack-distill-free-text b/bin/gstack-distill-free-text index a7e997c0a..86cbd8918 100755 --- a/bin/gstack-distill-free-text +++ b/bin/gstack-distill-free-text @@ -32,7 +32,9 @@ set -euo pipefail BASH_COMPAT=50 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" diff --git a/bin/gstack-egress-lib.sh b/bin/gstack-egress-lib.sh index 37fe045a7..4cb50dbfb 100644 --- a/bin/gstack-egress-lib.sh +++ b/bin/gstack-egress-lib.sh @@ -43,13 +43,13 @@ case "${BASH_SOURCE[0]}" in *) _gstack_egress_lib_dir="$(pwd)" ;; esac +# State root from the shared twin (bin/gstack-state-root.sh, builtins only), +# sourced lazily on first use. _gstack_egress_home() { - if [ -n "${GSTACK_HOME:-}" ]; then - printf '%s' "$GSTACK_HOME" - elif [ -n "${GSTACK_STATE_DIR:-}" ]; then - printf '%s' "$GSTACK_STATE_DIR" + if command -v gstack_state_root >/dev/null 2>&1 || { [ -f "$_gstack_egress_lib_dir/gstack-state-root.sh" ] && . "$_gstack_egress_lib_dir/gstack-state-root.sh"; }; then + gstack_state_root else - printf '%s' "$HOME/.gstack" + printf '%s' "" fi } diff --git a/bin/gstack-gbrain-read-capability.ts b/bin/gstack-gbrain-read-capability.ts index 4850d0409..6cdee19cb 100644 --- a/bin/gstack-gbrain-read-capability.ts +++ b/bin/gstack-gbrain-read-capability.ts @@ -1,10 +1,10 @@ #!/usr/bin/env bun import { readFileSync, realpathSync, statSync } from 'node:fs'; -import { homedir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { gbrainInvocation, buildGbrainEnv } from '../lib/gbrain-exec'; import { parseSourcesList } from '../lib/gbrain-sources'; +import { resolveStateRoot } from '../lib/state-root'; type Verdict = { status: 'ready' | 'unknown' | 'skipped' | 'source'; reason: string; source_id?: string; page_count?: number }; @@ -20,7 +20,7 @@ function readCapability(): Verdict { try { root = realpathSync(repo.stdout.trim()); const pinPath = join(root, '.gbrain-source'); - const statePath = join(process.env.GSTACK_HOME || join(homedir(), '.gstack'), '.gbrain-sync-state.json'); + const statePath = join(resolveStateRoot(), '.gbrain-sync-state.json'); if (statSync(pinPath).size > 512 || statSync(statePath).size > 64 * 1024) return unknown('sync state or source pin exceeds the read limit'); pin = readFileSync(pinPath, 'utf8').trim(); diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup index 7947fd587..05e50b1df 100755 --- a/bin/gstack-gbrain-source-wireup +++ b/bin/gstack-gbrain-source-wireup @@ -43,7 +43,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" WORKTREE="${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}" # v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration. if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 9b0b67ed8..29d644c6c 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -44,6 +44,7 @@ import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-s import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS, bashScriptInvocation } from "../lib/gbrain-exec"; import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client"; import { checkOwnedStagingDir } from "../lib/staging-guard"; +import { resolveStateRoot } from "../lib/state-root"; // ── Types ────────────────────────────────────────────────────────────────── @@ -98,7 +99,7 @@ interface StageResult { // ── Constants ────────────────────────────────────────────────────────────── const HOME = homedir(); -const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); +const GSTACK_HOME = resolveStateRoot(); const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json"); const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock"); const STALE_LOCK_MS = 5 * 60 * 1000; @@ -118,7 +119,7 @@ const DREAM_MARKER_STALE_MS = DEFAULT_DREAM_TIMEOUT_MS; * module-load-time const captures the real ~/.gstack before a test can redirect. */ export function dreamMarkerPath(): string { - return join(process.env.GSTACK_HOME || join(homedir(), ".gstack"), ".dream-in-progress"); + return join(resolveStateRoot(), ".dream-in-progress"); } // Default 35-minute timeout for code-walk + memory-ingest stages. Override via diff --git a/bin/gstack-learnings-log b/bin/gstack-learnings-log index 3c47ebb80..fd0efaa56 100755 --- a/bin/gstack-learnings-log +++ b/bin/gstack-learnings-log @@ -14,7 +14,9 @@ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; esac eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="$1" diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index d7038e821..4e3d8ba46 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -8,7 +8,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" TYPE="" QUERY="" diff --git a/bin/gstack-memorable b/bin/gstack-memorable index 31b1a1dfd..993f163db 100755 --- a/bin/gstack-memorable +++ b/bin/gstack-memorable @@ -36,7 +36,9 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" GSTACK_CONFIG="$SCRIPT_DIR/gstack-config" -STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}" +. "$(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" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" HOOK_SOURCE="gstack-memorable" @@ -380,9 +382,9 @@ status_bridge() { n="unknown (gstack-egress list failed; run it yourself)" fi echo "receipts: $n for sink $SINK (gstack-egress list --sink $SINK)" - # Same resolution as lib/egress-receipt.ts resolveEgressHome: GSTACK_HOME, GSTACK_STATE_DIR, ~/.gstack. + # Same root lib/egress-receipt.ts resolveEgressHome uses (the shared state-root chain). local ledger size - ledger="${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}/security/egress.jsonl" + ledger="$STATE_DIR/security/egress.jsonl" if [ -f "$ledger" ]; then size="$(wc -c < "$ledger" | tr -d ' ')" if [ "${size:-0}" -gt 26214400 ]; then diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 412ee48f9..a94adf4fc 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -72,6 +72,7 @@ import { execGbrainText, spawnGbrainAsync } from "../lib/gbrain-exec"; import { writeReceipt } from "../lib/egress-receipt"; import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard"; import { hasRepoPolicyStore, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client"; +import { resolveStateRoot } from "../lib/state-root"; // ── Types ────────────────────────────────────────────────────────────────── @@ -186,7 +187,7 @@ interface BulkResult { // ── Constants ────────────────────────────────────────────────────────────── const HOME = homedir(); -const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); +const GSTACK_HOME = resolveStateRoot(); const STATE_PATH = join(GSTACK_HOME, ".transcript-ingest-state.json"); const DEFAULT_INCREMENTAL_BUDGET_MS = 50; diff --git a/bin/gstack-question-log b/bin/gstack-question-log index ea1afe1e3..d769bf371 100755 --- a/bin/gstack-question-log +++ b/bin/gstack-question-log @@ -35,7 +35,9 @@ case "$(uname -s)" in esac eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" # GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="$1" diff --git a/bin/gstack-question-preference b/bin/gstack-question-preference index f78d4b269..51de1347e 100755 --- a/bin/gstack-question-preference +++ b/bin/gstack-question-preference @@ -32,7 +32,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json" diff --git a/bin/gstack-relink b/bin/gstack-relink index ecc609cae..bafd24ef6 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -5,7 +5,7 @@ # gstack-relink # # Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) # GSTACK_INSTALL_DIR — override gstack install directory # GSTACK_SKILLS_DIR — override target skills directory set -euo pipefail @@ -40,7 +40,9 @@ PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false") # out-dir instead of the tracked install checkout. When a render exists for a # skill, relink serves it — otherwise a config change would silently flip # every skill back to the canonical (blockless) source. -RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" +. "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" +RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}" # ─── Ownership gate ─────────────────────────────────────────────────────────── # relink runs on every ./setup and used to `rm -rf` any same-name entry with a @@ -58,7 +60,7 @@ RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claud # refreshed in place. WEAK (byte-identity with our source, or gen-skill-docs' # two-line banner on a real file) proves only that the SKILL.md came from us: # it authorizes touching that one file, never deleting the directory, and a -# differing file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills// +# differing file is moved to $GSTACK_STATE_ROOT/backups/skills// # before we link over it (a user who started their own skill from a gstack # SKILL.md looks exactly like a pre-marker legacy copy). # @@ -183,7 +185,7 @@ _report_foreign() { # Weakly-proven real files we would otherwise overwrite go here, one summary # line at the end. mv, not cp: the link that follows needs the path free. -BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)" +BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)" BACKED_UP=() _backup_skill_md() { # Non-zero when the file could NOT be moved: the caller leaves the entry alone. diff --git a/bin/gstack-repo-mode b/bin/gstack-repo-mode index a5c5a6ba6..7b1dd901e 100755 --- a/bin/gstack-repo-mode +++ b/bin/gstack-repo-mode @@ -41,7 +41,9 @@ if [ -n "$OVERRIDE" ] && [ "$OVERRIDE" != "null" ]; then fi # Check cache (7-day TTL) -CACHE_DIR="$HOME/.gstack/projects/$SLUG" +. "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" +CACHE_DIR="$GSTACK_STATE_ROOT/projects/$SLUG" CACHE_FILE="$CACHE_DIR/repo-mode.json" if [ -f "$CACHE_FILE" ]; then # GNU first (#2195): on GNU coreutils `stat -f` SUCCEEDS with filesystem diff --git a/bin/gstack-retro-metrics b/bin/gstack-retro-metrics index 58c2c0518..e04f84a40 100755 --- a/bin/gstack-retro-metrics +++ b/bin/gstack-retro-metrics @@ -12,7 +12,7 @@ # # Conventions mirror bin/gstack-skill-start: # - Paths resolve $0-relative (works for every host + install layout). -# - State paths honor ${GSTACK_HOME:-$HOME/.gstack}. +# - State paths honor the shared state root (bin/gstack-paths). # - Error style: per-line `|| true`, never `set -e` — a mid-script failure # must not drop later METRIC lines. # - No heredocs (nothing to BASH_COMPAT-guard; see @@ -45,7 +45,9 @@ done # sibling-bin call must go through $_BIN, never bare PATH lookup). _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _BIN="$_SCRIPT_DIR" -_GH="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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" echo "RETRO_METRICS_PROTO: 1" diff --git a/bin/gstack-review-log b/bin/gstack-review-log index c9f6149a0..c1bdab264 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -13,7 +13,9 @@ export GIT_OPTIONAL_LOCKS=0 export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="${1:-}" diff --git a/bin/gstack-review-read b/bin/gstack-review-read index 16139f1d8..3725c3e72 100755 --- a/bin/gstack-review-read +++ b/bin/gstack-review-read @@ -14,7 +14,9 @@ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; esac eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown") if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun -e ' diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 15f8caa87..fdb2ce873 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -10,7 +10,10 @@ set +e GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +# Hook: source the state-root twin (never spawn gstack-paths); a broken +# install exits 0 silently, like every other error here. +. "$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh" 2>/dev/null || exit 0 +STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}" # Egress receipt helpers (_receipted_git): fail-open — an update pull must # never block a session over a receipt hiccup. diff --git a/bin/gstack-skill-end b/bin/gstack-skill-end index b4b3b0174..b270b8f3a 100755 --- a/bin/gstack-skill-end +++ b/bin/gstack-skill-end @@ -30,7 +30,9 @@ done _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _BIN="$_SCRIPT_DIR" -_GH="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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" _TEL=$("$_BIN/gstack-config" get telemetry 2>/dev/null || echo off) _TEL_END=$(date +%s) diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 941e3b994..44eaac5b9 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -51,7 +51,9 @@ done _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _BIN="$_SCRIPT_DIR" -_GH="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 diff --git a/bin/gstack-slug b/bin/gstack-slug index 12f0ed397..49cd56dc8 100755 --- a/bin/gstack-slug +++ b/bin/gstack-slug @@ -46,11 +46,13 @@ # injection when consumed via source or eval. set -euo pipefail -# GSTACK_HOME-aware, matching lib/bin-context.ts's native port (#2561): the +# State-root aware (bin/gstack-state-root.sh), matching lib/bin-context.ts (#2561): the # bash writer and the TS reader must key the SAME cache, and a test running # with GSTACK_HOME= must write its cache junk there, not into the real # home (observed: 2,528 stale temp-cwd entries accumulated in ~/.gstack). -CACHE_DIR="${GSTACK_HOME:-$HOME/.gstack}/slug-cache" +. "$(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; GSTACK_STATE_ROOT="$_gstack_sr_root" +CACHE_DIR="$GSTACK_STATE_ROOT/slug-cache" PROJECT_DIR="$(pwd)" # Encode absolute path as cache key: /Users/j/foo → _Users_j_foo CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_') diff --git a/bin/gstack-specialist-stats b/bin/gstack-specialist-stats index 3349c2b71..1b1d1eafb 100755 --- a/bin/gstack-specialist-stats +++ b/bin/gstack-specialist-stats @@ -8,7 +8,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" if [ ! -d "$PROJECT_DIR" ]; then diff --git a/bin/gstack-state-root.sh b/bin/gstack-state-root.sh index 236f2e27b..1ce264fda 100644 --- a/bin/gstack-state-root.sh +++ b/bin/gstack-state-root.sh @@ -85,6 +85,21 @@ _gstack_config_rank() { esac } +# gstack_legacy_root_select — sets _gstack_legacy_root to the second candidate +# root merged privacy settings are also read from: $HOME/.gstack +# (GSTACK_TEST_LEGACY_ROOT in tests), or "" when there is none or it is the +# resolved root. Call after gstack_state_root_select. +gstack_legacy_root_select() { + _gstack_legacy_root="" + if [ -n "${GSTACK_TEST_LEGACY_ROOT:-}" ]; then + _gstack_legacy_root="$GSTACK_TEST_LEGACY_ROOT" + elif [ -n "$_gstack_home_val" ]; then + _gstack_legacy_root="$_gstack_home_val/.gstack" + fi + [ "$_gstack_legacy_root" = "$_gstack_sr_root" ] && _gstack_legacy_root="" + return 0 +} + # gstack_config_select KEY — sets _gstack_cfg_value and _gstack_cfg_root (the # root that supplied it; both "" when unset). Merged privacy keys (telemetry, # memorable_recall, codex_reviews, update_check) take the most restrictive @@ -97,14 +112,9 @@ gstack_config_select() { [ -n "$_gstack_cfg_value" ] && _gstack_cfg_root="$_gstack_sr_root" _gstack_config_rank "$1" "x" [ -n "$_gstack_cfg_rank" ] || return 0 - if [ -n "${GSTACK_TEST_LEGACY_ROOT:-}" ]; then - _gstack_cfg_legacy="$GSTACK_TEST_LEGACY_ROOT" - elif [ -n "$_gstack_home_val" ]; then - _gstack_cfg_legacy="$_gstack_home_val/.gstack" - else - return 0 - fi - [ "$_gstack_cfg_legacy" = "$_gstack_sr_root" ] && return 0 + gstack_legacy_root_select + [ -n "$_gstack_legacy_root" ] || return 0 + _gstack_cfg_legacy="$_gstack_legacy_root" _gstack_config_from_root "$_gstack_cfg_legacy" "$1" [ -n "$_gstack_cfg_one" ] || return 0 if [ -z "$_gstack_cfg_value" ]; then diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log index 7b95e5ea7..f75fe7623 100755 --- a/bin/gstack-telemetry-log +++ b/bin/gstack-telemetry-log @@ -16,7 +16,7 @@ # epilogue may sweep. # # Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) # GSTACK_DIR — override auto-detected gstack root # # NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero @@ -29,7 +29,9 @@ SCRIPT_DIR="$GSTACK_DIR/bin" case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; esac -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +. "$(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" ANALYTICS_DIR="$STATE_DIR/analytics" JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" PENDING_DIR="$ANALYTICS_DIR" # .pending-* files live here @@ -148,7 +150,7 @@ fi # can't be guessed or correlated by someone who knows your machine identity. INSTALL_ID="" if [ "$TIER" = "community" ]; then - ID_FILE="$HOME/.gstack/installation-id" + ID_FILE="$STATE_DIR/installation-id" if [ -f "$ID_FILE" ]; then INSTALL_ID="$(cat "$ID_FILE" 2>/dev/null)" fi diff --git a/bin/gstack-telemetry-sync b/bin/gstack-telemetry-sync index 1a4ac02b8..9ad5d62f3 100755 --- a/bin/gstack-telemetry-sync +++ b/bin/gstack-telemetry-sync @@ -6,13 +6,15 @@ # Posts to the telemetry-ingest edge function (not PostgREST directly). # # Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) # GSTACK_DIR — override auto-detected gstack root # GSTACK_SUPABASE_URL — override Supabase project URL set -uo pipefail GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +. "$(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" # Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed. . "$GSTACK_DIR/bin/gstack-egress-lib.sh" diff --git a/bin/gstack-timeline-log b/bin/gstack-timeline-log index 6b7dc7e4e..0f24f8a23 100755 --- a/bin/gstack-timeline-log +++ b/bin/gstack-timeline-log @@ -12,7 +12,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="$1" diff --git a/bin/gstack-timeline-read b/bin/gstack-timeline-read index 5c1b6bb6f..be2dc1d60 100755 --- a/bin/gstack-timeline-read +++ b/bin/gstack-timeline-read @@ -8,7 +8,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(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 +GSTACK_HOME="$_gstack_sr_root" SINCE="" LIMIT=20 diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index eaa30a013..8adad5600 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -4,7 +4,15 @@ # Usage: # gstack-uninstall — interactive uninstall (prompts before removing) # gstack-uninstall --force — remove everything without prompting -# gstack-uninstall --keep-state — remove skills but keep ~/.gstack/ data +# gstack-uninstall --keep-state — remove skills but keep all gstack state +# +# State: only the default root ~/.gstack/ is ever deleted. A state root +# selected elsewhere (GSTACK_HOME, GSTACK_STATE_ROOT, GSTACK_STATE_DIR or +# plugin data; chain in bin/gstack-state-root.sh) is left in place and the +# exact removal command is printed. Uninstall refuses (exit 2) when ~/.gstack +# resolves to /, $HOME or an ancestor, the gstack checkout, the current git +# repository, or an ancestor of either. +# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md # # What gets REMOVED: # ~/.claude/skills/gstack — global Claude skill install (git clone or vendored) @@ -28,7 +36,6 @@ # # Env overrides (for testing): # GSTACK_DIR — override auto-detected gstack root -# GSTACK_STATE_DIR — override ~/.gstack state directory # # NOTE: Uses set -uo pipefail (no -e) — uninstall must never abort partway. set -uo pipefail @@ -39,7 +46,14 @@ if [ -z "${HOME:-}" ]; then fi GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +# Only the default root is ever deleted; RESOLVED_ROOT (the full chain) is +# used read-only (daemon discovery) and reported when it is elsewhere. +STATE_DIR="$HOME/.gstack" +_STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md" +. "$(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 ($_STATE_DOC)" >&2; exit 1; } +gstack_state_root_select +RESOLVED_ROOT="$_gstack_sr_root" +RESOLVED_VAR="$_gstack_sr_var" _GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" # ─── Parse flags ───────────────────────────────────────────── @@ -61,6 +75,53 @@ while [ $# -gt 0 ]; do esac done +# ─── State-root safety ─────────────────────────────────────── +_real() { (cd -P -- "$1" 2>/dev/null && pwd -P); } +# _is_at_or_above TARGET PATH — TARGET is PATH or an ancestor of it. +_is_at_or_above() { + [ -n "$2" ] || return 1 + [ "$1" = "$2" ] && return 0 + case "$2/" in "${1%/}/"*) return 0 ;; esac + return 1 +} +_refuse() { + echo "gstack-uninstall: refusing to delete $STATE_DIR: $1" >&2 + echo "fix: $2 (docs: $_STATE_DOC)" >&2 + exit 2 +} +if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then + _STATE_REAL="$(_real "$STATE_DIR")" + _HOME_REAL="$(_real "$HOME")" + _CHECKOUT_REAL="$(_real "$GSTACK_DIR")" + _GIT_REAL="" + [ -n "$_GIT_ROOT" ] && _GIT_REAL="$(_real "$_GIT_ROOT")" + if [ -z "$_STATE_REAL" ] || [ "$_STATE_REAL" = "/" ]; then + _refuse "it resolves to the filesystem root /" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state" + elif _is_at_or_above "$_STATE_REAL" "$_HOME_REAL"; then + _refuse "it resolves to $_STATE_REAL, which is your home directory or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state" + elif _is_at_or_above "$_STATE_REAL" "$_CHECKOUT_REAL"; then + _refuse "it resolves to $_STATE_REAL, which is the gstack checkout $_CHECKOUT_REAL or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state" + elif _is_at_or_above "$_STATE_REAL" "$_GIT_REAL"; then + _refuse "it resolves to $_STATE_REAL, which is the current git repository $_GIT_REAL or an ancestor of it" "run gstack-uninstall from outside $_GIT_REAL, or re-run with --keep-state" + fi +fi +# The resolved root is left in place when it is not the default root. +_LEAVE_ROOT="" +if [ "$KEEP_STATE" -eq 0 ] && [ -e "$RESOLVED_ROOT" ]; then + _RESOLVED_REAL="$(_real "$RESOLVED_ROOT")" + _DEFAULT_REAL="$(_real "$STATE_DIR")" + if [ "$RESOLVED_ROOT" != "$STATE_DIR" ] && { [ -z "$_RESOLVED_REAL" ] || [ "$_RESOLVED_REAL" != "$_DEFAULT_REAL" ]; }; then + _LEAVE_ROOT="$RESOLVED_ROOT" + fi +fi +_report_left_root() { + [ -n "$_LEAVE_ROOT" ] || return 0 + local _val + eval "_val=\${$RESOLVED_VAR:-}" + echo "left in place: $_LEAVE_ROOT (selected by $RESOLVED_VAR=$_val)" >&2 + echo "fix: after checking it, remove it with rm -rf -- '$_LEAVE_ROOT' (docs: $_STATE_DOC)" >&2 +} + # ─── Confirmation ──────────────────────────────────────────── if [ "$FORCE" -eq 0 ]; then echo "This will remove gstack from your system:" @@ -70,6 +131,7 @@ if [ "$FORCE" -eq 0 ]; then [ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*" [ -d "$HOME/.cursor/skills" ] && echo " ~/.cursor/skills/gstack*" [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR" + [ -n "$_LEAVE_ROOT" ] && echo " (kept) $_LEAVE_ROOT: state root selected by $RESOLVED_VAR; left in place" if [ -n "$_GIT_ROOT" ]; then [ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)" @@ -125,12 +187,16 @@ if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then stop_browse_daemon "$_GIT_ROOT/.gstack/browse.json" fi -# Stop daemons tracked in global projects directory -if [ -d "$STATE_DIR/projects" ]; then +# Stop daemons tracked in global projects directories (the resolved root is +# only read here, never deleted). +_PROJ_ROOTS=("$STATE_DIR") +[ "$RESOLVED_ROOT" != "$STATE_DIR" ] && _PROJ_ROOTS+=("$RESOLVED_ROOT") +for _PROJ_ROOT in "${_PROJ_ROOTS[@]}"; do + [ -d "$_PROJ_ROOT/projects" ] || continue while IFS= read -r _BJ; do stop_browse_daemon "$_BJ" - done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true) -fi + done < <(find "$_PROJ_ROOT/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true) +done # ─── Remove gstack hooks from Claude Code settings ────────── # MUST run BEFORE any install-root deletion: SETTINGS_HOOK resolves inside the @@ -400,6 +466,8 @@ for _TMP in /tmp/gstack-latest-version /tmp/gstack-sketch-*.html /tmp/gstack-ske fi done +_report_left_root + # ─── Skipped-entry report ─────────────────────────────────── # Everything any provenance gate refused to delete (Claude shapes 2/3, # cursor real dirs) — listed once, at the end, so nothing is silent. diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 3573c7878..0337040be 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -10,7 +10,7 @@ # GSTACK_DIR — override auto-detected gstack root # GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback) # GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) set -euo pipefail # A crash must not read as "up to date" (#1974). With set -e, any unguarded @@ -22,7 +22,9 @@ set -E trap 'rc=$?; echo "CHECK_FAILED gstack-update-check crashed (line $LINENO, rc=$rc) — update status UNKNOWN, not up-to-date"; exit 0' ERR GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +. "$(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" # Egress receipt helpers (_receipted_curl / _receipted_git). Update checks # are fail-OPEN: a receipt hiccup warns but never blocks the version check. diff --git a/bin/gstack-verify-gate b/bin/gstack-verify-gate index 41f02cb5a..240eace6d 100755 --- a/bin/gstack-verify-gate +++ b/bin/gstack-verify-gate @@ -12,7 +12,7 @@ # NEVER runs until the user records it in the per-repo trust store: # gstack-verify-gate --trust (run from inside the repo) # The store maps realpath(repo root) -> sha256(command) at -# ${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust (flat "pathhash", +# $GSTACK_STATE_ROOT/verify-gate-trust (flat "pathhash", # 0600, atomic rewrite). Any edit to the declared command invalidates trust # until --trust is run again. Untrusted commands never block the turn. # @@ -23,7 +23,15 @@ set -uo pipefail TAB="$(printf '\t')" -STORE="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust" +# Hook: source the state-root twin (never spawn gstack-paths). A broken +# install fails open, like every other absence. +_VG_TWIN="$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh" +if [ ! -f "$_VG_TWIN" ] || ! . "$_VG_TWIN" 2>/dev/null; then + echo "verify-gate: $_VG_TWIN missing; gate skipped (reinstall with ./setup or /gstack-upgrade)" + exit 0 +fi +GSTACK_STATE_ROOT="$(gstack_state_root; printf x)"; GSTACK_STATE_ROOT="${GSTACK_STATE_ROOT%x}" +STORE="$GSTACK_STATE_ROOT/verify-gate-trust" _sha256() { if command -v shasum >/dev/null 2>&1; then @@ -100,7 +108,7 @@ _json_escape() { # Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim. _log_trust_grant() { local sec_dir log tty ts - sec_dir="${GSTACK_HOME:-$HOME/.gstack}/security" + sec_dir="$GSTACK_STATE_ROOT/security" log="$sec_dir/verify-gate-trust-grants.jsonl" tty=false [ -t 0 ] && tty=true @@ -175,7 +183,7 @@ _session_key() { [ -n "$sid" ] || sid="ppid-$PPID" _sha256 "$sid|$(_trust_key)" } -ATTEMPTS_DIR="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-attempts" +ATTEMPTS_DIR="$GSTACK_STATE_ROOT/verify-gate-attempts" COUNTER="$ATTEMPTS_DIR/$(_session_key)" # A first entry (stop_hook_active=false) starts a fresh blocking episode. diff --git a/browse/src/browser-skill-write.ts b/browse/src/browser-skill-write.ts index 81599b419..4d0262a46 100644 --- a/browse/src/browser-skill-write.ts +++ b/browse/src/browser-skill-write.ts @@ -18,11 +18,11 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { mkdirSecure } from './file-permissions'; import { isPathWithin } from './platform'; import type { TierPaths } from './browser-skills'; import { defaultTierPaths } from './browser-skills'; +import { resolveStateRoot } from '../../lib/state-root'; // ─── Naming validation ────────────────────────────────────────── @@ -71,7 +71,7 @@ export function stageSkill(opts: StageSkillOptions): string { } const spawnId = opts.spawnId ?? generateSpawnId(); - const tmpRoot = opts.tmpRoot ?? path.join(os.homedir(), '.gstack', '.tmp'); + const tmpRoot = opts.tmpRoot ?? path.join(resolveStateRoot(), '.tmp'); const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`); const stagedDir = path.join(wrapperDir, opts.name); diff --git a/browse/src/browser-skills.ts b/browse/src/browser-skills.ts index 277f38096..009d3ea98 100644 --- a/browse/src/browser-skills.ts +++ b/browse/src/browser-skills.ts @@ -22,8 +22,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import * as cp from 'child_process'; +import { resolveStateRoot } from '../../lib/state-root'; // ─── Types ────────────────────────────────────────────────────── @@ -85,13 +85,13 @@ export interface TierPaths { * Project tier requires git or a project hint; returns null when neither resolves. */ export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bundledRoot?: string } = {}): TierPaths { - const home = opts.home ?? os.homedir(); + const stateRoot = opts.home !== undefined ? path.join(opts.home, '.gstack') : resolveStateRoot(); const projectRoot = opts.projectRoot ?? detectProjectRoot(); const bundledRoot = opts.bundledRoot ?? detectBundledRoot(); return { project: projectRoot ? path.join(projectRoot, '.gstack', 'browser-skills') : null, - global: path.join(home, '.gstack', 'browser-skills'), + global: path.join(stateRoot, 'browser-skills'), bundled: path.join(bundledRoot, 'browser-skills'), }; } diff --git a/browse/src/config.ts b/browse/src/config.ts index f7d7eeefa..2274dad7b 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -11,10 +11,10 @@ */ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { mkdirSecure } from './file-permissions'; import { safeUnlinkQuiet } from './error-handling'; +import { readConfigKey, resolveStateRoot } from '../../lib/state-root'; export interface BrowseConfig { projectDir: string; @@ -193,39 +193,26 @@ export function readVersionHash(execPath: string = process.execPath): string | n } } -/** - * Resolve the gstack home directory. - * - * Honors the existing convention used by telemetry.ts and domain-skills.ts: - * 1. GSTACK_HOME env (explicit override) - * 2. $HOME/.gstack (default) - */ +/** The gstack state root: delegates to the shared chain in lib/state-root.ts. */ export function resolveGstackHome(): string { - return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); + return resolveStateRoot(); } /** - * Read one key from the flat-YAML config store at /config.yaml - * (the shape bin/gstack-config writes: `key: value` lines). Tolerates - * optional single/double quotes around the value and a trailing `# comment`. - * Returns the unquoted value string, or null when the file is missing or - * unreadable or the key is absent. + * Read one key from the flat-YAML config store via readConfigKey (the same + * reader bin/gstack-config uses, including the most-restrictive merge for + * privacy keys such as telemetry). Tolerates optional single/double quotes + * around the value and a trailing `# comment`. Returns the unquoted value + * string, or null when the file is missing or unreadable or the key is absent. * * Single source of truth for flat-YAML key reads — isPairAgentEnabled * (pair_agent) and telemetry.ts (telemetry tier) both route through it so * the two consent gates can never drift on parsing semantics. */ export function readGstackConfigYamlKey(key: string): string | null { - const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - try { - const yaml = fs.readFileSync(path.join(resolveGstackHome(), 'config.yaml'), 'utf-8'); - // Last match wins: bin/gstack-config's `get` reads duplicates with - // `tail -1`, and both surfaces must agree on the same line. - const all = [...yaml.matchAll(new RegExp(`^\\s*${escaped}\\s*:\\s*['"]?([^'"#\\n]*?)['"]?\\s*(?:#.*)?$`, 'gm'))]; - return all.length > 0 ? all[all.length - 1][1] : null; - } catch { - return null; - } + const raw = readConfigKey(key); + if (raw === null) return null; + return raw.replace(/\s*#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2'); } /** diff --git a/browse/src/domain-skills.ts b/browse/src/domain-skills.ts index 92258fee3..ae8112b8b 100644 --- a/browse/src/domain-skills.ts +++ b/browse/src/domain-skills.ts @@ -35,9 +35,9 @@ import { promises as fs } from 'fs'; import { open as fsOpen, constants as fsConstants } from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { createHash } from 'crypto'; import type { Page } from 'playwright'; +import { resolveStateRoot } from '../../lib/state-root'; export type SkillState = 'quarantined' | 'active' | 'global'; export type SkillScope = 'project' | 'global'; @@ -63,7 +63,7 @@ export interface DomainSkillRow { const PROMOTE_THRESHOLD = 3; function gstackHome(): string { - return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); + return resolveStateRoot(); } function globalFile(): string { diff --git a/browse/src/security-classifier.ts b/browse/src/security-classifier.ts index a89ddf694..595dea73f 100644 --- a/browse/src/security-classifier.ts +++ b/browse/src/security-classifier.ts @@ -24,9 +24,9 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { mkdirSecure } from './file-permissions'; import { type LayerSignal } from './security'; +import { resolveStateRoot } from '../../lib/state-root'; // ─── Model location + packaging ────────────────────────────── @@ -45,7 +45,7 @@ import { type LayerSignal } from './security'; * vocab.txt * onnx/model.onnx (~112MB) */ -const MODELS_DIR = path.join(os.homedir(), '.gstack', 'models'); +const MODELS_DIR = path.join(resolveStateRoot(), 'models'); const TESTSAVANT_DIR = path.join(MODELS_DIR, 'testsavant-small'); const TESTSAVANT_HF_URL = 'https://huggingface.co/testsavantai/prompt-injection-defender-small-v0-onnx/resolve/main'; const TESTSAVANT_FILES = [ diff --git a/browse/src/telemetry.ts b/browse/src/telemetry.ts index c71ca8ae2..ad9092579 100644 --- a/browse/src/telemetry.ts +++ b/browse/src/telemetry.ts @@ -20,11 +20,11 @@ import { promises as fs } from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { readGstackConfigYamlKey } from './config'; +import { resolveStateRoot } from '../../lib/state-root'; function gstackHome(): string { - return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); + return resolveStateRoot(); } function analyticsDir(): string { diff --git a/browse/src/tunnel-denial-log.ts b/browse/src/tunnel-denial-log.ts index 82b9c34a5..15bec5bd1 100644 --- a/browse/src/tunnel-denial-log.ts +++ b/browse/src/tunnel-denial-log.ts @@ -17,10 +17,10 @@ */ import { promises as fsp } from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { mkdirSecure } from './file-permissions'; +import { resolveStateRoot } from '../../lib/state-root'; -const LOG_DIR = path.join(os.homedir(), '.gstack', 'security'); +const LOG_DIR = path.join(resolveStateRoot(), 'security'); const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl'); const RATE_CAP = 60; // writes per minute const WINDOW_MS = 60_000; diff --git a/browse/test/gstack-update-check.test.ts b/browse/test/gstack-update-check.test.ts index 6b78263f7..a04886aa0 100644 --- a/browse/test/gstack-update-check.test.ts +++ b/browse/test/gstack-update-check.test.ts @@ -58,6 +58,11 @@ beforeEach(() => { join(import.meta.dir, '..', '..', 'bin', 'gstack-egress-lib.sh'), join(binDir, 'gstack-egress-lib.sh'), ); + // Same for the state-root twin every migrated bin sources (docs/state-root.md). + symlinkSync( + join(import.meta.dir, '..', '..', 'bin', 'gstack-state-root.sh'), + join(binDir, 'gstack-state-root.sh'), + ); }); afterEach(() => { diff --git a/careful/bin/hook-extract.sh b/careful/bin/hook-extract.sh index 594b4d6c0..2934beb2e 100644 --- a/careful/bin/hook-extract.sh +++ b/careful/bin/hook-extract.sh @@ -64,40 +64,36 @@ gstack_hook_decision() { } # gstack_hook_state_root -# Print the gstack state root, resolved with EXACTLY the chain bin/gstack-paths -# uses (GSTACK_STATE_ROOT): GSTACK_HOME, then CLAUDE_PLUGIN_DATA only when -# CLAUDE_PLUGIN_ROOT names gstack (a CLAUDE_PLUGIN_DATA leaked from another -# plugin via CLAUDE_ENV_FILE must not redirect our state), then $HOME/.gstack, -# then a project-local .gstack. Hooks run on every Edit/Bash call, so this is -# pure bash — never spawn gstack-paths from a hook. The writers (/freeze, -# /guard, /unfreeze, /investigate) resolve through gstack-paths; a reader that -# used a different chain failed OPEN whenever GSTACK_HOME was set (#1459). -# test/hook-scripts.test.ts pins parity against gstack-paths. +# Print the gstack state root: a thin wrapper over gstack_state_root from +# bin/gstack-state-root.sh, the one bash implementation of the chain +# bin/gstack-paths uses (docs/state-root.md). Hooks run on every Edit/Bash +# call, so the twin is pure bash — never spawn gstack-paths from a hook. The +# writers (/freeze, /guard, /unfreeze, /investigate) resolve through +# gstack-paths; a reader that used a different chain failed OPEN whenever +# GSTACK_HOME was set (#1459). test/hook-scripts.test.ts pins parity. # Printed WITHOUT a trailing newline: callers capture with a sentinel # (`r="$(gstack_hook_state_root; printf x)"; r="${r%x}"`) so a root that -# itself ends in a newline round-trips exactly as gstack-paths' %q does — -# otherwise writer and reader would again disagree on the directory. +# itself ends in a newline round-trips exactly as gstack-paths' %q does. +# When the twin is missing (partial upgrade) the wrapper is removed, so +# callers take their own fallback: careful asks, freeze fails closed. gstack_hook_state_root() { - if [ -n "${GSTACK_HOME:-}" ]; then - printf '%s' "$GSTACK_HOME" - elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && printf '%s' "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then - printf '%s' "$CLAUDE_PLUGIN_DATA" - elif [ -n "${HOME:-}" ]; then - printf '%s' "$HOME/.gstack" - else - printf '%s' ".gstack" - fi + gstack_state_root } +_ghsr_twin="${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" +if ! { [ -f "$_ghsr_twin" ] && . "$_ghsr_twin" 2>/dev/null; } || ! command -v gstack_state_root >/dev/null 2>&1; then + unset -f gstack_hook_state_root +fi # gstack_hook_log_fire SKILL PATTERN # Append a hook_fire analytics record (pattern name only, never command -# content). Respects GSTACK_HOME so tests never pollute the operator's real -# analytics file. Deliberately NOT gstack_hook_state_root: every other +# content) under the resolved state root, the same root every other # analytics writer and reader (gstack-skill-start, gstack-retro-metrics, -# gstack-analytics) uses this two-step chain, and the usage log must stay one -# file. Best-effort: failures never affect the hook decision. +# gstack-analytics) uses, so the usage log stays one file and tests never +# pollute the operator's real analytics file. Best-effort: failures (or a +# missing twin) never affect the hook decision. gstack_hook_log_fire() { - _ghlf_dir="${GSTACK_HOME:-$HOME/.gstack}/analytics" + command -v gstack_state_root >/dev/null 2>&1 || return 0 + _ghlf_dir="$(gstack_state_root; printf x)"; _ghlf_dir="${_ghlf_dir%x}/analytics" mkdir -p "$_ghlf_dir" 2>/dev/null || true # Fields are JSON-encoded (a repo basename can carry quotes/backslashes) — # same rule this file states for decisions: never raw-interpolate into JSON. diff --git a/design/src/daemon-state.ts b/design/src/daemon-state.ts index dd20e660f..7f6dc951a 100644 --- a/design/src/daemon-state.ts +++ b/design/src/daemon-state.ts @@ -10,8 +10,8 @@ import { execFileSync } from "child_process"; import fs from "fs"; -import os from "os"; import path from "path"; +import { resolveStateRoot } from "../../lib/state-root"; export interface DaemonState { pid: number; @@ -48,11 +48,11 @@ export function resolveLockFilePath(stateFile: string = resolveStateFilePath()): } export function resolveDaemonLogPath(): string { - return path.join(os.homedir(), ".gstack", "design-daemon.log"); + return path.join(resolveStateRoot(), "design-daemon.log"); } export function resolveStartupLogPath(): string { - return path.join(os.homedir(), ".gstack", "design-daemon-startup.log"); + return path.join(resolveStateRoot(), "design-daemon-startup.log"); } /** diff --git a/freeze/bin/freeze-state.sh b/freeze/bin/freeze-state.sh index 6e64c045e..ad61b80cd 100755 --- a/freeze/bin/freeze-state.sh +++ b/freeze/bin/freeze-state.sh @@ -3,6 +3,10 @@ set -euo pipefail _here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$_here/../../careful/bin/hook-extract.sh" +if ! command -v gstack_hook_state_root >/dev/null 2>&1; then + echo "FREEZE_ERROR: cannot resolve the gstack state root: $_here/../../bin/gstack-state-root.sh is missing. No boundary changed. Fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)." >&2 + exit 1 +fi STATE_DIR="$(gstack_hook_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}" mkdir -p "$STATE_DIR" STATE_DIR="$(cd "$STATE_DIR" && pwd -P && printf x)"; STATE_DIR="${STATE_DIR%$'\nx'}" diff --git a/gstack-upgrade/migrations/v0.16.2.0.sh b/gstack-upgrade/migrations/v0.16.2.0.sh index afdc09d95..534db737b 100755 --- a/gstack-upgrade/migrations/v0.16.2.0.sh +++ b/gstack-upgrade/migrations/v0.16.2.0.sh @@ -11,7 +11,8 @@ # Affected: users who ran /office-hours before this version set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" PROFILE_FILE="$GSTACK_HOME/builder-profile.jsonl" # Find all per-project resource logs diff --git a/gstack-upgrade/migrations/v1.0.0.0.sh b/gstack-upgrade/migrations/v1.0.0.0.sh index 2e62fe06a..f43ea3f6b 100755 --- a/gstack-upgrade/migrations/v1.0.0.0.sh +++ b/gstack-upgrade/migrations/v1.0.0.0.sh @@ -13,7 +13,8 @@ # Affected: every user on v0.19.x and below who upgrades to v1.x set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" PROMPTED_FLAG="$GSTACK_HOME/.writing-style-prompted" PENDING_FLAG="$GSTACK_HOME/.writing-style-prompt-pending" diff --git a/gstack-upgrade/migrations/v1.17.0.0.sh b/gstack-upgrade/migrations/v1.17.0.0.sh index 5b8f1dd95..7048c7363 100755 --- a/gstack-upgrade/migrations/v1.17.0.0.sh +++ b/gstack-upgrade/migrations/v1.17.0.0.sh @@ -38,7 +38,9 @@ if [ "$SYNC_MODE" = "off" ] || [ -z "$SYNC_MODE" ]; then fi # Skip if no brain-sync git repo exists. -if [ ! -d "${HOME}/.gstack/.git" ]; then +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +if [ ! -d "$_gstack_sr_root/.git" ]; then exit 0 fi diff --git a/gstack-upgrade/migrations/v1.27.0.0.sh b/gstack-upgrade/migrations/v1.27.0.0.sh index 65ac82890..e43466e38 100755 --- a/gstack-upgrade/migrations/v1.27.0.0.sh +++ b/gstack-upgrade/migrations/v1.27.0.0.sh @@ -44,7 +44,8 @@ fi # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- -GSTACK_HOME="${HOME}/.gstack" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" SKILLS_DIR="${HOME}/.claude/skills" BIN_DIR="${SKILLS_DIR}/gstack/bin" CONFIG_BIN="${BIN_DIR}/gstack-config" diff --git a/gstack-upgrade/migrations/v1.37.0.0.sh b/gstack-upgrade/migrations/v1.37.0.0.sh index b60b8530c..c975e6bd5 100755 --- a/gstack-upgrade/migrations/v1.37.0.0.sh +++ b/gstack-upgrade/migrations/v1.37.0.0.sh @@ -38,7 +38,8 @@ if [ -z "${HOME:-}" ]; then exit 0 fi -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" MIGRATIONS_DIR="$GSTACK_HOME/.migrations" DONE_TOUCH="$MIGRATIONS_DIR/v1.37.0.0.done" CONFIG_BIN="$HOME/.claude/skills/gstack/bin/gstack-config" diff --git a/gstack-upgrade/migrations/v1.38.1.0.sh b/gstack-upgrade/migrations/v1.38.1.0.sh index 2a56634d7..1b23e9edd 100755 --- a/gstack-upgrade/migrations/v1.38.1.0.sh +++ b/gstack-upgrade/migrations/v1.38.1.0.sh @@ -15,7 +15,8 @@ # still run. `set -u` is fine. set -u -GSTACK_HOME="${HOME}/.gstack" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" ALLOWLIST="${GSTACK_HOME}/.brain-allowlist" PRIVACY="${GSTACK_HOME}/.brain-privacy-map.json" GITATTRS="${GSTACK_HOME}/.gitattributes" diff --git a/gstack-upgrade/migrations/v1.40.0.0.sh b/gstack-upgrade/migrations/v1.40.0.0.sh index e482d57a6..177705d7f 100755 --- a/gstack-upgrade/migrations/v1.40.0.0.sh +++ b/gstack-upgrade/migrations/v1.40.0.0.sh @@ -22,7 +22,8 @@ set -u -GSTACK_HOME="${HOME}/.gstack" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" ALLOWLIST="${GSTACK_HOME}/.brain-allowlist" PRIVACY="${GSTACK_HOME}/.brain-privacy-map.json" GITATTRS="${GSTACK_HOME}/.gitattributes" diff --git a/gstack-upgrade/migrations/v1.58.0.0.sh b/gstack-upgrade/migrations/v1.58.0.0.sh index da2252286..bd5adc24f 100755 --- a/gstack-upgrade/migrations/v1.58.0.0.sh +++ b/gstack-upgrade/migrations/v1.58.0.0.sh @@ -20,7 +20,8 @@ set -u -GSTACK_HOME="${HOME}/.gstack" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" MIGRATION_DIR="${GSTACK_HOME}/.migrations" DONE="${MIGRATION_DIR}/v1.58.0.0.done" mkdir -p "${MIGRATION_DIR}" 2>/dev/null || true diff --git a/gstack-upgrade/migrations/v1.65.0.0.sh b/gstack-upgrade/migrations/v1.65.0.0.sh index 56181d648..44e698a58 100755 --- a/gstack-upgrade/migrations/v1.65.0.0.sh +++ b/gstack-upgrade/migrations/v1.65.0.0.sh @@ -34,7 +34,8 @@ set -u -GSTACK_HOME="${GSTACK_HOME:-${HOME}/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" MIGRATION_DIR="${GSTACK_HOME}/.migrations" DONE="${MIGRATION_DIR}/v1.65.0.0.done" # Written when a removal happened but the verified end state (a Chromium diff --git a/gstack-upgrade/migrations/v1.78.0.0.sh b/gstack-upgrade/migrations/v1.78.0.0.sh index ee2ed3db3..28c230aaa 100755 --- a/gstack-upgrade/migrations/v1.78.0.0.sh +++ b/gstack-upgrade/migrations/v1.78.0.0.sh @@ -15,7 +15,8 @@ set -u INSTALL_DIR="${GSTACK_INSTALL_DIR:-$HOME/.claude/skills/gstack}" -GH="${GSTACK_HOME:-$HOME/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GH="$_gstack_sr_root" mkdir -p "$GH" 2>/dev/null || exit 0 diff --git a/ios-qa/daemon/src/allowlist.ts b/ios-qa/daemon/src/allowlist.ts index 6496b041e..a440615a7 100644 --- a/ios-qa/daemon/src/allowlist.ts +++ b/ios-qa/daemon/src/allowlist.ts @@ -7,13 +7,13 @@ import { readFile, writeFile, mkdir } from 'fs/promises'; import { join, dirname } from 'path'; -import { homedir } from 'os'; import type { Allowlist, AllowlistEntry, Capability } from './types'; import { capabilityCovers } from './types'; +import { resolveStateRoot } from '../../../lib/state-root'; export function defaultAllowlistPath(): string { return process.env.GSTACK_IOS_ALLOWLIST_PATH - ?? join(homedir(), '.gstack', 'ios-qa-allowlist.json'); + ?? join(resolveStateRoot(), 'ios-qa-allowlist.json'); } export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise { diff --git a/ios-qa/daemon/src/audit.ts b/ios-qa/daemon/src/audit.ts index 7b328ca39..c2743c3dc 100644 --- a/ios-qa/daemon/src/audit.ts +++ b/ios-qa/daemon/src/audit.ts @@ -3,28 +3,28 @@ import { mkdir, appendFile, stat, rename, readFile } from 'fs/promises'; import { join, dirname } from 'path'; -import { homedir } from 'os'; import { createHash } from 'crypto'; import type { AuditRow, AttemptRow } from './types'; +import { resolveStateRoot } from '../../../lib/state-root'; const MAX_BYTES = 10 * 1024 * 1024; const MAX_GENS = 5; export function defaultAuditPath(): string { return process.env.GSTACK_IOS_AUDIT_PATH - ?? join(homedir(), '.gstack', 'security', 'ios-qa-audit.jsonl'); + ?? join(resolveStateRoot(), 'security', 'ios-qa-audit.jsonl'); } export function defaultAttemptsPath(): string { return process.env.GSTACK_IOS_ATTEMPTS_PATH - ?? join(homedir(), '.gstack', 'security', 'attempts.jsonl'); + ?? join(resolveStateRoot(), 'security', 'attempts.jsonl'); } let _saltCache: string | null = null; async function loadDeviceSalt(): Promise { if (_saltCache) return _saltCache; - const path = join(homedir(), '.gstack', 'security', 'device-salt'); + const path = join(resolveStateRoot(), 'security', 'device-salt'); try { _saltCache = (await readFile(path, 'utf-8')).trim(); } catch { diff --git a/ios-qa/daemon/src/single-instance.ts b/ios-qa/daemon/src/single-instance.ts index 0196b0032..432563e8b 100644 --- a/ios-qa/daemon/src/single-instance.ts +++ b/ios-qa/daemon/src/single-instance.ts @@ -8,8 +8,8 @@ import { readFile, mkdir, unlink } from 'fs/promises'; import { existsSync, openSync, writeSync, closeSync, unlinkSync } from 'fs'; import { join, dirname } from 'path'; -import { homedir } from 'os'; import { spawn } from 'child_process'; +import { resolveStateRoot } from '../../../lib/state-root'; export interface PidfileContents { pid: number; @@ -19,7 +19,7 @@ export interface PidfileContents { export function defaultPidfilePath(): string { return process.env.GSTACK_IOS_DAEMON_PIDFILE - ?? join(homedir(), '.gstack', 'ios-qa-daemon.pid'); + ?? join(resolveStateRoot(), 'ios-qa-daemon.pid'); } /** diff --git a/ios-qa/scripts/gen-accessors.ts b/ios-qa/scripts/gen-accessors.ts index 315f2bde6..d97b02db4 100644 --- a/ios-qa/scripts/gen-accessors.ts +++ b/ios-qa/scripts/gen-accessors.ts @@ -22,9 +22,9 @@ import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'fs'; import { join, resolve, dirname } from 'path'; -import { homedir } from 'os'; import { createHash } from 'crypto'; import { execSync } from 'child_process'; +import { resolveStateRoot } from '../../lib/state-root'; export interface AccessorField { name: string; @@ -771,7 +771,7 @@ function detectBuildId(): string { } export function defaultCacheRoot(): string { - return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors'); + return process.env.GSTACK_IOS_CACHE_ROOT ?? join(resolveStateRoot(), 'cache', 'gen-accessors'); } export function generate(inputs: GenInputs): GenResult { diff --git a/lib/bin-context.ts b/lib/bin-context.ts index d3be44009..ccc3ef7f3 100644 --- a/lib/bin-context.ts +++ b/lib/bin-context.ts @@ -7,8 +7,8 @@ import { spawnSync } from "child_process"; import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs"; -import { homedir } from "os"; import { basename, dirname, join } from "path"; +import { resolveStateRoot } from "./state-root"; /** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */ function sanitizeSlug(s: string): string { @@ -148,7 +148,7 @@ export function outermostRemoteRepo(startDir: string): { root: string; url: stri * 4. Project root's basename; else basename(cwd) for plain non-project folders. */ export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string { - const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack"); + const home = gstackHome || resolveStateRoot(); const cacheDir = join(home, "slug-cache"); const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")); diff --git a/lib/code-intelligence/selection.ts b/lib/code-intelligence/selection.ts index b4fe58baf..c3534afb9 100644 --- a/lib/code-intelligence/selection.ts +++ b/lib/code-intelligence/selection.ts @@ -12,11 +12,11 @@ */ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; -import { homedir } from "os"; import { dirname, join, resolve } from "path"; import { execFileSync } from "child_process"; import { hasRepoPolicyStore, repoPolicyTier } from "../gbrain-repo-policy-client"; import type { CodeProviderId, OpClass } from "./contract"; +import { resolveStateRoot } from "../state-root"; export interface Selection { provider: CodeProviderId | null; @@ -31,7 +31,7 @@ export interface Selection { const EMPTY: Selection = { provider: null, consents: {}, roots: {}, declined: false }; function storePath(env: NodeJS.ProcessEnv = process.env): string { - const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack"); + const home = resolveStateRoot(env); return join(home, "code-intelligence.json"); } diff --git a/lib/cso/state.ts b/lib/cso/state.ts index 39e889a22..680ca51ef 100644 --- a/lib/cso/state.ts +++ b/lib/cso/state.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'; import { atomicWriteSync } from '../fs-atomic'; import { CsoError, RunReportV3, canonical, completeness, fingerprint, renderReport, sha256 } from './contracts'; import { redact, sanitizeForJson, sanitizeHelperForJson } from './process'; +import { resolveStateRoot } from '../state-root'; const MAX_STATE_FILE=1024*1024; type ExactStats=Pick & Pick; @@ -179,9 +180,8 @@ export function discardAtomicNoReplaceTemp(path:string,publisherPid:number,optio } export function stateRoot(env: Record = process.env): string { - // Mirrors bin/gstack-paths. Security artifacts are intentionally outside every sync allowlist. - const userHome=env.HOME||(process.platform==='win32'?env.USERPROFILE:''); - return resolve(env.GSTACK_HOME || (env.CLAUDE_PLUGIN_ROOT?.toLowerCase().includes('gstack') ? env.CLAUDE_PLUGIN_DATA : '') || join(userHome || '.', '.gstack')); + // The shared chain (lib/state-root.ts), made absolute. Security artifacts are intentionally outside every sync allowlist. + return resolve(resolveStateRoot(env)); } function ensureDirectory(path:string,hardenExistingLeaf:boolean):string{ const p=resolve(path),root=parse(p).root; diff --git a/lib/egress-receipt.ts b/lib/egress-receipt.ts index 01882ebcf..fd501eb60 100644 --- a/lib/egress-receipt.ts +++ b/lib/egress-receipt.ts @@ -26,7 +26,7 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; -import os from 'node:os'; +import { resolveStateRoot } from './state-root'; import path from 'node:path'; export const EGRESS_RECEIPT_FAILED = 'EGRESS_RECEIPT_FAILED'; @@ -109,14 +109,9 @@ export interface VerifyResult { sizeWarning: string | null; } -/** - * Same resolution order as the rest of gstack (shell sinks, selection code): - * GSTACK_HOME, legacy GSTACK_STATE_DIR, then $HOME/.gstack. - */ +/** The shared state root (lib/state-root.ts), made absolute. */ export function resolveEgressHome(env: Env = process.env): string { - const configured = env.GSTACK_HOME || env.GSTACK_STATE_DIR; - if (configured) return path.resolve(configured); - return path.join(env.HOME || os.homedir(), '.gstack'); + return path.resolve(resolveStateRoot(env)); } export function egressLedgerPath(home: string): string { diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index 033dc034f..ae6ebcd8c 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -49,6 +49,7 @@ import { atomicWriteSync } from "./fs-atomic"; import { homedir } from "os"; import { dirname, join } from "path"; import { buildGbrainEnv, gbrainConfigDir, isExecTimeout, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; +import { resolveStateRoot } from "./state-root"; export type LocalEngineStatus = | "ok" @@ -116,10 +117,7 @@ function userHome(env?: NodeJS.ProcessEnv): string { /** Cache path computed fresh on each call so tests can mutate GSTACK_HOME per case. */ export function cacheFilePath(): string { - return join( - process.env.GSTACK_HOME || join(userHome(), ".gstack"), - ".gbrain-local-status-cache.json", - ); + return join(resolveStateRoot(), ".gbrain-local-status-cache.json"); } /** diff --git a/lib/gstack-decision.ts b/lib/gstack-decision.ts index 87a0f35cb..db1774a78 100644 --- a/lib/gstack-decision.ts +++ b/lib/gstack-decision.ts @@ -14,12 +14,12 @@ */ import { join } from "path"; -import { homedir } from "os"; import { randomUUID } from "crypto"; import { existsSync, readFileSync, appendFileSync, statSync, openSync, closeSync, unlinkSync } from "fs"; import { atomicWriteSync } from "./fs-atomic"; import { appendJsonl, readJsonl, hasInjection } from "./jsonl-store"; import { scan } from "./redact-engine"; +import { resolveStateRoot } from "./state-root"; export type DecisionKind = "decide" | "supersede" | "redact"; export type DecisionScope = "repo" | "branch" | "issue"; @@ -57,7 +57,7 @@ export interface DecisionPaths { /** Resolve the per-project decision store paths. Bins pass slug + GSTACK_HOME. */ export function decisionPaths(slug: string, gstackHome?: string): DecisionPaths { - const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack"); + const home = gstackHome || resolveStateRoot(); const dir = join(home, "projects", slug || "unknown"); return { log: join(dir, "decisions.jsonl"), diff --git a/lib/gstack-memory-helpers.ts b/lib/gstack-memory-helpers.ts index f511abac8..2ea3a3334 100644 --- a/lib/gstack-memory-helpers.ts +++ b/lib/gstack-memory-helpers.ts @@ -24,7 +24,8 @@ import { appendJsonl } from "./jsonl-store"; import { gbrainConfigDir, isExecTimeout } from "./gbrain-exec"; import { dirname, join } from "path"; import { execFileSync } from "child_process"; -import { homedir, tmpdir } from "os"; +import { tmpdir } from "os"; +import { resolveStateRoot } from "./state-root"; // ── Types ────────────────────────────────────────────────────────────────── @@ -326,7 +327,7 @@ function redactMatch(s: string): string { const ENGINE_CACHE_TTL_MS = 60 * 1000; function gstackHome(): string { - return process.env.GSTACK_HOME || join(homedir(), ".gstack"); + return resolveStateRoot(); } function engineCachePath(): string { diff --git a/lib/redact-audit-log.ts b/lib/redact-audit-log.ts index 8ea03c5bf..510aba760 100644 --- a/lib/redact-audit-log.ts +++ b/lib/redact-audit-log.ts @@ -19,6 +19,7 @@ import * as os from "os"; import * as path from "path"; import { createHash } from "crypto"; import { appendJsonl } from "./jsonl-store"; +import { resolveStateRoot } from "./state-root"; export interface SemanticReviewEntry { ts: string; @@ -30,7 +31,7 @@ export interface SemanticReviewEntry { } function securityDir(): string { - const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack"); + const home = resolveStateRoot(); return path.join(home, "security"); } diff --git a/lib/state-root.ts b/lib/state-root.ts index 8ae563763..a0b178060 100644 --- a/lib/state-root.ts +++ b/lib/state-root.ts @@ -39,6 +39,13 @@ function legacyStateRoot(env: StateRootEnv, platform: NodeJS.Platform): string | return home ? `${home}/.gstack` : null; } +/** Roots merged privacy settings are read from: the resolved root, then $HOME/.gstack when different. */ +export function mergedStateRoots(env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): string[] { + const resolved = resolveStateRoot(env, platform); + const legacy = legacyStateRoot(env, platform); + return legacy && legacy !== resolved ? [resolved, legacy] : [resolved]; +} + /** Privacy and egress opt-outs: most restrictive first. Read from every candidate root. */ export const MERGED_CONFIG_KEYS: Record = { telemetry: ['off', 'anonymous', 'community'], @@ -75,11 +82,8 @@ export interface ConfigKeyReading { * resolved root only. */ export function readConfigKeyWithRoot(key: string, env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): ConfigKeyReading { - const resolved = resolveStateRoot(env, platform); const order = MERGED_CONFIG_KEYS[key]; - const candidates = [resolved]; - const legacy = order ? legacyStateRoot(env, platform) : null; - if (legacy && legacy !== resolved) candidates.push(legacy); + const candidates = order ? mergedStateRoots(env, platform) : [resolveStateRoot(env, platform)]; let best: ConfigKeyReading = { value: null, root: null }; let bestRank = Infinity; for (const root of candidates) { diff --git a/scripts/analytics.ts b/scripts/analytics.ts index 6aa93cb37..1e4697c3a 100644 --- a/scripts/analytics.ts +++ b/scripts/analytics.ts @@ -13,7 +13,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; +import { resolveStateRoot } from '../lib/state-root'; export interface AnalyticsEvent { skill: string; @@ -23,7 +23,7 @@ export interface AnalyticsEvent { pattern?: string; } -const ANALYTICS_FILE = path.join(os.homedir(), '.gstack', 'analytics', 'skill-usage.jsonl'); +const ANALYTICS_FILE = path.join(resolveStateRoot(), 'analytics', 'skill-usage.jsonl'); /** * Parse JSONL content into AnalyticsEvent[], skipping malformed lines. diff --git a/scripts/declared-annotation.ts b/scripts/declared-annotation.ts index fa45c585b..e856b080b 100644 --- a/scripts/declared-annotation.ts +++ b/scripts/declared-annotation.ts @@ -22,9 +22,9 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { SIGNAL_MAP, type Dimension, ALL_DIMENSIONS } from './psychographic-signals'; +import { resolveStateRoot } from '../lib/state-root'; const STRONG_HIGH = 0.7; const STRONG_LOW = 0.3; @@ -61,11 +61,7 @@ interface DeveloperProfile { } function stateRoot(): string { - return ( - process.env.GSTACK_STATE_ROOT || - process.env.GSTACK_HOME || - path.join(os.homedir(), '.gstack') - ); + return resolveStateRoot(); } function readProfile(): DeveloperProfile | null { diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index d19281ada..186b86062 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -27,6 +27,7 @@ import type { HostConfig } from './host-config'; const ROOT = path.resolve(import.meta.dir, '..'); import { ALL_MODEL_NAMES, resolveModel, type Model } from './models'; +import { resolveStateRoot } from '../lib/state-root'; type HostArg = Host | 'all'; @@ -75,7 +76,7 @@ export interface GenerationResult { /** Canonical generation never reads local detection state unless opted in. */ function loadGbrainOverride(respectDetection: boolean): boolean { if (!respectDetection) return false; - const stateDir = process.env.GSTACK_HOME || path.join(process.env.HOME || '', '.gstack'); + const stateDir = resolveStateRoot(); try { const json = JSON.parse(fs.readFileSync(path.join(stateDir, 'gbrain-detection.json'), 'utf-8')); // Slow, remote, and locked engines are still usable (#1964/#2051/#2456). @@ -1087,7 +1088,7 @@ export async function main(args = process.argv.slice(2)): Promise { } if (!settings.dryRun) { try { - const config = fs.readFileSync(path.join(process.env.HOME || '', '.gstack', 'config.yaml'), 'utf-8'); + const config = fs.readFileSync(path.join(resolveStateRoot(), 'config.yaml'), 'utf-8'); if (/^skill_prefix:\s*true/m.test(config)) { console.log('\nNote: skill_prefix is true. Run gstack-relink to re-apply name: patches (it patches both the install and any active gbrain render).'); } diff --git a/setup b/setup index 86ddc5242..53d2b1736 100755 --- a/setup +++ b/setup @@ -167,6 +167,9 @@ fi INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)" SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)" +# One state root for everything setup writes (bin/gstack-state-root.sh, docs/state-root.md). +. "$SOURCE_GSTACK_DIR/bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SOURCE_GSTACK_DIR/bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")" BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse" CODEX_SKILLS="${CODEX_HOME:-$HOME/.codex}/skills" @@ -308,7 +311,7 @@ _link_or_copy() { # marker) means we created the entry and may delete or refresh it whole; WEAK # (byte-identity with our source, or the two-line generated banner on a real # file) covers only that SKILL.md — never the directory — and a differing -# weakly-proven file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills// +# weakly-proven file is moved to $GSTACK_STATE_ROOT/backups/skills// # before we install over it. An entry is OURS # when: it is a symlink resolving into the gstack payload / render dir (or any # path with a `gstack` segment, the convention cleanup and gstack-uninstall @@ -332,7 +335,7 @@ _gstack_target_is_ours() { # $1 = absolute target path, $2 = gstack payload dir local t="$1" g="$2" g_real render render_real g_real="$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")" - render="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" + render="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}" render_real="$(cd "$render" 2>/dev/null && pwd -P || printf '%s' "$render")" case "$t" in "$g"/*|"$g_real"/*|"$render"/*|"$render_real"/*|gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;; @@ -363,7 +366,7 @@ _claude_entry_is_ours() { # A gbrain install serves the RENDERED file (link_claude_skill_dirs prefers # it), so an exact copy of that render is ours too. if [ -n "$src_md" ]; then - render_md="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md" + render_md="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md" [ -f "$render_md" ] && cmp -s "$entry/SKILL.md" "$render_md" && return 0 fi _gstack_generated_header "$entry/SKILL.md" && return 0 @@ -389,7 +392,7 @@ _claude_entry_owned_strongly() { } # Weakly-proven real files we would otherwise overwrite are moved here (mv, so # the path is free for the link); the final summary prints one line. -_SKILL_BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)" +_SKILL_BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)" _BACKED_UP_SKILL_MDS=() _backup_skill_md() { # Returns non-zero when the file could NOT be moved: the caller must then @@ -1620,7 +1623,7 @@ link_claude_skill_dirs() { # file's section-base paths point into the render dir, so section reads # resolve there too. _skill_md_src="$gstack_dir/$dir_name/SKILL.md" - _render_dir="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" + _render_dir="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}" if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then _skill_md_src="$_render_dir/$dir_name/SKILL.md" fi @@ -2868,7 +2871,7 @@ fi # Users who install gbrain after running ./setup should re-run setup OR # call `gstack-config gbrain-refresh`. DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect" -GBRAIN_STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}" +GBRAIN_STATE_DIR="$GSTACK_STATE_ROOT" DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json" _GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}" # PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't @@ -3290,8 +3293,10 @@ fi # posture). An explicit answer is persisted and never re-asked; an explicit # "false" is a recorded decline (adversarial review finding 11). # `gstack-config get` defaults absent keys to "false", which is -# indistinguishable from a decline — test key presence in the config file. -_GSTACK_CFG_FILE="${GSTACK_HOME:-$HOME/.gstack}/config.yaml" +# indistinguishable from a decline — test key presence in the config file +# of the resolved state root (the twin beside gstack-config). +. "$(dirname "$GSTACK_CONFIG")/gstack-state-root.sh" 2>/dev/null && gstack_state_root_select +_GSTACK_CFG_FILE="${_gstack_sr_root:-$GSTACK_STATE_ROOT}/config.yaml" if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then if [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then _REDACT_PROMPT_TIMEOUT=10 diff --git a/test/freeze-owned-lifecycle.test.ts b/test/freeze-owned-lifecycle.test.ts index 10a722341..e3a2cf6b3 100644 --- a/test/freeze-owned-lifecycle.test.ts +++ b/test/freeze-owned-lifecycle.test.ts @@ -50,7 +50,7 @@ beforeEach(() => { env = { ...process.env, HOME: join(root, 'home'), GSTACK_HOME: join(root, 'state'), CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' }; mkdirSync(env.GSTACK_HOME!); state = join(env.GSTACK_HOME!, 'freeze-dir.txt'); - for (const file of ['freeze/bin/check-freeze.sh', 'freeze/bin/freeze-state.sh', 'careful/bin/hook-extract.sh', 'bin/gstack-paths']) { + for (const file of ['freeze/bin/check-freeze.sh', 'freeze/bin/freeze-state.sh', 'careful/bin/hook-extract.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh']) { if (!existsSync(join(ROOT, file))) continue; const dest = join(env.HOME!, '.claude/skills/gstack', file); mkdirSync(dirname(dest), { recursive: true }); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 2c17418d4..1ed659b55 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1935,6 +1935,7 @@ describe('BENEFITS_FROM resolver', () => { fs.mkdirSync(cwd); fs.copyFileSync(path.join(ROOT, 'bin/gstack-slug'), helper); fs.chmodSync(helper, 0o755); + fs.copyFileSync(path.join(ROOT, 'bin/gstack-state-root.sh'), path.join(path.dirname(helper), 'gstack-state-root.sh')); const expected = path.join(home, '.gstack/projects/canonical-override/session-unknown-design-current.md'); const wrong = path.join(home, '.gstack/projects/project/session-unknown-design-wrong.md'); for (const file of [expected, wrong]) { diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index f2b133ab4..e6549c666 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -168,6 +168,7 @@ describe('gstack-skill-start behavior', () => { // Shadow the real bin dir by copying the script next to the poisoned tool. fs.copyFileSync(START, path.join(fakeBin, 'gstack-skill-start')); fs.chmodSync(path.join(fakeBin, 'gstack-skill-start'), 0o755); + fs.copyFileSync(path.join(path.dirname(START), 'gstack-state-root.sh'), path.join(fakeBin, 'gstack-state-root.sh')); const out = execFileSync(path.join(fakeBin, 'gstack-skill-start'), ['--skill', 't'], { timeout: 30_000, encoding: 'utf-8', diff --git a/test/helpers/health-eval-fixture.ts b/test/helpers/health-eval-fixture.ts index 5abafefec..bdadbf6a3 100644 --- a/test/helpers/health-eval-fixture.ts +++ b/test/helpers/health-eval-fixture.ts @@ -52,6 +52,7 @@ export function createHealthEvalFixture(dir: string, repoRoot: string): HealthEv fs.mkdirSync(path.join(dir, 'bin'), { recursive: true }); fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-slug'), path.join(dir, 'bin', 'gstack-slug')); fs.chmodSync(path.join(dir, 'bin', 'gstack-slug'), 0o755); + fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-state-root.sh'), path.join(dir, 'bin', 'gstack-state-root.sh')); // Only redirect installed paths. The workflow and score/history rules come // from the real generated skill, without its unrelated shared preamble. diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index 95cfcd213..df3b9575c 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -1082,11 +1082,11 @@ describe('check-freeze.sh state-root resolution (#1459 / #1509)', () => { // ============================================================ // gstack_hook_log_fire analytics sink follows the same state root (#1459) // ============================================================ -// The hook_fire record lands under ${GSTACK_HOME:-$HOME/.gstack}/analytics — -// the SAME two-step chain every other analytics writer and reader uses -// (gstack-skill-start, gstack-retro-metrics, gstack-analytics) — deliberately -// NOT the plugin-aware state root the freeze FILE uses, so the usage log stays -// one file. Logging is best-effort: an unwritable sink never changes the decision. +// The hook_fire record lands under the resolved state root (bin/gstack-state-root.sh) +// — the SAME root the freeze FILE and every other analytics writer and reader +// (gstack-skill-start, gstack-retro-metrics, gstack-analytics) use, so the usage +// log stays one file. Logging is best-effort: an unwritable sink never changes +// the decision. describe('gstack_hook_log_fire writes under the resolved state root', () => { const BOUNDARY = '/Users/dev/project/src/'; const OUTSIDE = '/Users/dev/other-project/index.ts'; @@ -1114,16 +1114,16 @@ describe('gstack_hook_log_fire writes under the resolved state root', () => { }); }); - test('plugin install: the freeze FILE is read from CLAUDE_PLUGIN_DATA but hook_fire still lands under $HOME/.gstack/analytics (one usage log)', () => { + test('plugin install: the freeze FILE and hook_fire both use CLAUDE_PLUGIN_DATA (one state root, one usage log)', () => { withFreezeDir(BOUNDARY, (pluginData) => { withEmptyDir((fakeHome) => { const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), freezeEnv(pluginData, { HOME: fakeHome, CLAUDE_PLUGIN_ROOT: '/Plugins/GSTACK' })); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); - const rec = lastRecord(path.join(fakeHome, '.gstack', 'analytics', 'skill-usage.jsonl')); + const rec = lastRecord(path.join(pluginData, 'analytics', 'skill-usage.jsonl')); expect(rec.event).toBe('hook_fire'); expect(rec.skill).toBe('freeze'); - expect(fs.existsSync(path.join(pluginData, 'analytics'))).toBe(false); + expect(fs.existsSync(path.join(fakeHome, '.gstack', 'analytics'))).toBe(false); }); }); }); diff --git a/test/ios-qa-regen.test.ts b/test/ios-qa-regen.test.ts index 2aa39884c..08bd4a822 100644 --- a/test/ios-qa-regen.test.ts +++ b/test/ios-qa-regen.test.ts @@ -48,6 +48,8 @@ function copyIntoFakeInstall(workDir: string): { root: string; launcher: string copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher); chmodSync(launcher, 0o755); copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts')); + mkdirSync(join(root, 'lib'), { recursive: true }); + copyFileSync(join(ROOT, 'lib', 'state-root.ts'), join(root, 'lib', 'state-root.ts')); for (const [template] of SAFE_TEMPLATE_MAP) { copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template)); } diff --git a/test/plan-tune-cathedral.test.ts b/test/plan-tune-cathedral.test.ts index 4ac4fd8fb..def4fcac4 100644 --- a/test/plan-tune-cathedral.test.ts +++ b/test/plan-tune-cathedral.test.ts @@ -43,6 +43,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string; 'gstack-codex-session-import', 'gstack-distill-free-text', 'gstack-distill-apply', + 'gstack-state-root.sh', ]) { const src = path.join(ROOT, 'bin', script); if (fs.existsSync(src)) { @@ -65,10 +66,10 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string; if (fs.existsSync(src)) fs.copyFileSync(src, path.join(scriptsDir, f)); } - // Both imports are resolved relative to the copied install, not the source checkout. + // These imports are resolved relative to the copied install, not the source checkout. const libDir = path.join(workDir, 'lib'); fs.mkdirSync(libDir, { recursive: true }); - for (const file of ['jsonl-store.ts', 'is-conductor.ts']) { + for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts']) { fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file)); } diff --git a/test/relink.test.ts b/test/relink.test.ts index 65424f9f1..45b9e6233 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -56,6 +56,8 @@ function setupMockInstall(skills: string[]): void { fs.mkdirSync(mockBin, { recursive: true }); fs.copyFileSync(path.join(BIN, 'gstack-config'), path.join(mockBin, 'gstack-config')); fs.chmodSync(path.join(mockBin, 'gstack-config'), 0o755); + // A real install always ships the state-root twin beside the bins that source it. + fs.copyFileSync(path.join(BIN, 'gstack-state-root.sh'), path.join(mockBin, 'gstack-state-root.sh')); if (fs.existsSync(path.join(BIN, 'gstack-relink'))) { fs.copyFileSync(path.join(BIN, 'gstack-relink'), path.join(mockBin, 'gstack-relink')); fs.chmodSync(path.join(mockBin, 'gstack-relink'), 0o755); diff --git a/test/setup-claude-code-migration.test.ts b/test/setup-claude-code-migration.test.ts index 91b084e82..dcc14d6df 100644 --- a/test/setup-claude-code-migration.test.ts +++ b/test/setup-claude-code-migration.test.ts @@ -30,7 +30,7 @@ function fixture(copy: boolean) { put(path.join(root, 'VERSION'), '2.0.0.0\n'); put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n'); put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } })); - for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code']) { + for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh']) { const target = path.join(root, 'bin', bin); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(path.join(ROOT, 'bin', bin), target); diff --git a/test/skill-e2e-bws.test.ts b/test/skill-e2e-bws.test.ts index f1fc5d782..bf9015845 100644 --- a/test/skill-e2e-bws.test.ts +++ b/test/skill-e2e-bws.test.ts @@ -219,7 +219,7 @@ Report the exact output — either "READY: " or "NEEDS_SETUP".`, // identically on main). const binDir = path.join(opDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); - for (const script of ['gstack-learnings-log', 'gstack-slug']) { + for (const script of ['gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh']) { fs.copyFileSync(path.join(ROOT, 'bin', script), path.join(binDir, script)); fs.chmodSync(path.join(binDir, script), 0o755); } diff --git a/test/skill-e2e-context-skills.test.ts b/test/skill-e2e-context-skills.test.ts index 32335ffe1..69a680393 100644 --- a/test/skill-e2e-context-skills.test.ts +++ b/test/skill-e2e-context-skills.test.ts @@ -59,7 +59,7 @@ function setupWorkdir(suffix: string): { workDir: string; gstackHome: string; sl const binDir = path.join(workDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); for (const script of [ - 'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', + 'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh', 'gstack-learnings-log', 'gstack-learnings-search', 'gstack-update-check', 'gstack-config', 'gstack-repo-mode', ]) { diff --git a/test/skill-e2e-learnings.test.ts b/test/skill-e2e-learnings.test.ts index 8672ef3c2..78a933fd8 100644 --- a/test/skill-e2e-learnings.test.ts +++ b/test/skill-e2e-learnings.test.ts @@ -40,7 +40,7 @@ describeIfSelected('Learnings E2E', ['learnings-show'], () => { // Copy bin scripts needed by /learn const binDir = path.join(workDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); - for (const script of ['gstack-learnings-search', 'gstack-learnings-log', 'gstack-slug']) { + for (const script of ['gstack-learnings-search', 'gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh']) { fs.copyFileSync(path.join(ROOT, 'bin', script), path.join(binDir, script)); fs.chmodSync(path.join(binDir, script), 0o755); } diff --git a/test/skill-e2e-plan-tune.test.ts b/test/skill-e2e-plan-tune.test.ts index 17a7a2a0f..7bb7530be 100644 --- a/test/skill-e2e-plan-tune.test.ts +++ b/test/skill-e2e-plan-tune.test.ts @@ -50,6 +50,7 @@ describeIfSelected('PlanTune E2E', ['plan-tune-inspect'], () => { for (const script of [ 'gstack-slug', 'gstack-config', + 'gstack-state-root.sh', 'gstack-question-log', 'gstack-question-preference', 'gstack-developer-profile', diff --git a/test/skill-e2e-session-intelligence.test.ts b/test/skill-e2e-session-intelligence.test.ts index 90fdbe149..de28e2d12 100644 --- a/test/skill-e2e-session-intelligence.test.ts +++ b/test/skill-e2e-session-intelligence.test.ts @@ -44,7 +44,7 @@ describeIfSelected('Session Intelligence E2E', [ const binDir = path.join(workDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); for (const script of [ - 'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', + 'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh', 'gstack-learnings-log', 'gstack-learnings-search', ]) { const src = path.join(ROOT, 'bin', script); diff --git a/test/skill-e2e-skillify.test.ts b/test/skill-e2e-skillify.test.ts index 175bb03fa..1fd2bec51 100644 --- a/test/skill-e2e-skillify.test.ts +++ b/test/skill-e2e-skillify.test.ts @@ -96,7 +96,7 @@ function setupSkillifyWorkdir(suffix: string, installSkills: string[] = ['scrape const binDir = path.join(workDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); for (const script of [ - 'gstack-timeline-log', 'gstack-slug', 'gstack-config', + 'gstack-timeline-log', 'gstack-slug', 'gstack-config', 'gstack-state-root.sh', 'gstack-update-check', 'gstack-repo-mode', 'gstack-learnings-log', 'gstack-learnings-search', ]) { diff --git a/test/state-root-one-variable.test.ts b/test/state-root-one-variable.test.ts new file mode 100644 index 000000000..4de1b4815 --- /dev/null +++ b/test/state-root-one-variable.test.ts @@ -0,0 +1,68 @@ +/** + * One variable moves every store (W1). With only GSTACK_HOME, only + * GSTACK_STATE_ROOT or only GSTACK_STATE_DIR pointing at a temp dir, the + * config, egress ledger, trust-policy store, telemetry and analytics log, hook + * error log, and the root uninstall reports all resolve under that directory, + * and nothing lands in $HOME/.gstack. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { resolveStateRoot } from '../lib/state-root'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin'); + +for (const variable of ['GSTACK_HOME', 'GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR'] as const) { + describe(`only ${variable} set`, () => { + test('every store resolves under it', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-one-var-')); + try { + const root = path.join(tmp, 'root'); + const home = path.join(tmp, 'home'); + const cwd = path.join(tmp, 'cwd'); + for (const d of [root, home, cwd]) fs.mkdirSync(d, { recursive: true }); + const env: Record = { + ...(process.env as Record), + HOME: home, GSTACK_HOME: '', GSTACK_STATE_ROOT: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '', + GSTACK_TEST_LEGACY_ROOT: '', [variable]: root, + }; + expect(resolveStateRoot(env)).toBe(root); + const run = (cmd: string, args: string[], input?: string) => + spawnSync(cmd, args, { cwd, env, input, encoding: 'utf-8', timeout: 30_000 }); + + // config + expect(run('bash', [path.join(BIN, 'gstack-config'), 'set', 'telemetry', 'anonymous']).status).toBe(0); + expect(fs.readFileSync(path.join(root, 'config.yaml'), 'utf-8')).toContain('telemetry: anonymous'); + // telemetry + analytics log + expect(run('bash', [path.join(BIN, 'gstack-telemetry-log'), '--skill', 'qa', '--duration', '1', '--outcome', 'success', '--session-id', 'one-var-1', '--no-sweep']).status).toBe(0); + expect(fs.readFileSync(path.join(root, 'analytics', 'skill-usage.jsonl'), 'utf-8')).toContain('"skill":"qa"'); + // egress ledger + const receipt = run('bun', [path.join(BIN, 'gstack-egress-receipt'), 'write', '--sink', 'test', '--host', 'example.invalid', '--class', 'test', '--no-payload', '--consent', 'test']); + expect(receipt.status).toBe(0); + expect(fs.existsSync(path.join(root, 'security', 'egress.jsonl'))).toBe(true); + // trust-policy store + expect(run('bash', [path.join(BIN, 'gstack-gbrain-repo-policy'), 'set', 'https://github.com/a/b', 'deny']).status).toBe(0); + expect(fs.existsSync(path.join(root, 'gbrain-repo-policy.json'))).toBe(true); + // hook error log + const hook = run('bash', [path.join(ROOT, 'hosts', 'claude', 'hooks', 'question-log-hook')], '{not json'); + expect(hook.status).toBe(0); + expect(fs.readFileSync(path.join(root, 'hook-errors.log'), 'utf-8')).toContain('question-log-hook'); + // uninstall target: a non-default root is reported, never deleted + const skillsGstack = path.join(home, '.claude', 'skills', 'gstack'); + fs.mkdirSync(path.dirname(skillsGstack), { recursive: true }); + const un = run('bash', [path.join(BIN, 'gstack-uninstall'), '--force']); + expect(un.status).toBe(0); + expect(un.stderr).toContain(`left in place: ${root} (selected by ${variable}=${root})`); + expect(un.stderr).toContain(`fix: after checking it, remove it with rm -rf -- '${root}'`); + expect(fs.existsSync(path.join(root, 'config.yaml'))).toBe(true); + + expect(fs.existsSync(path.join(home, '.gstack'))).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + }); +} diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index 2419c60e0..982cd0b11 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -351,7 +351,7 @@ describe('setup --team / --no-team / -q', () => { fs.writeFileSync(file, content, { mode: 0o755 }); }; try { - for (const rel of ['setup', 'VERSION', 'SKILL.md', 'qa/SKILL.md', 'bin/gstack-config', 'bin/gstack-patch-names', 'scripts/resolve-codex-generation-model.ts', 'scripts/models.ts', 'scripts/preflight-codex-overlap.ts', 'scripts/discover-skills.ts', 'scripts/external-skill-names.ts', 'scripts/host-config.ts']) { + for (const rel of ['setup', 'VERSION', 'SKILL.md', 'qa/SKILL.md', 'bin/gstack-config', 'bin/gstack-patch-names', 'bin/gstack-state-root.sh', 'scripts/resolve-codex-generation-model.ts', 'scripts/models.ts', 'scripts/preflight-codex-overlap.ts', 'scripts/discover-skills.ts', 'scripts/external-skill-names.ts', 'scripts/host-config.ts']) { const dest = path.join(cwd, rel); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(path.join(ROOT, rel), dest); diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index 82316d5eb..83d5e3926 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -258,7 +258,7 @@ describe('hook cleanup runs before the install root is deleted', () => { fs.mkdirSync(installBin, { recursive: true }); // The installed copies — the uninstaller under test IS the one inside // the root it deletes. - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const src = path.join(ROOT, 'bin', b); const dst = path.join(installBin, b); fs.copyFileSync(src, dst); @@ -317,7 +317,7 @@ describe('the Memorable bridge hook is removed by name and the kept config is le const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -360,7 +360,7 @@ describe('the Memorable arm stays quiet when nothing of its is registered', () = const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -392,7 +392,7 @@ describe('hook cleanup under lock contention is loud, never silent (review-army) const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -449,7 +449,7 @@ describe('the consent key never outlives the hook, even when the config lives ou const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); fs.mkdirSync(otherRoot, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -480,7 +480,7 @@ describe('the consent flip does not depend on the hook manager being present', ( const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-config']) { // no settings hook, no session-update + for (const b of ['gstack-uninstall', 'gstack-config', 'gstack-state-root.sh']) { // no settings hook, no session-update const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -499,3 +499,65 @@ describe('the consent flip does not depend on the hook manager being present', ( } }); }); + +describe('state-root safety: only the default root is deleted (docs/state-root.md)', () => { + let tmp: string, mockHome: string, installBin: string, installRoot: string; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-root-')); + mockHome = path.join(tmp, 'home'); + installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); + installBin = path.join(installRoot, 'bin'); + fs.mkdirSync(installBin, { recursive: true }); + for (const b of ['gstack-uninstall', 'gstack-config', 'gstack-state-root.sh']) { + fs.copyFileSync(path.join(ROOT, 'bin', b), path.join(installBin, b)); + fs.chmodSync(path.join(installBin, b), 0o755); + } + }); + afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + function uninstall(args: string[], env: Record, input?: string) { + return spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), ...args], { + cwd: tmp, input, encoding: 'utf-8', timeout: 30_000, + env: { ...process.env, HOME: mockHome, GSTACK_SETTINGS_FILE: path.join(mockHome, 'settings.json'), + GSTACK_HOME: '', GSTACK_STATE_ROOT: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '', ...env } as Record, + }); + } + + for (const mode of ['--force', 'interactive'] as const) { + test(`a custom GSTACK_HOME root is left in place with the removal command (${mode}); $HOME/.gstack goes`, () => { + const custom = path.join(tmp, 'custom-state'); + fs.mkdirSync(path.join(custom, 'projects'), { recursive: true }); + fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true }); + const r = mode === '--force' ? uninstall(['--force'], { GSTACK_HOME: custom }) : uninstall([], { GSTACK_HOME: custom }, 'y\n'); + expect(r.status).toBe(0); + expect(r.stderr).toContain(`left in place: ${custom} (selected by GSTACK_HOME=${custom})`); + expect(r.stderr).toContain(`fix: after checking it, remove it with rm -rf -- '${custom}'`); + expect(r.stderr).toContain('docs/state-root.md'); + expect(fs.existsSync(path.join(custom, 'projects'))).toBe(true); + expect(fs.existsSync(path.join(mockHome, '.gstack'))).toBe(false); + if (mode === 'interactive') expect(r.stdout).toContain(`(kept) ${custom}`); + }); + } + + for (const [label, target] of [['$HOME', () => mockHome], ['/', () => '/'], ['the gstack checkout', () => installRoot]] as const) { + test(`$HOME/.gstack symlinked to ${label} is refused with exit 2 and a fix line`, () => { + fs.symlinkSync(target(), path.join(mockHome, '.gstack')); + const r = uninstall(['--force'], {}); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`gstack-uninstall: refusing to delete ${path.join(mockHome, '.gstack')}: `); + expect(r.stderr).toMatch(/^fix: .*docs\/state-root\.md/m); + expect(fs.existsSync(path.join(installBin, 'gstack-uninstall'))).toBe(true); + expect(fs.lstatSync(path.join(mockHome, '.gstack')).isSymbolicLink()).toBe(true); + }); + } + + test('--keep-state deletes nothing and never refuses', () => { + const custom = path.join(tmp, 'custom-state'); + fs.mkdirSync(custom, { recursive: true }); + fs.symlinkSync(mockHome, path.join(mockHome, '.gstack')); + const r = uninstall(['--force', '--keep-state'], { GSTACK_HOME: custom }); + expect(r.status).toBe(0); + expect(r.stderr).not.toContain('left in place:'); + expect(fs.existsSync(custom)).toBe(true); + expect(fs.lstatSync(path.join(mockHome, '.gstack')).isSymbolicLink()).toBe(true); + }); +}); diff --git a/test/user-render-out-dir-install.test.ts b/test/user-render-out-dir-install.test.ts index e982f9cd6..07dd014e7 100644 --- a/test/user-render-out-dir-install.test.ts +++ b/test/user-render-out-dir-install.test.ts @@ -173,6 +173,7 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => { 'SKILL_PREFIX=0', '_WINDOWS_COPY_NOTE_PRINTED=1', `GSTACK_HOME="${home}"`, + `GSTACK_STATE_ROOT="${home}"`, extractFn(SETUP_SRC, '_link_or_copy'), extractFn(SETUP_SRC, '_print_windows_copy_note_once'), extractFn(SETUP_SRC, '_link_skill_runtime_assets'),