#!/usr/bin/env bash # gstack-settings-hook — manage Claude Code hooks in ~/.claude/settings.json # # Three shapes: # # 1. Legacy (SessionStart only — kept so old installs still clean up): # gstack-settings-hook add # adds SessionStart hook # gstack-settings-hook remove # removes gstack-session-update items # # (the arg is accepted for # # interface compat; matching is by # # the gstack-session-update basename) # # 2. Schema-aware (plan-tune cathedral T3): # gstack-settings-hook add-event --event \ # --command --source [--matcher ] [--timeout ] # gstack-settings-hook ensure-event --event ... --command ... --source ... [--matcher ...] [--timeout ] # gstack-settings-hook remove-source --source # gstack-settings-hook diff-event --event ... --command ... --source ... [--matcher ...] # gstack-settings-hook rollback # restore latest backup (single-step undo) # gstack-settings-hook list-sources # show all gstack-tagged hook entries # # 3. Self-heal (phantom-hooks fix): # gstack-settings-hook prune-stale # prune dead gstack hook items # gstack-settings-hook prune-stale --repoint # re-point gstack items at , prune still-dead # gstack-settings-hook prune-stale --all # remove ALL gstack hook items (uninstall sweep) # # ensure-event is the update-in-place verb: same flags as add-event, but keyed # on (event, source) — any entry carrying our source tag for the event is THE # registration to compare/update, so a matcher change re-points in place # instead of pushing a second entry, and duplicate same-source twins from the # old matcher-keyed dedup collapse to one (reported on stderr). Identical # payload → no write, no backup ("unchanged"); a re-run of ./setup stays a # true no-op. This heals a stale absolute hook path (e.g. a deleted dev # worktree) baked into settings.json by an earlier setup. # # Ownership model (KNOWN_HOOKS identity table): a hook ITEM is gstack-owned iff # its command basename + relpath suffix + event (+ matcher where the table row # defines one) match a table row. Entry-level `_gstack_source` tags are # best-effort metadata — Claude Code strips unknown keys when it rewrites # settings.json, so identity is intrinsic (the table), never tag-only. A tag # NEVER claims foreign items: in a tagged multi-item entry, unrecognized items # are always preserved; only a tagged SINGLE-item entry with no table match is # treated as an owned legacy stray. # # Mutation safety: # - every mutation runs under a mkdir lock (.lock/) with an owner # token; release is ownership-checked; stale locks (>30s) are taken over # via atomic rename. On lock give-up the mutation is SKIPPED with a warning # (the next setup retries — the system is convergent). # - parse failure fails CLOSED: a corrupt settings.json is never overwritten # (only ENOENT starts fresh). Exit 3. # - backup-on-change: a backup (unique name, .bak-latest pointer) is written # only when the file content actually changes. No-op mutations are silent # on disk. `rollback` is a single-step undo of the last real mutation. # - writes are atomic: unique tmp file + rename (a fixed tmp name would let # two concurrent writers rename a half-written file into place). set -euo pipefail ACTION="${1:-}" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" if [ -z "$ACTION" ]; then cat <&2 Usage: gstack-settings-hook add # legacy SessionStart add gstack-settings-hook remove # legacy SessionStart remove gstack-settings-hook add-event --event --command --source [--matcher ] [--timeout ] gstack-settings-hook ensure-event --event --command --source [--matcher ] [--timeout ] gstack-settings-hook remove-source --source gstack-settings-hook diff-event --event --command --source [--matcher ] [--timeout ] gstack-settings-hook prune-stale [--repoint ] [--all] gstack-settings-hook rollback gstack-settings-hook list-sources EOF exit 1 fi if ! command -v bun >/dev/null 2>&1; then echo "Error: bun is required but not installed." >&2 exit 1 fi # ─── Shared JS prelude ──────────────────────────────────────────────── # Single source of truth for the KNOWN_HOOKS identity table and the # ownership/liveness/IO helpers, interpolated into EVERY bun -e script as # bun -e "$_HOOK_JS_PRELUDE"' ' # so the dedupe key and the prune predicate cannot drift. The prelude MUST NOT # contain single quotes (this assignment is single-quoted). _HOOK_JS_PRELUDE=' // Umbrella fail-closed guard: bun in -e mode swallows uncaught exceptions // thrown after a require() call and exits 0 (verified on bun 1.3.13; // uncaughtException handlers never fire in -e mode either). Every script body // below runs inside gsMain so a runtime throw becomes a LOUD exit 4 instead // of a silent success that reports a mutation as clean. function gsMain(fn) { try { fn(); } catch (e) { process.stderr.write("gstack-settings-hook: internal error (" + (e && e.message) + ") -- refusing to mutate\n"); process.exit(4); } } var KNOWN_HOOKS = { "question-log-hook": { source: "plan-tune-cathedral", event: "PostToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/question-log-hook" }, "question-preference-hook": { source: "plan-tune-cathedral", event: "PreToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/question-preference-hook" }, "auq-error-fallback-hook": { source: "auq-error-fallback", event: "PostToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/auq-error-fallback-hook" }, "timeline-stop-hook": { source: "gstack-timeline-stop", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/timeline-stop-hook" }, "gstack-session-update": { source: "gstack-session-update", event: "SessionStart", matcher: "", relpath: "bin/gstack-session-update" }, "gstack-verify-gate": { source: "verify-gate", event: "Stop", matcher: "", relpath: "bin/gstack-verify-gate" } }; function gsHadBashPrefix(c) { return String(c == null ? "" : c).trim().indexOf("bash ") === 0; } function gsStripWrap(c) { var s = String(c == null ? "" : c).trim(); if (s.indexOf("bash ") === 0) s = s.slice(5).trim(); if (s.length >= 2 && s.charAt(0) === "\"" && s.charAt(s.length - 1) === "\"") { // Unescape the gsQuoteCmd form so a re-pointed escaped command is still // recognized as ours on later passes (identity round-trips). s = s.slice(1, -1).replace(/\\([\\"$\x60])/g, "$1"); } // Separator normalization is Windows-only (a rare-but-legal Unix path // containing a backslash must not be rewritten and mis-stat-ed). if (process.platform === "win32" || /^[A-Za-z]:[\\\/]/.test(s)) { s = s.replace(/\\/g, "/"); } return s; } function gsBaseOf(c) { var p = gsStripWrap(c); return p.split("/").pop(); } function gsOwnedRow(cmd, event, matcher) { var p = gsStripWrap(cmd); var b = p.split("/").pop(); // hasOwnProperty guard: a foreign hook basename like "toString" or // "constructor" must not resolve to an inherited Object.prototype member. var row = Object.prototype.hasOwnProperty.call(KNOWN_HOOKS, b) ? KNOWN_HOOKS[b] : null; if (!row) return null; if (p !== row.relpath && p.slice(-(row.relpath.length + 1)) !== "/" + row.relpath) return null; if (row.event !== event) return null; if (row.matcher && (matcher || "") !== row.matcher) return null; return row; } function gsWinPath(p) { // Git Bash writes MSYS-form paths (/c/Users/...) into settings.json, but // native bun resolves them drive-relative (C:\c\Users\...) -- translate for // fs calls only; stored commands keep the form the firing shell expects. if (process.platform === "win32" && /^\/[A-Za-z]\//.test(p)) { return p.charAt(1) + ":" + p.slice(2); } return p; } function gsIsAlive(cmd) { var fs = require("fs"); var p = gsWinPath(gsStripWrap(cmd)); if (!p) return false; try { if (process.platform === "win32") return fs.existsSync(p); var st = fs.statSync(p); if (!st.isFile()) return false; fs.accessSync(p, fs.constants.X_OK); return true; } catch (e) { // Only provable absence counts as dead. EACCES/EIO/unmounted-volume // errors are transient unreachability -- pruning on those would be a // one-way ratchet, so conservatively treat the item as alive. var code = e && e.code; return !(code === "ENOENT" || code === "ENOTDIR"); } } function gsQuoteCmd(target, hadBash) { // Shell-metacharacter hardening: the command string is executed by a shell // when Claude Code fires the hook, so a path containing $, backtick (x60 -- // written as an escape so the prelude itself stays backtick-free), or a // quote must be neutralized, not just space-wrapped. var needsQuote = /[\s$\x60"\\]/.test(target); var quoted = needsQuote ? "\"" + target.replace(/[\\"$\x60]/g, function (ch) { return "\\" + ch; }) + "\"" : target; return (hadBash ? "bash " : "") + quoted; } function gsRotateBackups(settingsPath, keep) { // Backup files are change-gated but unbounded across months of setups -- // keep the most recent N so ~/.claude does not accumulate forever. var fs = require("fs"); var path = require("path"); try { var dir = path.dirname(settingsPath); var base = path.basename(settingsPath) + ".bak."; var baks = fs.readdirSync(dir) .filter(function (f) { return f.indexOf(base) === 0; }) .map(function (f) { var full = path.join(dir, f); var m = 0; try { m = fs.statSync(full).mtimeMs; } catch (e3) {} return { full: full, m: m }; }) .sort(function (a, b) { return a.m - b.m; }); for (var i = 0; i < baks.length - keep; i++) { try { fs.unlinkSync(baks[i].full); } catch (e2) {} } } catch (e) {} } function gsLoadSettings(path) { var fs = require("fs"); var raw = null; try { raw = fs.readFileSync(path, "utf8"); } catch (e) { if (e && e.code === "ENOENT") return { settings: {}, existed: false }; process.stderr.write("gstack-settings-hook: cannot read " + path + ": " + e.message + " -- refusing to mutate\n"); process.exit(3); } try { return { settings: JSON.parse(raw), existed: true }; } catch (e) { process.stderr.write("gstack-settings-hook: " + path + " is not valid JSON (" + e.message + ") -- refusing to mutate; fix or restore it (.bak files / rollback)\n"); process.exit(3); } } function gsWriteIfChanged(path, beforeText, settings, existed) { var fs = require("fs"); var afterText = JSON.stringify(settings, null, 2); if (afterText === beforeText) return false; // Preserve the live file mode across the tmp+rename (settings.json can // carry API keys in its env block -- a user-tightened 0600 must never be // silently broadened to the default 0644). Fresh files start 0600. var mode = 0o600; if (existed) { try { mode = fs.statSync(path).mode & 0o777; } catch (e) {} fs.copyFileSync(path, process.env.GSTACK_BACKUP_PATH); fs.writeFileSync(process.env.GSTACK_BAK_LATEST, process.env.GSTACK_BACKUP_PATH + "\n"); gsRotateBackups(path, 10); } var tmp = process.env.GSTACK_TMP_PATH; fs.writeFileSync(tmp, afterText + "\n"); try { fs.chmodSync(tmp, mode); } catch (e) {} fs.renameSync(tmp, path); return true; } ' # ─── Mutation lock ──────────────────────────────────────────────────── # Accepted tradeoffs (adversarial-reviewed): (1) the lock serializes gstack # writers only -- Claude Code rewrites settings.json without honoring it, so a # lost update against a live session remains possible (convergent: the next # heal repairs); (2) stale takeover is mtime-based -- a holder legitimately # slower than the stale window can be stolen from, and a fresh crash stalls # callers for the give-up window. PID-aware takeover was considered and # deferred (owner file already carries $$ if it becomes worth it). _LOCK_DIR="$SETTINGS_FILE.lock" _LOCK_TOKEN="" _release_lock() { if [ -n "$_LOCK_TOKEN" ] && [ -d "$_LOCK_DIR" ]; then # Ownership-checked: never remove a lock another process re-acquired # after a stale takeover. _OWNER_CONTENT="$(cat "$_LOCK_DIR/owner" 2>/dev/null || true)" if [ "$_OWNER_CONTENT" = "$_LOCK_TOKEN" ] || [ -z "$_OWNER_CONTENT" ]; then rm -rf "$_LOCK_DIR" 2>/dev/null || true fi fi _LOCK_TOKEN="" } _acquire_lock() { # GSTACK_SETTINGS_LOCK_TIMEOUT_MS: test-only override for the give-up # (a contention test should not stall the suite for 10 real seconds). local waited_ms=0 token stale mtime now local give_up_ms="${GSTACK_SETTINGS_LOCK_TIMEOUT_MS:-10000}" local stale_after_s=30 # lock older than this belongs to a crashed holder local poll_ms=50 # retry cadence; sleep below derives from this token="$$-$RANDOM$RANDOM" while :; do if mkdir "$_LOCK_DIR" 2>/dev/null; then printf '%s\n' "$token" > "$_LOCK_DIR/owner" 2>/dev/null || true _LOCK_TOKEN="$token" trap _release_lock EXIT trap 'exit 129' INT trap 'exit 143' TERM return 0 fi # mkdir failed but no lock dir exists: NOT contention (unwritable parent, # read-only fs, missing directory) -- waiting cannot help, so give up # loudly now instead of spinning out the full timeout. (Tiny race: a # contender could acquire+release between our mkdir and this check; that # transient reads as an environment failure and the next run converges.) if [ ! -e "$_LOCK_DIR" ]; then echo "gstack-settings-hook: cannot create lock $_LOCK_DIR (unwritable parent?) -- skipping this mutation, exit 5" >&2 return 1 fi # Stale takeover: atomic rename means exactly one contender wins; the # loser loops and re-contends against the winner's fresh mkdir. # GNU stat (-c %Y) first: on Linux, BSD-style `stat -f %m` prints a # multi-line FILESYSTEM block to stdout before failing, and the || chain # would capture that garbage alongside the real epoch. BSD stat rejects # -c with no stdout, so macOS falls through cleanly. The numeric guard # below makes any residual garbage inert (no takeover, normal give-up) # instead of an arithmetic abort under set -e. mtime=$(stat -c %Y "$_LOCK_DIR" 2>/dev/null || stat -f %m "$_LOCK_DIR" 2>/dev/null || echo "") case "$mtime" in *[!0-9]*) mtime="" ;; esac now=$(date +%s) if [ -n "$mtime" ] && [ $(( now - mtime )) -gt "$stale_after_s" ]; then stale="$_LOCK_DIR.stale.$$-$RANDOM" if mv "$_LOCK_DIR" "$stale" 2>/dev/null; then rm -rf "$stale" 2>/dev/null || true; fi continue fi if [ "$waited_ms" -ge "$give_up_ms" ]; then echo "gstack-settings-hook: could not acquire lock $_LOCK_DIR -- skipping this mutation, exit 5 (the next setup retries it)" >&2 return 1 fi sleep "$(printf '0.%03d' "$poll_ms")" waited_ms=$(( waited_ms + poll_ms )) done } # Per-invocation unique backup + tmp paths, exported for gsWriteIfChanged. _mutation_env() { GSTACK_BACKUP_PATH="$SETTINGS_FILE.bak.$(date +%Y%m%d-%H%M%S).$$.$RANDOM" GSTACK_BAK_LATEST="$SETTINGS_FILE.bak-latest" GSTACK_TMP_PATH="$SETTINGS_FILE.tmp.$$.$RANDOM" export GSTACK_BACKUP_PATH GSTACK_BAK_LATEST GSTACK_TMP_PATH } case "$ACTION" in # --- legacy SessionStart add/remove (backwards compat) ----------------- add) HOOK_CMD="${2:-}" if [ -z "$HOOK_CMD" ]; then echo "Usage: gstack-settings-hook add " >&2 exit 1 fi _acquire_lock || exit 5 _mutation_env GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const hookCmd = process.env.GSTACK_HOOK_CMD; const loaded = gsLoadSettings(settingsPath); const settings = loaded.settings; const before = JSON.stringify(settings, null, 2); if (!settings.hooks) settings.hooks = {}; if (!settings.hooks.SessionStart) settings.hooks.SessionStart = []; const exists = settings.hooks.SessionStart.some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes("gstack-session-update")) ); if (!exists) { settings.hooks.SessionStart.push({ hooks: [{ type: "command", command: hookCmd }] }); } gsWriteIfChanged(settingsPath, before, settings, loaded.existed); }); ' ;; remove) HOOK_CMD="${2:-}" if [ -z "$HOOK_CMD" ]; then echo "Usage: gstack-settings-hook remove " >&2 exit 1 fi [ -f "$SETTINGS_FILE" ] || exit 1 _acquire_lock || exit 5 _mutation_env GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const loaded = gsLoadSettings(settingsPath); const settings = loaded.settings; const before = JSON.stringify(settings, null, 2); if (settings.hooks && settings.hooks.SessionStart) { // Item-aware: remove only matching hook items; foreign items in the // same entry survive; an entry is dropped ONLY when this pass emptied // it. Malformed/foreign entries (hooks absent, non-array, or already // empty) are preserved verbatim -- they are not ours to judge. settings.hooks.SessionStart = settings.hooks.SessionStart .filter(entry => { if (!Array.isArray(entry.hooks)) return true; const beforeLen = entry.hooks.length; entry.hooks = entry.hooks.filter(h => !(h && h.command && h.command.includes("gstack-session-update")) ); if (entry.hooks.length === 0 && beforeLen > 0) return false; return true; }); if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart; if (Object.keys(settings.hooks).length === 0) delete settings.hooks; } gsWriteIfChanged(settingsPath, before, settings, loaded.existed); }); ' ;; add-event|diff-event|ensure-event) EVENT="" COMMAND="" SOURCE="" MATCHER="" TIMEOUT="" shift while [ $# -gt 0 ]; do case "$1" in --event) EVENT="$2"; shift 2 ;; --command) COMMAND="$2"; shift 2 ;; --source) SOURCE="$2"; shift 2 ;; --matcher) MATCHER="$2"; shift 2 ;; --timeout) TIMEOUT="$2"; shift 2 ;; *) echo "unknown flag: $1" >&2; exit 1 ;; esac done if [ -z "$EVENT" ] || [ -z "$COMMAND" ] || [ -z "$SOURCE" ]; then echo "add-event/ensure-event/diff-event require --event, --command, --source" >&2 exit 1 fi case "$EVENT" in SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification) ;; *) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;; esac DIFF_ONLY="" ENSURE="" if [ "$ACTION" = "diff-event" ]; then DIFF_ONLY=1 else [ "$ACTION" = "ensure-event" ] && ENSURE=1 _acquire_lock || exit 5 fi _mutation_env GSTACK_SETTINGS_PATH="$SETTINGS_FILE" \ GSTACK_EVENT="$EVENT" \ GSTACK_COMMAND="$COMMAND" \ GSTACK_SOURCE="$SOURCE" \ GSTACK_MATCHER="$MATCHER" \ GSTACK_TIMEOUT="$TIMEOUT" \ GSTACK_DIFF_ONLY="$DIFF_ONLY" \ GSTACK_ENSURE="$ENSURE" \ bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const event = process.env.GSTACK_EVENT; const cmd = process.env.GSTACK_COMMAND; const source = process.env.GSTACK_SOURCE; const matcher = process.env.GSTACK_MATCHER || ""; const timeoutRaw = process.env.GSTACK_TIMEOUT || ""; const diffOnly = process.env.GSTACK_DIFF_ONLY === "1"; const ensure = process.env.GSTACK_ENSURE === "1"; const loaded = gsLoadSettings(settingsPath); const settings = loaded.settings; const before = JSON.stringify(settings, null, 2); if (!settings.hooks) settings.hooks = {}; if (!settings.hooks[event]) settings.hooks[event] = []; // add-event is the single quoting authority: normalize the command // through the same round-trip the healer uses so metachar paths are // registered in the escaped-quoted form from the start (a caller-side // quoting step would drift per call site). const cmdNorm = gsQuoteCmd(gsStripWrap(cmd), gsHadBashPrefix(cmd)); const hookEntry = { type: "command", command: cmdNorm }; if (timeoutRaw) { const n = Number(timeoutRaw); if (Number.isFinite(n) && n > 0) hookEntry.timeout = n; } // Identity is item-level and two-layer: // - a same-event entry carrying OUR source tag is ours even under a // DIFFERENT matcher (a matcher change must re-point in place, never // push a second registration -- the old matcher-keyed dedupe // duplicated the entry on every matcher change), and // - a same-matcher entry containing our exact command or a table-owned // item with our basename is OUR registration under a stale path // (Claude Code strips _gstack_source on its own rewrites, so // tag-based dedupe degrades). // A tag never claims foreign items: in a multi-item entry only the // identified item is touched; entries where no item can be identified // as ours are left alone entirely. const ourItemIdx = (entry) => { if (!Array.isArray(entry.hooks)) return -1; let idx = entry.hooks.findIndex(h => h && h.command === cmdNorm); if (idx < 0) { idx = entry.hooks.findIndex(h => { if (!h || !h.command) return false; const row = gsOwnedRow(h.command, event, entry.matcher || ""); return !!row && gsBaseOf(h.command) === gsBaseOf(cmdNorm); }); } if (idx < 0 && entry._gstack_source === source && entry.hooks.length === 1 && entry.hooks[0] && entry.hooks[0].command) idx = 0; return idx; }; const cands = []; for (const entry of settings.hooks[event]) { const idx = ourItemIdx(entry); if (idx < 0) continue; if (entry._gstack_source === source || (entry.matcher || "") === matcher) cands.push({ entry: entry, idx: idx }); } let placed = false; let collapsed = 0; if (cands.length > 0) { const primary = cands[0]; if ((primary.entry.matcher || "") === matcher) { primary.entry.hooks[primary.idx] = hookEntry; // Mixed-version ratchet guard: tag ONLY single-item entries (the // item we just placed). Old gstack versions in sibling worktrees do // entry-level ownership (remove-source deletes the whole tagged // entry; add-event clobbers entry.hooks wholesale) -- a tag on a // mixed entry hands them permission to destroy the user items in it. if (primary.entry.hooks.length === 1) primary.entry._gstack_source = source; else delete primary.entry._gstack_source; placed = true; } else if (primary.entry.hooks.length === 1) { // Tagged single-item entry under a stale matcher: the entry is // exclusively ours, so re-point payload AND matcher in place. primary.entry.hooks = [hookEntry]; primary.entry._gstack_source = source; if (matcher) primary.entry.matcher = matcher; else delete primary.entry.matcher; placed = true; } else { // Our item sits in a MIXED entry under a different matcher: the // entry-level matcher also governs the foreign siblings, so pull // our item out and register separately below. primary.entry.hooks.splice(primary.idx, 1); delete primary.entry._gstack_source; } // Duplicate registrations (e.g. same-source twins from the old // matcher-keyed dedupe): remove OUR item from every other candidate. for (let i = 1; i < cands.length; i++) { cands[i].entry.hooks.splice(cands[i].idx, 1); delete cands[i].entry._gstack_source; collapsed++; } // Drop only entries WE emptied; started-empty foreign entries are // never candidates, so they are preserved verbatim. settings.hooks[event] = settings.hooks[event].filter(e => !(Array.isArray(e.hooks) && e.hooks.length === 0 && cands.some(c => c.entry === e))); } if (!placed) { const newEntry = { _gstack_source: source, hooks: [hookEntry] }; if (matcher) newEntry.matcher = matcher; settings.hooks[event].push(newEntry); } const after = JSON.stringify(settings, null, 2); if (diffOnly) { console.log("--- BEFORE"); console.log(before); console.log("--- AFTER"); console.log(after); process.exit(0); } if (ensure && before === after) { // Registered payload already matches the canonical one -- no write, // no backup, no churn. Re-running ./setup stays a true no-op. console.log("OK: " + event + " hook unchanged (source: " + source + ")"); process.exit(0); } gsWriteIfChanged(settingsPath, before, settings, loaded.existed); if (collapsed > 0) { console.error("collapsed " + collapsed + " duplicate (event, source) hook entr" + (collapsed === 1 ? "y" : "ies") + " for " + event + " (source: " + source + ")"); } if (ensure && cands.length > 0) { console.log("OK: " + event + " hook re-pointed (source: " + source + ")"); } else { console.log("OK: " + event + " hook registered (source: " + source + ")"); } }); ' ;; remove-source) SOURCE="" shift while [ $# -gt 0 ]; do case "$1" in --source) SOURCE="$2"; shift 2 ;; *) echo "unknown flag: $1" >&2; exit 1 ;; esac done if [ -z "$SOURCE" ]; then echo "remove-source requires --source " >&2 exit 1 fi [ -f "$SETTINGS_FILE" ] || exit 0 _acquire_lock || exit 5 _mutation_env GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const source = process.env.GSTACK_SOURCE; const loaded = gsLoadSettings(settingsPath); const settings = loaded.settings; if (!settings.hooks) { console.log("OK: removed 0 hook entry/entries tagged source=" + source); process.exit(0); } const before = JSON.stringify(settings, null, 2); let removed = 0; for (const event of Object.keys(settings.hooks)) { const kept = []; for (const entry of settings.hooks[event]) { if (entry._gstack_source !== source) { kept.push(entry); continue; } if (!Array.isArray(entry.hooks) || entry.hooks.length === 0) { removed++; continue; } // Item-aware: remove table-owned items (or the single item of a // tagged legacy-stray entry); foreign items in a tagged multi-item // entry are preserved and the tag is dropped with the last owned item. const single = entry.hooks.length === 1; const remain = entry.hooks.filter(h => { // Command-less items cannot be ours (gstack only writes // type:command items) -- preserve them. const owned = (h && h.command) ? gsOwnedRow(h.command, event, entry.matcher || "") !== null : false; // The single-item stray claim requires a command item (gstack // never writes command-less items). if (owned || (single && h && h.command)) { removed++; return false; } return true; }); if (remain.length === 0) continue; entry.hooks = remain; delete entry._gstack_source; kept.push(entry); } settings.hooks[event] = kept; if (settings.hooks[event].length === 0) delete settings.hooks[event]; } if (Object.keys(settings.hooks).length === 0) delete settings.hooks; gsWriteIfChanged(settingsPath, before, settings, loaded.existed); console.log("OK: removed " + removed + " hook entry/entries tagged source=" + source); }); ' ;; prune-stale) REPOINT_ROOT="" PRUNE_ALL="" shift while [ $# -gt 0 ]; do case "$1" in --repoint) REPOINT_ROOT="$2"; shift 2 ;; --all) PRUNE_ALL=1; shift ;; *) echo "unknown flag: $1" >&2; exit 1 ;; esac done if [ -n "$REPOINT_ROOT" ] && [ -n "$PRUNE_ALL" ]; then echo "prune-stale: --repoint and --all are mutually exclusive" >&2 exit 1 fi if [ ! -f "$SETTINGS_FILE" ]; then echo "OK: removed 0 gstack hook entries (repointed 0)" exit 0 fi # Explicit plan_tune_hooks opt-out: dead plan-tune items are still pruned, # but live ones are never re-pointed (re-activation needs consent; removal # of live ones is --no-team/uninstall territory). GSTACK_PT_OPTOUT=0 # The opt-out lookup is repoint/heal-only — the --all sweep never # re-points, and uninstall must not depend on a sibling gstack-config. if [ -z "$PRUNE_ALL" ]; then _CFG_BIN="$(cd "$(dirname "$0")" && pwd)/gstack-config" if [ -x "$_CFG_BIN" ] && "$_CFG_BIN" has plan_tune_hooks 2>/dev/null; then _PT_VAL=$("$_CFG_BIN" get plan_tune_hooks 2>/dev/null || true) _PT_VAL=$(printf '%s' "$_PT_VAL" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') case "$_PT_VAL" in n|no|false|skip|off|0) GSTACK_PT_OPTOUT=1 ;; esac fi fi _acquire_lock || exit 5 _mutation_env GSTACK_SETTINGS_PATH="$SETTINGS_FILE" \ GSTACK_REPOINT_ROOT="$REPOINT_ROOT" \ GSTACK_PRUNE_ALL="$PRUNE_ALL" \ GSTACK_PT_OPTOUT="$GSTACK_PT_OPTOUT" \ GSTACK_SWEEP_EXCLUDE_SOURCES="${GSTACK_SWEEP_EXCLUDE_SOURCES:-}" \ bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const root = (process.env.GSTACK_REPOINT_ROOT || "").replace(/\/+$/, ""); const all = process.env.GSTACK_PRUNE_ALL === "1"; const ptOptout = process.env.GSTACK_PT_OPTOUT === "1"; const PT_SOURCES = { "plan-tune-cathedral": true, "auq-error-fallback": true }; // Sources a sweep must leave alone (e.g. `setup --no-team` excludes the // user-registered verify-gate hook: turning team mode off must not // delete an unrelated opt-in whose binary still exists). const sweepExclude = {}; (process.env.GSTACK_SWEEP_EXCLUDE_SOURCES || "").split(",").forEach(function (sName) { if (sName.trim()) sweepExclude[sName.trim()] = true; }); const loaded = gsLoadSettings(settingsPath); const settings = loaded.settings; const before = JSON.stringify(settings, null, 2); let removed = 0; let repointed = 0; if (settings.hooks) { for (const event of Object.keys(settings.hooks)) { const rebuilt = []; for (const entry of settings.hooks[event]) { if (!Array.isArray(entry.hooks)) { rebuilt.push(entry); continue; } const matcher = entry.matcher || ""; const wasSingle = entry.hooks.length === 1; const remain = []; const seenInEntry = new Set(); if (entry.hooks.length === 0) { // Started-empty entries are foreign data we never touched -- // preserve them (only a gstack-tagged empty entry is claimable, // and only by the --all sweep). if (all && entry._gstack_source && !sweepExclude[entry._gstack_source]) { removed++; continue; } rebuilt.push(entry); continue; } for (const h of entry.hooks) { const cmdRaw = h && h.command; const row = cmdRaw ? gsOwnedRow(cmdRaw, event, matcher) : null; // A tagged SINGLE-item entry with no table match is an owned // legacy stray (command items only -- gstack never writes // command-less items); tags never claim items in multi-item entries. const stray = !row && !!cmdRaw && !!entry._gstack_source && wasSingle; if (!row && !stray) { remain.push(h); continue; } // foreign: never touched if (all) { if ((row && sweepExclude[row.source]) || (!row && entry._gstack_source && sweepExclude[entry._gstack_source])) { remain.push(h); continue; } removed++; continue; } if (row && root && !(ptOptout && PT_SOURCES[row.source])) { const target = root + "/" + row.relpath; if (gsIsAlive(target)) { const newCmd = gsQuoteCmd(target, gsHadBashPrefix(cmdRaw)); if (h.command !== newCmd) { h.command = newCmd; repointed++; } // Within-entry twin collapse: two dead copies of the same // hook re-point to the same canonical command -- keeping // both would fire the hook twice per event, forever. if (seenInEntry.has(newCmd)) { removed++; continue; } seenInEntry.add(newCmd); if (wasSingle) entry._gstack_source = row.source; // tag restore remain.push(h); continue; } } // No re-point target (or plan-tune opt-out): keep live, prune dead. if (gsIsAlive(cmdRaw)) remain.push(h); else { removed++; } } if (remain.length === 0) continue; // entry emptied → dropped entry.hooks = remain; // Tag hygiene: a tag must never sit on an entry containing foreign // items (old gstack versions in sibling worktrees treat tags as // entry-level ownership and would destroy the user items). Drop // the tag from any mixed entry; single-item strays keep theirs. if (entry._gstack_source && entry.hooks.length > 0 && !entry.hooks.every(h => h && h.command && gsOwnedRow(h.command, event, matcher)) && !(entry.hooks.length === 1 && wasSingle)) { delete entry._gstack_source; } rebuilt.push(entry); } // Collapse exact duplicates among FULLY-owned entries (same matcher, // same item commands). Prefer the tagged twin so stripped tags heal. const seen = new Map(); const out = []; for (const entry of rebuilt) { const items = Array.isArray(entry.hooks) ? entry.hooks : []; const fullyOwned = items.length > 0 && items.every(h => h && h.command && gsOwnedRow(h.command, event, entry.matcher || "")); if (!fullyOwned) { out.push(entry); continue; } const key = JSON.stringify([entry.matcher || ""].concat(items.map(function (h) { return h.command; }).sort())); const at = seen.get(key); if (at === undefined) { seen.set(key, out.length); out.push(entry); } else { if (!out[at]._gstack_source && entry._gstack_source) out[at] = entry; removed++; } } settings.hooks[event] = out; if (out.length === 0) delete settings.hooks[event]; } if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks; } gsWriteIfChanged(settingsPath, before, settings, loaded.existed); console.log("OK: removed " + removed + " gstack hook entries (repointed " + repointed + ")"); }); ' ;; rollback) if [ ! -f "$SETTINGS_FILE.bak-latest" ]; then echo "rollback: no backup pointer at $SETTINGS_FILE.bak-latest" >&2 exit 1 fi LATEST=$(cat "$SETTINGS_FILE.bak-latest") LATEST=$(printf '%s' "$LATEST" | tr -d '\n') # Defense in depth: only ever restore a sibling settings.json.bak.* file # (a corrupted/hostile pointer must not install an arbitrary file as the # live settings.json). case "$LATEST" in "$SETTINGS_FILE".bak.*) ;; *) echo "rollback: pointer target $LATEST is not a $SETTINGS_FILE.bak.* file -- refusing" >&2 exit 1 ;; esac case "${LATEST#"$SETTINGS_FILE".bak.}" in */*) echo "rollback: pointer suffix contains a path separator -- refusing" >&2 exit 1 ;; esac if [ ! -f "$LATEST" ]; then echo "rollback: pointer references missing backup $LATEST" >&2 exit 1 fi _acquire_lock || exit 1 _RB_TMP="$SETTINGS_FILE.tmp.$$.$RANDOM" cp "$LATEST" "$_RB_TMP" mv "$_RB_TMP" "$SETTINGS_FILE" echo "OK: restored $SETTINGS_FILE from $LATEST" ;; list-sources) [ -f "$SETTINGS_FILE" ] || { echo "(no settings file)"; exit 0; } GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const fs = require("fs"); let settings = {}; try { settings = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); } catch (e) { // Read-only surface: report loudly (setup guards read this output and // must not mistake corrupt-file for no-hooks) but exit 0. process.stderr.write("gstack-settings-hook: settings.json unparseable (" + e.message + ") -- fix or rollback\n"); process.exit(0); } const hooks = settings.hooks || {}; let any = false; for (const event of Object.keys(hooks)) { for (const entry of hooks[event]) { if (entry._gstack_source) { any = true; console.log(event + "\t" + entry._gstack_source + "\t" + (entry.matcher || "(no matcher)")); } } } if (!any) console.log("(no gstack-tagged hooks)"); }); ' ;; *) echo "Unknown action: $ACTION" >&2 exit 1 ;; esac