Files
gstack/bin/gstack-settings-hook
T
ad8400543c v1.69.0.0 fix: the silent-failure wave — 6 fixes, 5 community PRs absorbed, tracker closed with receipts (#2666)
* test(wireup): make gbrain-missing PATH fixture hermetic

The gbrain-missing test appended the host PATH (and a hardcoded /opt/homebrew/bin) to the fixture PATH, so on any machine with a real gbrain installed the 'missing' case saw it, exited 0 instead of 2, and could never fail where the bug exists — a false green for a whole machine class. The fixture now keeps only root-owned OS dirs on the child PATH, and a new determinism check plants a host-like gbrain to prove it is unreachable.

Absorbed from PR #2615 with authorship preserved; the PR-thread liveness screenshot (docs/images/gstack-pr-liveness-2255.png) is dropped — referenced by nothing in the tree.

Fixes #2255

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* fix(evidence): stop bun's dotenv autoload from reaching the spawned command

`bin/gstack-evidence` has a `#!/usr/bin/env bun` shebang, and bun AUTO-LOADS
`.env`, `.env.<NODE_ENV>` and `.env.local` from the cwd into `process.env`. The
wrapper then spawned the command with no `env` override, so every command run
through it inherited those variables — and a repo `.env.local` routinely holds
production credentials.

Two things go wrong, and the second is worse than the leak:

1. Secrets reach a child that would not otherwise have them. `npm test` run by
   hand in the same shell sees none of them; the same command through the wrapper
   sees all of them.
2. THE COMMAND UNDER TEST BEHAVES DIFFERENTLY, so the ledger certifies a run that
   is not the run CI performs. Observed in a Next.js repo on 2026-08-20: four
   tests failed 4/4 through the wrapper and passed 5/5 without it, because app
   code branched on env vars only the wrapper supplied. Nearly an hour went into
   chasing a "flake" that was the measuring instrument. The wrapper exists to
   record trustworthy evidence, so silently altering the environment defeats its
   purpose.

The fix builds the child env from `process.env` minus the keys bun injected, and
detection is exact rather than heuristic: verified on bun 1.3.11, a dotenv file
does NOT override a variable the shell already exported (the shell's value wins).
So a key whose live value equals the dotenv file's value was injected by bun, and
dropping it restores the environment the user's own shell would have given the
command. A key whose live value differs is genuinely the caller's and survives.

`BUN_DOTENV_FILES()` mirrors bun's precedence, including that `.env.local` is
skipped when NODE_ENV is "test" — scrubbing a key bun never loaded would strip a
variable the caller legitimately provided.

Escape hatch: GSTACK_EVIDENCE_KEEP_DOTENV=1 keeps the old behaviour. When keys are
scrubbed the wrapper warns with the KEY NAMES ONLY, so the diagnostic cannot
become the leak it prevents.

Tests: 6 cases, mutation-verified — removing `env: spawnEnv` reddens exactly the
two leak tests and restoring it gives 30/30. Every leak test asserts the scrub
warning fired, because `bun test` sets NODE_ENV=test and the first version of
these tests passed vacuously against a `.env.local` bun had never loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Absorbed from PR #2652 with authorship preserved. Wave additions: a doc-comment on the ${VAR}-expansion limitation (bun expands refs, the reader compares raw text — those keys are left in the child env, failing open) and a regression pin for the unreadable-.env fail-open path with a functional DAC-override skip guard.

Fixes #2624

* fix(setup): reap dangling skill dirs when the payload is gone

cleanup_old_claude_symlinks derived its work list from the payload directory, so when the payload was gone — precisely when orphans exist — the glob matched nothing and the loop never ran; the -f guard also followed symlinks, hiding dangling SKILL.md links even with a payload present. The cleanup now scans the DESTINATION skills dir (-e/-L, so dangling symlinks are visible) and anchors SKILL.md provenance to path segments (gstack/*, */gstack/*, */.gstack/render/claude/*) instead of a bare *gstack* substring that would eat a user skill under ~/tools/gstack-fork/. The Windows real-file arm stays payload-gated: a real file has no provable owner.

Absorbed from PR #2634 (2 commits squashed) with authorship preserved. The symmetric cleanup_prefixed_claude_symlinks hole is filed as a TODOS.md residual in this wave.

Fixes #2204

* fix(redact): tolerate EEXIST from recursive mkdir in install-prepush-hook on bun/Windows (#2635)

fs.mkdirSync(dir, { recursive: true }) is a no-op on an existing directory
in Node, but bun on Windows throws EEXIST - crashing hook install on any
repo whose .git/hooks already existed, leaving the repo unprotected.

Add lib/fs-utils.ts mkdirpSync: swallow EEXIST only when statSync confirms
the path is an existing directory; a regular file occupying the path, a
stat failure, or any other errno still rethrows. Use it in
installPrepushHook().

The regression test emulates the Windows bun fs semantics via a
bun --preload fixture, so the exact crash path runs (and fails on the old
code) on any platform, including CI Linux.

Absorbed from PR #2641 with authorship preserved.

Fixes #2635

* fix(bin): route remaining Windows-reachable mkdirSync sites through mkdirpSync

Sweep follow-up to #2641's lib/fs-utils.ts helper: bun on Windows throws EEXIST from a recursive mkdir on an existing dir, so every unguarded recursive mkdirSync on a Windows-reachable path is a latent crash. Converted: bin/gstack-decision-log (unguarded, runs on every decision log — the second call on any machine hits the pre-existing projects dir), bin/gstack-evidence logsDir + ledger dir sites, and bin/gstack-redact-prepush's skip-log site (already try-wrapped, so its failure mode was a silent skip-log loss rather than a crash — the fix makes the log survive). The ~15 remaining gbrain/mac-lane sites are deliberately left alone.

Regression: fs-utils.test.ts drives gstack-decision-log twice, the second run under the bun-Windows EEXIST preload fixture — the pre-sweep code exits 1 with EEXIST there; verified red against v1.68.3.0.

* fix(setup-gbrain): warn about the ZeroEntropy sunset before Sept 4

ZeroEntropy was acquired by Notion and sunsets its hosted API on September 4, 2026. A gbrain configured with the zeroentropyai embedding recipe keeps importing pages after that date but embedding silently fails — pages land structurally with no semantic search, this repo's tracker P1 (TODOS.md NEXT PRIORITY). Nothing in gstack ever recommended ZeroEntropy (the dependency is gbrain-internal), so the gstack side is detection + advisory: the wireup helper warns when ~/.gbrain/config.json names the recipe (fail-open grep — a missing, unreadable, or other-provider config stays silent and never blocks a working setup), the setup-gbrain provider-default comments say never to select the legacy recipe for a new brain, and USING_GBRAIN_WITH_GSTACK.md gains a troubleshooting entry. The gbrain-side provider migration stays open upstream.

Refs #2365

* fix(gbrain-source-wireup): first sync targets the registered source, not --repo

The wireup registered a federated source by id, then ran 'gbrain sync --repo $WORKTREE' — which resolves against the brain's DEFAULT source and (on gbrain 0.46.x) rewrites that source's local_path anchor to our worktree. Net effect: the user's primary knowledge source silently repointed at the gstack brain worktree while the just-registered source got zero pages, and pages_synced still reported success. The sync now targets the registered id ('gbrain sync --source $id', the same form the repo's own troubleshooting documents). Because the script's stated floor is gbrain >= 0.18.0 and nothing proves --source exists there, support is probed via 'gbrain sync --help' first: an older gbrain keeps the wrong-but-working --repo call with an upgrade warning instead of converting it into a hard failure. The probe sits after the GSTACK_BRAIN_NO_SYNC early-exit and is unreachable in --probe mode.

Regression tests (fail on v1.68.3.0): a no-skip sync case asserting the call log shows 'sync --source gstack-brain-<id>' and never 'sync --repo', and an old-gbrain fallback case (fake sync --help without --source) asserting --repo plus the upgrade warning.

Fixes #2662

* fix(setup): --host slate exits informatively instead of silently installing nothing

slate passed --host validation (added to the accept-list in v1.64.1.0) but never got a dispatch arm, and the all-INSTALL_*-zero fallback lives inside the auto branch — so './setup --host slate' configured nothing and exited 0, a silent no-op strictly worse than the original hard rejection. slate is now an informational arm (per docs/designs/SLATE_HOST.md it is blocked on the host-config refactor; Slate reads .claude/skills as a compatibility fallback, so the arm points at './setup --host claude'), and a defensive guard after the dispatch chain errors loudly (naming the host, the missing arm, and the valid targets, exit 1) if a future host is ever accepted without being wired.

Regression tests (fail on v1.68.3.0): a dispatch-arm ratchet asserting every accept-listed install target has a matching dispatch branch — the exact drift class; a registry cross-check deriving both sides from hosts/index.ts and setup's case arms; a behavioral slate probe (exit 0, points at --host claude, never reaches the installer — on unfixed code it fell through into the installer); and a static pin on the guard's shape.

Fixes #2361

* fix(make-pdf): resolve the sibling browse binary from execPath, not argv[0]

In a bun-compiled binary process.argv[0] is the raw invocation string — often relative ('./pdf', 'pdf') — so dirname(argv[0]) yielded '.' and the sibling candidates (../browse/dist/browse etc.) resolved against the CWD instead of the install dir. Resolution was cwd-dependent: correct-by-luck when the fallbacks rescued it, wrong when a cwd-relative path matched. process.execPath is always the absolute binary path. The resolution step takes an injectable selfPath (defaulted) because under bun test the process path is the bun runtime and the compiled-binary shapes are otherwise unreachable.

The issue's other half — pdf setup failing on newtab('about:blank') — was already fixed on main in v1.64.0.0 (browse/src/url-validation.ts exact-match allows about:blank; its comment names this exact smoke). This commit closes what remains.

Regression tests (the sibling-via-selfPath case fails on v1.68.3.0 — pre-fix code ignores the seam and either resolves the global install or throws): sibling resolution from an install-shaped tree, and a decoy-browse-DIRECTORY case pinning that a directory never wins resolution.

Fixes #2156

* fix(memory-ingest): store the normalized git_remote so unattributed pages hit the policy filter

buildTranscriptPage wrote the normalized '_unattributed' sentinel into the page FRONTMATTER but stored the raw resolved remote ('' when unresolvable) on the page object. The policy filter fast-paths !p.git_remote, so under --include-unattributed an explicit '_unattributed → deny' (or read-only) policy never applied to exactly the pages it names — they ingested unpoliced. The stored value now matches the frontmatter.

Regression test (fails on v1.68.3.0): seeds the REAL bin/gstack-gbrain-repo-policy store with '_unattributed → deny' through its own set verb, ingests an unresolvable-remote session with --include-unattributed, and asserts nothing reaches gbrain — pre-fix the '' remote bypassed the filter and the import ran. A fake echoing tiers would pass on both sides of the fix; the real helper prints 'none' for unknown keys, so only a genuinely applied deny distinguishes the two.

Fixes #2353

* fix(land-and-deploy): MERGED recovery reconciles and reports remote-branch cleanup

Step 4's merge commands carry --delete-branch, and the success path tells the user 'The branch has been cleaned up.' When gh exits non-zero AFTER GitHub already merged (routine in worktree layouts: gh's local cleanup runs git checkout <base> and fails), the §4a-postfail MERGED recovery re-established everything EXCEPT the branch deletion — and said nothing about it, so the discrepancy was invisible. The MERGED path now reconciles: git ls-remote --heads distinguishes branch-already-gone (exit 0, empty → 'already cleaned up', idempotent on re-runs) from branch-survived (offer confirm-first deletion, matching the section's worktree posture; -d not -D for any local branch) from check-itself-failed (non-zero exit → 'couldn't verify', skip the offer — never read a failed check as a clean branch).

Template + regenerated SKILL.md + test extensions land in one commit (the md-sync assertion goes red otherwise). Regression assertions (fail on v1.68.3.0: no delete-branch reconciliation existed in test/ at all) pin the ls-remote check, the confirm-first delete, and the absent-vs-failed distinction.

Fixes #2656

* fix(scripts): stop heredoc bodies deadlocking under Homebrew bash

`./setup --help` can hang forever on macOS, printing nothing, with no way
to tell it apart from a slow install. Eleven scripts carry the same
latent hang, `setup` itself being the one every user hits first.

bash 5.2+ delivers a heredoc body of 64KiB or less through a pipe: the
forked child writes the entire body before exec, and nothing reads the
other end until the command starts. Under macOS pipe-KVA pressure the
kernel hands a fresh pipe a 512-byte buffer instead of the usual 16-64KiB,
so any body of 512 bytes or more blocks write() permanently. The capacity
check bash would need to notice (F_GETPIPE_SZ) is Linux-only, so it never
fires here. It is pressure-dependent, which is why it reads as "worked on
my machine" — the same script runs fine all day and then wedges.

Homebrew bash is what `#!/usr/bin/env bash` resolves to on a Mac with brew
on PATH, which is most of them. Apple's /bin/bash 3.2 predates the pipe
path and is unaffected, so the bug is invisible to anyone testing with the
system shell.

The fix is `BASH_COMPAT=50` in each affected script, which restores the
pre-5.2 tempfile path:

    $ bash -c 'probe() { [ -p /dev/stdin ] && echo PIPE || echo TEMPFILE; }
               probe <<EOF
    $(printf "x%.0s" $(seq 1 1000))
    EOF'
    PIPE
    $ BASH_COMPAT=50 bash -c '...same...'
    TEMPFILE

- Not a `#!/bin/bash` shebang swap: that pins the script to whatever bash
  lives at /bin (3.2 on macOS, absent on some Linux distributions) and is
  bypassed entirely by `bash script.sh` call sites. The variable survives
  both.
- Not exported, so child processes keep their own compat level.
- Placed below any `--help` sed range that reads $0, so usage output is
  unchanged (verified on all eleven).
- Every guarded script is bash-3.2-clean — no associative arrays, case
  conversion, or mapfile — so compat level 50 costs them nothing.

test/heredoc-pipe-deadlock.test.ts scans every tracked shell script for a
heredoc body in the 512B-64KiB window and fails without the guard, and
proves the mechanism at runtime on bash 5.2+ by asserting the body moves
from PIPE to TEMPFILE. On older bash the runtime half is skipped, since
the pipe path does not exist there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Absorbed from PR #2640 with authorship preserved. Wave adaptations: the pipe-probe test skips on minimal-/dev environments without /dev/stdin (it would report OTHER for an unobservable fd), and one caveat verified during review: on bash 4.3/4.4 (e.g. Git Bash), assigning BASH_COMPAT=50 prints a non-fatal 'invalid value' warning to stderr — those bashes are already on tempfiles, so the guard is a no-op there; windows-setup-e2e exercises this empirically.

* docs: TODOS.md v1.69 wave close-out

Move the slate P4 entry and the ZeroEntropy P1's gstack-side half to Completed (v1.69.0.0); reframe the ZeroEntropy NEXT PRIORITY entry around the remaining gbrain-side work; file the wave's four residuals with rationale — the prefixed-cleanup symmetric conversion, the #2163 legacy-slug checkpoint heal, the invited #2657 --reconcile contribution, and the table-driven setup host dispatch behind the new cross-check ratchet.

* chore: bump version and changelog (v1.69.0.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Som Samantray <som.samantray@gmail.com>
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Co-authored-by: Connex Client Access <paul@paulkortman.com>
Co-authored-by: y$un_ <forrest.sun527@gmail.com>
Co-authored-by: Lockyer <135391289+Lockyer228@users.noreply.github.com>
Co-authored-by: Benjamin D. Smith <benjamin.smith@binarysword.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 08:55:58 -07:00

852 lines
39 KiB
Bash
Executable File

#!/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 <cmd> # adds SessionStart hook
# gstack-settings-hook remove <cmd> # removes gstack-session-update items
# # (the <cmd> 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 <name — see the validator in add-event> \
# --command <cmd> --source <tag> [--matcher <regex>] [--timeout <s>]
# gstack-settings-hook ensure-event --event ... --command ... --source ... [--matcher ...] [--timeout <s>]
# gstack-settings-hook remove-source --source <tag>
# 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 <root> # re-point gstack items at <root>, 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 (<settings>.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).
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
# pipe in the forked child before exec, with no reader on the other end. On
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
# body >=512B blocks write() forever and the script hangs at startup with no
# output. Compat level 50 restores the tempfile path. These scripts are
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
# guard is per-script, and it survives `bash script.sh` call sites that
# bypass the shebang.
BASH_COMPAT=50
set -euo pipefail
ACTION="${1:-}"
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}"
if [ -z "$ACTION" ]; then
cat <<EOF >&2
Usage:
gstack-settings-hook add <hook-command> # legacy SessionStart add
gstack-settings-hook remove <hook-command> # legacy SessionStart remove
gstack-settings-hook add-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
gstack-settings-hook ensure-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
gstack-settings-hook remove-source --source <tag>
gstack-settings-hook diff-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
gstack-settings-hook prune-stale [--repoint <root>] [--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"' <single-quoted body>'
# 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 <hook-command>" >&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 <hook-command>" >&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 <tag>" >&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