mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-23 14:32:33 +02:00
v1.68.1.0 fix: phantom AskUserQuestion hooks — canonical-only registration + self-healing settings.json (#2631)
* fix(settings-hook): KNOWN_HOOKS identity healer — per-item ownership, mutation lock, fail-closed parse
Claude Code strips the unknown _gstack_source key when it rewrites
settings.json, so tag-based dedupe degraded to exact-command equality and
every Conductor worktree's setup appended a fresh hook entry; deleted
worktrees left dead hooks erroring on every AskUserQuestion fire.
- KNOWN_HOOKS identity table (shared JS prelude, single source of truth):
ownership is intrinsic and PER HOOK ITEM — basename + relpath suffix +
event (+ matcher where defined). Tags never claim foreign items.
- New `prune-stale [--repoint <root>] [--all]`: prune dead gstack items,
re-point survivors at the stable install (tag restore from the table),
exact-duplicate collapse, uninstall/no-team identity sweep. Explicit
plan_tune_hooks:no is honored (dead pruned, live never re-pointed).
- add-event / remove-source become item-aware: replace/remove only the owned
item; a user's co-located hook in the same entry is never collateral.
- Mutation safety: mkdir lock with owner token, ownership-checked release,
atomic stale takeover; per-process-unique tmp + backup names;
backup-on-change everywhere; fail-closed on parse failure (a corrupt
settings.json is never overwritten — previously catch{} clobbered it);
locked atomic rollback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(gstack-config): `has <key>` — key-presence provenance through STATE_DIR resolution
`get` returns the DEFAULTS value for absent keys, so callers that need to
know whether the USER decided something (vs inherited a default) had no
correct primitive — setup's consent logic was about to grep a hardcoded
~/.gstack/config.yaml, which misclassifies under GSTACK_STATE_ROOT /
GSTACK_HOME / GSTACK_STATE_DIR overrides. `has` exits 0 iff the key is
literally present in the resolved config file, with the same C-locale key
validation as get/set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): canonical-only hook registration, heal-first, PT_EXPLICIT consent provenance
Three root causes of the phantom-AskUserQuestion-hooks class, all in the
registration path:
- Bug A: the Conductor auto-opt-in upgraded PT_DECISION "prompt" -> "yes"
even when "prompt" was dev-setup's EXPLICIT --plan-tune-hooks=prompt pin,
so every new Conductor workspace installed hooks. PT_EXPLICIT (flag/env/
config-key-presence via `gstack-config has`) now gates the auto-opt-in to
the true silent fall-through.
- Bug B: hook commands were baked from $SOURCE_GSTACK_DIR (`pwd -P` of the
running tree — ephemeral for worktrees). Registration is now CANONICAL-ONLY
via _hook_command_path (${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack);
missing canonical hook = skip + log, never a baked tree path. SessionStart
moves to schema-aware add-event under its identity source; whitespace paths
are quoted.
- Bug C: nothing ever pruned, and dead tagged entries blocked the
"already installed" guards forever. Setup now heals FIRST on every run
(prune-stale --repoint at the stable install), surfaces a one-line summary
only when something changed, surfaces the plan_tune_hooks:no-vs-live-hooks
contradiction, and --no-team tears down all three sources plus an identity
sweep for untagged strays.
dev-setup's no-mutation guarantee gains its stated repair exception (prune
dead / re-point existing, never ADD).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(uninstall): run hook cleanup BEFORE install-root deletion + full identity sweep
SETTINGS_HOOK resolves via $(dirname "$0") INSIDE the install root, but the
cleanup ran after `rm -rf ~/.claude/skills/gstack` — a real global uninstall
(running the installed copy) silently no-op'd and orphaned every hook entry.
Tests masked it by running the uninstaller from the repo checkout.
The relocated block also removes the auq-error-fallback source (registered by
setup, previously never torn down) and finishes with a prune-stale --all
identity sweep so untagged strays (Claude Code strips _gstack_source) go too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: phantom-hooks heal coverage — incident facsimile, per-item safety, lock, canonical tripwires
- gstack-settings-hook-schema-aware: 16 new cases — identity re-point (tag
restore), foreign-basename rejection, mixed-entry per-item safety for
add-event/remove-source/--all, prune-stale modes incl. bash-prefix +
Windows-backslash + spaced-path idempotence, duplicate collapse preferring
the tagged twin, plan_tune_hooks:no split, backup-on-change no-churn,
fail-closed corrupt-JSON for every mutator, stale-lock takeover,
fresh-foreign-lock skip, two-writer concurrency smoke, and an INCIDENT
FACSIMILE replaying the exact 2026-08-17 production damage (6/3/2 entries,
mixed tags, live-ephemeral Stop) healing to 2/1/1 canonical.
- NEW setup-hook-canonical-paths: static tripwires — canonical-only resolver
(no $SOURCE_GSTACK_DIR anywhere in it), heal-before-guards ordering,
unsuppressed heal output, ${VAR:-0} counter idiom, shared-prelude
concatenation at every bun call site, KNOWN_HOOKS completeness vs setup's
registrations, uninstall cleanup-before-deletion ordering, defect-class
warning present.
- setup-plan-tune-hooks-noninteractive: PT_EXPLICIT pins + `gstack-config
has` provenance + has-subcommand behavior (env-resolution, malformed keys).
- auq-error-fallback-hook: registration + both-teardown wiring (previously
untested).
- uninstall: behavioral ordering test running the INSTALLED copy from inside
the root it deletes.
- setup-windows-fallback / gstack-config-key-locale: pins updated for the new
HOOK_CMD shape and the third C-locale validator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): banner-tripwire exec used JSON.stringify as shell quoting — vacuous pass + stray artifact
JSON escaping is not shell escaping. Interpolating JSON.stringify(script)
into `bash -c ${...}` left every JSON "\n" as a literal backslash-n inside
shell double quotes, collapsing the extracted release-body tripwire block
onto one line: `then\n` parsed as the command word `thenn`, and
`>&2\nelse\n` parsed as the redirect `>&2nelsen` — so every full-suite run
littered a `2nelsen` file (containing "bash: thenn: command not found") in
the repo root, and the test's single not-contains assertion passed
VACUOUSLY because all output had been redirected into that file. The
"and it actually fires" functional check never verified anything.
Fix: pass the script as an argv element (spawnSync array form) and assert
both branches for real — ABORT case must print the leak message to stderr,
clean case must print "banner tripwire clean" to stdout.
Verified: `bun test test/binding-template-drift.test.ts` previously created
the artifact deterministically; the full free suite now runs artifact-free.
The other shell-interpolation sites (evidence, schema-aware concurrency,
empty-find-fallthrough, branch-slug-hygiene) already use correct quoting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: regression pin for legacy remove mixed-entry filtering + ownership negatives
Coverage-audit iron rule: the rewritten legacy `remove` action filters
per-item (pre-v1.67.2 it dropped the whole entry, destroying a user's
co-located SessionStart hook) — modified existing behavior, previously
untested. Also pins two ownership negatives: an owned basename+relpath under
the WRONG matcher stays foreign, and prune-stale on an absent settings file
exits 0 with removed 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: pre-landing review fixes — review-army findings hardened
Specialist review (testing, maintainability, security, performance,
data-migration) findings, each verified against code before fixing:
- legacy remove: preserve malformed/foreign entries (hooks absent, non-array,
or pre-existing empty) — only entries THIS pass emptied are dropped
- add-event: never tag a mixed entry (old gstack versions in sibling
worktrees treat tags as entry-level ownership and would destroy the user's
co-located items); tag only single-item entries; prune-stale drops tags
from mixed entries for the same reason
- prune-stale: within-entry twin collapse (two dead copies of one hook
re-pointed to the same canonical command no longer double-fire); command
quoting hardened via gsQuoteCmd (escapes \\ " $ backtick; gsStripWrap
unescapes so identity round-trips); NUL bytes in the dedupe key replaced
with a JSON.stringify key (bash silently dropped the NULs, degrading the
separator; the file also read as binary to tooling)
- gsIsAlive: only provable absence (ENOENT/ENOTDIR) counts as dead —
EACCES/EIO/unmounted volumes no longer prune (one-way-ratchet guard)
- gsWriteIfChanged: preserves the live settings.json mode across rewrites
(a user-tightened 0600 carrying API keys was silently broadened to 0644);
fresh files start 0600; backups rotate (keep 10)
- remove-source: command-less items default to foreign (gstack only writes
type:command items); single-item stray claim requires a command
- rollback: pointer target must be a sibling settings.json.bak.* file
- uninstall + setup --no-team + SessionStart registration: stderr stays
attached — a lock give-up or fail-closed parse during TEARDOWN must be
visible ("the next setup retries" does not apply after uninstall)
- setup: team-mode banner no longer claims an auto-update hook when
registration was skipped; heal log documents the rollback-pointer caveat;
SESSION_UPDATE_CMD quoting mirrors gsQuoteCmd; lock constants named
- list-sources: corrupt settings.json reports to stderr instead of silently
printing nothing (setup guards must not misread corrupt as no-hooks)
- tests: 10 new pins (malformed-entry preservation, mixed no-tag, twin
collapse, 0600 mode, metachar escaping round-trip, backup rotation,
rollback pointer refusal, held-lock uninstall warning, matcher-drift
tripwire, ownership negatives)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: red-team findings — verify-gate identity, single quoting authority, Windows paths
Red-team pass over the hardened diff (several findings empirically verified
by the reviewer before reporting):
- KNOWN_HOOKS gains the sixth identity: gstack-verify-gate (README-documented
opt-in Stop hook). A tag-stripped verify-gate entry previously survived
prune-stale --all and errored at the end of EVERY turn after uninstall
deleted the install root — the exact phantom-hook class this branch fixes.
Uninstall also sweeps its tagged form.
- add-event is now the single quoting authority: every registered command is
normalized through the same gsQuoteCmd/gsStripWrap round-trip the healer
uses. Pre-fix, only SessionStart got caller-side quoting — a spaced/metachar
canonical root registered broken plan-tune/AUQ/timeline hooks that the very
next heal rewrote (the codebase disagreed with its own registrations).
- Windows: MSYS-form paths (/c/Users/...) are drive-translated for fs checks
only (gsWinPath) — native bun resolved them drive-relative, so the heal
judged every LIVE Windows hook dead and pruned it. The three AskUserQuestion
hooks and the Stop hook now also get the mandatory 'bash ' prefix on
Windows (previously only SessionStart did; extensionless bash shims
otherwise hit the file-association dialog).
- CANONICAL_GSTACK_ROOT falls back to $HOME/.claude/skills/gstack when a
CLAUDE_CONFIG_DIR-derived root was never installed (the installer hardcodes
the home path — split-brain left such users permanently hookless).
- prune-stale preserves foreign entries that STARTED empty (they were
silently deleted, uncounted, on every heal).
- The timeline Stop registration and its list-sources guard join the
zero-silent-mutations contract (stderr attached).
Tests: verify-gate tag-stripped heal+sweep, started-empty preservation,
add-event quoting-authority round-trip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump version and changelog (v1.68.1.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: update project documentation for v1.68.1.0
README: document canonical-only hook registration + the prune-stale
self-heal in the setup hooks section; expand the manual-uninstall note
to cover every gstack hook identity, not just timeline-stop-hook.
CONTRIBUTING: record PT_EXPLICIT provenance (Conductor auto-opt-in
fires only on the true silent fall-through) and the heal-first repair
exception in the dev-setup paragraph.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings-hook): fail-loud hardening — gsMain umbrella, lock exit 5, prototype-safe ownership
bun in -e mode swallows uncaught exceptions thrown after a require() and
exits 0 (verified on 1.3.13; uncaughtException handlers never fire either),
so any runtime throw in a mutator was a SILENT SUCCESS. Every script body
now runs inside a gsMain try/catch that prints "internal error ... refusing
to mutate" and exits 4.
Also: lock give-up now exits 5 instead of 0 (callers must not report a
skipped mutation as registered); basename lookup uses hasOwnProperty so a
foreign hook named "toString"/"constructor" can't resolve to an inherited
Object.prototype member and abort the sweep; ownership-checked release also
clears an empty/missing owner file; backup rotation sorts by mtime, not
name; Windows-only backslash normalization (a legal Unix path containing a
backslash is no longer rewritten); GSTACK_SWEEP_EXCLUDE_SOURCES lets a
sweep spare named sources; lock tradeoffs documented at the lock helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): honest hook-registration reporting + verify-gate sweep exclusion
_install_plan_tune_hooks now propagates per-add-event failures (lock
contention exits 5, fail-closed settings errors exit 3) and both caller
sites branch on it: success logs the installed message, failure logs a
visible "NOT registered — re-run ./setup" warning instead of claiming
success for a mutation that never happened.
--no-team's identity sweep runs with GSTACK_SWEEP_EXCLUDE_SOURCES=
verify-gate: turning team mode off must not delete the user-registered
verify-gate opt-in whose binary still exists (uninstall still sweeps it,
correctly, because there the binary itself is being removed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: adversarial regression pins — wrong-shape fail-loud, prototype basename, sweep exclusion, lock exit 5
New pins for the fail-loud hardening: a wrong-shape hooks value (object
where an array belongs) exits 4 with "refusing to mutate" and leaves the
file byte-identical (pre-gsMain this was a silent exit-0 no-op); a foreign
hook whose basename collides with Object.prototype ("toString") survives
an --all sweep that still removes gstack rows; GSTACK_SWEEP_EXCLUDE_SOURCES
preserves the verify-gate row during --all; the fresh-foreign-lock test now
asserts the loud exit 5 instead of a quiet skip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(verify-gate): allow the --no-team sweep exclusion, keep registration banned
setup now legitimately mentions verify-gate once: the --no-team identity
sweep excludes it via GSTACK_SWEEP_EXCLUDE_SOURCES so team-mode teardown
can't delete a user-registered gate. The opt-in pin tightens from a blanket
not-contains to: every mention must be a comment or that exclusion, and no
mention may sit on an add-event line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings-hook): GNU-first stat in the lock stale check — Linux abort on held locks
On Linux, BSD-style `stat -f %m` prints a multi-line FILESYSTEM block to
stdout before exiting 1, so the BSD-first || chain captured that garbage
concatenated with the real `stat -c %Y` epoch. The non-numeric mtime made
`$(( now - mtime ))` a syntax error and set -e killed the binary with
exit 1 whenever a lock dir already existed — every contention path (stale
takeover, give-up, concurrent writers) broke on CI while staying green on
macOS, where BSD stat -f succeeds cleanly.
GNU `stat -c %Y` now goes first (BSD stat rejects -c with no stdout, so
macOS falls through cleanly), and a numeric guard blanks any residual
garbage so a future platform quirk degrades to the normal give-up path
instead of an arithmetic abort. Same defect class as gstack-repo-mode's
GNU-first ordering (#2195). Verified in an oven/bun Linux container:
the four CI-failing lock tests now pass (62/62 across both files).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(uninstall): 30s budgets for the two subprocess-heavy behavioral tests
Both tests spawn the copied uninstaller, which itself runs several
settings-hook bun -e children (the lock-contention one also waits out a
300ms give-up per call). On a loaded box those cold starts blow bun's
default 5s per-test timeout, and a timeout kill reports as a bare fail
with no assertion diff — observed at 5.6-8.5s under load avg 25+.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
28d59ad56c
commit
9da6692930
+11
-4
@@ -68,10 +68,17 @@ fi
|
||||
# GSTACK_PLAN_TUNE_HOOKS=yes would still resolve to "install" and rewrite the
|
||||
# user's global ~/.claude/settings.json to point at THIS ephemeral worktree —
|
||||
# which breaks once the workspace is deleted. The flag has highest precedence,
|
||||
# so it pins resolution to "prompt", and closed stdin then makes prompt-mode a
|
||||
# no-op skip (no install, no decline marker). A dev workspace must never mutate
|
||||
# global settings.json. To install the hooks, run `./setup --plan-tune-hooks`
|
||||
# directly (outside dev-setup). Saved prefix/other config preferences still apply.
|
||||
# so it pins resolution to "prompt" (setup's PT_EXPLICIT provenance keeps the
|
||||
# Conductor auto-opt-in from overriding an explicit flag), and closed stdin
|
||||
# then makes prompt-mode a no-op skip (no install, no decline marker).
|
||||
#
|
||||
# A dev workspace never ADDS hooks to global settings.json. One stated repair
|
||||
# exception: setup's heal-first pass may PRUNE dead gstack hook entries and
|
||||
# RE-POINT existing ones at the stable ~/.claude/skills/gstack install —
|
||||
# strictly convergent repair, never a new registration, and hook registration
|
||||
# itself is canonical-only (an ephemeral tree path can never be baked in).
|
||||
# To install the hooks, run `./setup --plan-tune-hooks` directly (outside
|
||||
# dev-setup). Saved prefix/other config preferences still apply.
|
||||
#
|
||||
# GSTACK_SKIP_GBRAIN_REGEN=1 is passed INLINE (not exported) so it scopes to
|
||||
# exactly this nested setup call and can't leak into any other setup path. It
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
#
|
||||
# Usage:
|
||||
# gstack-config get <key> — read a config value (falls back to DEFAULTS)
|
||||
# gstack-config has <key> — exit 0 iff the key is literally present in the
|
||||
# config file (get returns DEFAULTS for absent keys,
|
||||
# so callers that need provenance use this instead)
|
||||
# gstack-config set <key> <value> — write a config value
|
||||
# gstack-config list — show all config (values + defaults)
|
||||
# gstack-config defaults — show just the defaults table
|
||||
@@ -326,6 +329,14 @@ case "${1:-}" in
|
||||
fi
|
||||
printf '%s' "$VALUE"
|
||||
;;
|
||||
has)
|
||||
KEY="${2:?Usage: gstack-config has <key>}"
|
||||
if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
|
||||
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<endpoint-id> suffix" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null
|
||||
;;
|
||||
set)
|
||||
KEY="${2:?Usage: gstack-config set <key> <value>}"
|
||||
VALUE="${3:?Usage: gstack-config set <key> <value>}"
|
||||
|
||||
+616
-159
@@ -1,39 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-settings-hook — manage Claude Code hooks in ~/.claude/settings.json
|
||||
#
|
||||
# Two shapes:
|
||||
# Three shapes:
|
||||
#
|
||||
# 1. Legacy (SessionStart only — used by setup --team and gstack-uninstall):
|
||||
# 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 matching 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 — supports PreToolUse + PostToolUse):
|
||||
# gstack-settings-hook add-event --event <SessionStart|PreToolUse|PostToolUse> \
|
||||
# 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
|
||||
# gstack-settings-hook rollback # restore latest backup (single-step undo)
|
||||
# gstack-settings-hook list-sources # show all gstack-tagged hook entries
|
||||
#
|
||||
# ensure-event is the update-in-place verb: same flags as add-event, but it
|
||||
# first compares the REGISTERED payload for (event, matcher, source) against
|
||||
# the requested one. Identical → no write, no backup ("unchanged"). Different
|
||||
# → the single matching entry is replaced via one atomic tmp+rename, so a
|
||||
# failed update can never leave zero or two registrations. This is what heals
|
||||
# a stale absolute hook path (e.g. a deleted dev worktree) baked into
|
||||
# settings.json by an earlier setup — presence-only dedup never re-pointed it.
|
||||
# 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)
|
||||
#
|
||||
# Every add-event/remove-source writes a backup to ~/.claude/settings.json.bak.<ts>
|
||||
# before mutating (Codex correction — silent settings.json mutation is wrong);
|
||||
# ensure-event backs up only when it actually mutates, so a no-op re-run of
|
||||
# ./setup doesn't churn backup files.
|
||||
# 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.
|
||||
#
|
||||
# Dedup: legacy `add`/`remove` dedupe by the historical `gstack-session-update`
|
||||
# substring. Schema-aware `add-event` dedupes by (event, matcher, _gstack_source) so
|
||||
# multiple gstack registrations (plan-tune, ...) don't collide.
|
||||
# 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.
|
||||
#
|
||||
# Writes atomically: .tmp + rename to prevent corruption on crash/disk-full.
|
||||
# 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).
|
||||
set -euo pipefail
|
||||
|
||||
ACTION="${1:-}"
|
||||
@@ -48,6 +68,7 @@ Usage:
|
||||
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
|
||||
@@ -59,41 +80,258 @@ if ! command -v bun >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
backup_settings() {
|
||||
if [ -f "$SETTINGS_FILE" ]; then
|
||||
local ts
|
||||
ts=$(date +%Y%m%d-%H%M%S)
|
||||
cp "$SETTINGS_FILE" "$SETTINGS_FILE.bak.$ts"
|
||||
echo "$SETTINGS_FILE.bak.$ts" > "$SETTINGS_FILE.bak-latest"
|
||||
# ─── 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=""
|
||||
}
|
||||
|
||||
# --- legacy SessionStart add/remove (backwards compat) -----------------
|
||||
_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
|
||||
backup_settings
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e '
|
||||
const fs = require("fs");
|
||||
_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;
|
||||
let settings = {};
|
||||
// An EXISTING file that does not parse must never be rewritten: the
|
||||
// old catch{} folded it to {} and the atomic write below replaced the
|
||||
// user permissions/env/other hooks with just ours. Refuse loudly.
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
|
||||
catch (e) {
|
||||
console.error("error: " + settingsPath + " exists but is not valid JSON (" +
|
||||
(e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
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 =>
|
||||
@@ -104,9 +342,8 @@ case "$ACTION" in
|
||||
hooks: [{ type: "command", command: hookCmd }]
|
||||
});
|
||||
}
|
||||
const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
gsWriteIfChanged(settingsPath, before, settings, loaded.existed);
|
||||
});
|
||||
'
|
||||
;;
|
||||
|
||||
@@ -117,23 +354,34 @@ case "$ACTION" in
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$SETTINGS_FILE" ] || exit 1
|
||||
backup_settings
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e '
|
||||
const fs = require("fs");
|
||||
_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;
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); }
|
||||
const loaded = gsLoadSettings(settingsPath);
|
||||
const settings = loaded.settings;
|
||||
const before = JSON.stringify(settings, null, 2);
|
||||
if (settings.hooks && settings.hooks.SessionStart) {
|
||||
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry =>
|
||||
!(entry.hooks && entry.hooks.some(h => h.command && h.command.includes("gstack-session-update")))
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
' 2>/dev/null
|
||||
gsWriteIfChanged(settingsPath, before, settings, loaded.existed);
|
||||
});
|
||||
'
|
||||
;;
|
||||
|
||||
add-event|diff-event|ensure-event)
|
||||
@@ -161,13 +409,15 @@ case "$ACTION" 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
|
||||
if [ "$ACTION" = "add-event" ]; then
|
||||
backup_settings
|
||||
fi
|
||||
DIFF_ONLY=""
|
||||
if [ "$ACTION" = "diff-event" ]; then DIFF_ONLY=1; fi
|
||||
ENSURE=""
|
||||
if [ "$ACTION" = "ensure-event" ]; then ENSURE=1; fi
|
||||
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" \
|
||||
@@ -176,8 +426,7 @@ case "$ACTION" in
|
||||
GSTACK_TIMEOUT="$TIMEOUT" \
|
||||
GSTACK_DIFF_ONLY="$DIFF_ONLY" \
|
||||
GSTACK_ENSURE="$ENSURE" \
|
||||
bun -e '
|
||||
const fs = require("fs");
|
||||
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;
|
||||
@@ -187,64 +436,98 @@ case "$ACTION" in
|
||||
const diffOnly = process.env.GSTACK_DIFF_ONLY === "1";
|
||||
const ensure = process.env.GSTACK_ENSURE === "1";
|
||||
|
||||
let settings = {};
|
||||
// An EXISTING file that does not parse must never be rewritten: the
|
||||
// old catch{} folded it to {} and the atomic write below replaced the
|
||||
// user permissions/env/other hooks with just ours. Refuse loudly.
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
|
||||
catch (e) {
|
||||
console.error("error: " + settingsPath + " exists but is not valid JSON (" +
|
||||
(e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run.");
|
||||
process.exit(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] = [];
|
||||
|
||||
// Identity key is (event, source): any existing entry carrying OUR
|
||||
// source tag for this event IS the entry to compare/update — a matcher
|
||||
// change must update it in place, never push a SECOND gstack entry
|
||||
// (the old key included the matcher, so a future matcher change would
|
||||
// have duplicated the registration). Untagged legacy entries are still
|
||||
// adopted when both matcher and command line up.
|
||||
const matchesEntry = (entry) => {
|
||||
if (entry._gstack_source === source) return true;
|
||||
const sameMatcher = (entry.matcher || "") === matcher;
|
||||
const sameCommand = entry.hooks && entry.hooks[0] && entry.hooks[0].command === cmd;
|
||||
return sameMatcher && sameCommand;
|
||||
};
|
||||
|
||||
// Collect ALL matches, not just the first: pre-existing installs can
|
||||
// carry two entries with the same (event, _gstack_source) from the old
|
||||
// matcher-keyed dedup. `.find()` updated only the first and left the
|
||||
// stale twin running forever. Keep ONE canonical entry (the first),
|
||||
// remove the rest in the same atomic write.
|
||||
const matched = settings.hooks[event].filter(matchesEntry);
|
||||
let existing = matched.length > 0 ? matched[0] : undefined;
|
||||
let collapsed = 0;
|
||||
if (matched.length > 1) {
|
||||
const extras = new Set(matched.slice(1));
|
||||
settings.hooks[event] = settings.hooks[event].filter((e) => !extras.has(e));
|
||||
collapsed = matched.length - 1;
|
||||
}
|
||||
const hookEntry = { type: "command", command: cmd };
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
existing.hooks = [hookEntry];
|
||||
existing._gstack_source = source;
|
||||
// Keep the matcher current too — under the (event, source) key the
|
||||
// matched entry may carry a stale matcher.
|
||||
if (matcher) existing.matcher = matcher;
|
||||
else delete existing.matcher;
|
||||
} else {
|
||||
// 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);
|
||||
@@ -261,48 +544,22 @@ case "$ACTION" in
|
||||
}
|
||||
|
||||
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.
|
||||
// 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);
|
||||
}
|
||||
|
||||
try {
|
||||
if (ensure && fs.existsSync(settingsPath)) {
|
||||
// Mirrors backup_settings (bash) — but only when a write actually
|
||||
// happens, so a no-op ensure-event never creates backup files.
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const ts = "" + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) +
|
||||
"-" + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds());
|
||||
fs.copyFileSync(settingsPath, settingsPath + ".bak." + ts);
|
||||
fs.writeFileSync(settingsPath + ".bak-latest", settingsPath + ".bak." + ts + "\n");
|
||||
}
|
||||
|
||||
// Atomic tmp+rename: the settings file is either the old JSON (with
|
||||
// the old single registration) or the new JSON (with the replaced
|
||||
// one) — a failed update can never leave zero or two registrations.
|
||||
// Per-process tmp suffix: a fixed settings.json.tmp let two parallel
|
||||
// writers consume one another. (No apostrophes here: this JS lives
|
||||
// inside a bash single-quoted string.)
|
||||
const tmp = settingsPath + ".tmp." + process.pid;
|
||||
fs.writeFileSync(tmp, after + "\n");
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
} catch (e) {
|
||||
// Explicit catch + exit 1: bun -e has been observed (1.3.13) to turn
|
||||
// an uncaught sync fs error into a SILENT exit 0, which would let a
|
||||
// failed update masquerade as success to the caller.
|
||||
console.error("error: could not update " + settingsPath + ": " + (e && e.message ? e.message : e));
|
||||
process.exit(1);
|
||||
}
|
||||
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 && existing) {
|
||||
if (ensure && cands.length > 0) {
|
||||
console.log("OK: " + event + " hook re-pointed (source: " + source + ")");
|
||||
} else {
|
||||
console.log("OK: " + event + " hook registered (source: " + source + ")");
|
||||
}
|
||||
});
|
||||
'
|
||||
;;
|
||||
|
||||
@@ -320,26 +577,199 @@ case "$ACTION" in
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$SETTINGS_FILE" ] || exit 0
|
||||
backup_settings
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" bun -e '
|
||||
const fs = require("fs");
|
||||
_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;
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); }
|
||||
if (!settings.hooks) { process.exit(0); }
|
||||
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 before = settings.hooks[event].length;
|
||||
settings.hooks[event] = settings.hooks[event].filter(entry => entry._gstack_source !== source);
|
||||
removed += before - settings.hooks[event].length;
|
||||
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;
|
||||
const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
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 + ")");
|
||||
});
|
||||
'
|
||||
;;
|
||||
|
||||
@@ -349,20 +779,46 @@ case "$ACTION" in
|
||||
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
|
||||
cp "$LATEST" "$SETTINGS_FILE"
|
||||
_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 '
|
||||
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 { process.exit(0); }
|
||||
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)) {
|
||||
@@ -374,6 +830,7 @@ case "$ACTION" in
|
||||
}
|
||||
}
|
||||
if (!any) console.log("(no gstack-tagged hooks)");
|
||||
});
|
||||
'
|
||||
;;
|
||||
|
||||
|
||||
+33
-15
@@ -132,6 +132,39 @@ if [ -d "$STATE_DIR/projects" ]; then
|
||||
done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# ─── Remove gstack hooks from Claude Code settings ──────────
|
||||
# MUST run BEFORE any install-root deletion: SETTINGS_HOOK resolves inside the
|
||||
# install being removed — in a real global uninstall, running this after
|
||||
# `rm -rf ~/.claude/skills/gstack` silently no-ops and orphans every hook.
|
||||
SETTINGS_HOOK="$(dirname "$0")/gstack-settings-hook"
|
||||
SESSION_UPDATE="$(dirname "$0")/gstack-session-update"
|
||||
if [ -x "$SETTINGS_HOOK" ]; then
|
||||
"$SETTINGS_HOOK" remove "$SESSION_UPDATE" && REMOVED+=("SessionStart hook") || true
|
||||
# Cathedral T8 cleanup: also remove plan-tune PreToolUse + PostToolUse hooks.
|
||||
if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral | grep -q "removed [1-9]"; then
|
||||
REMOVED+=("plan-tune cathedral hooks")
|
||||
fi
|
||||
# AskUserQuestion error-fallback hook (registered by setup; previously never
|
||||
# torn down).
|
||||
if "$SETTINGS_HOOK" remove-source --source auq-error-fallback | grep -q "removed [1-9]"; then
|
||||
REMOVED+=("AskUserQuestion error-fallback hook")
|
||||
fi
|
||||
# Timeline Stop hook (#2553).
|
||||
if "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop | grep -q "removed [1-9]"; then
|
||||
REMOVED+=("timeline Stop hook")
|
||||
fi
|
||||
# Verification stop hook (opt-in via README; user-registered, ours to sweep).
|
||||
if "$SETTINGS_HOOK" remove-source --source verify-gate | grep -q "removed [1-9]"; then
|
||||
REMOVED+=("verification Stop hook")
|
||||
fi
|
||||
# Identity sweep for untagged strays (Claude Code strips _gstack_source
|
||||
# tags; pre-v1.67 setups baked worktree paths). Removes every gstack-owned
|
||||
# hook item, live or dead — the binaries they point at are being deleted.
|
||||
if "$SETTINGS_HOOK" prune-stale --all | grep -q "removed [1-9]"; then
|
||||
REMOVED+=("stray gstack hook entries")
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Remove global Claude skills ────────────────────────────
|
||||
CLAUDE_SKILLS="$HOME/.claude/skills"
|
||||
|
||||
@@ -334,21 +367,6 @@ if [ -n "$_GIT_ROOT" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Remove SessionStart hook from Claude Code settings ─────
|
||||
SETTINGS_HOOK="$(dirname "$0")/gstack-settings-hook"
|
||||
SESSION_UPDATE="$(dirname "$0")/gstack-session-update"
|
||||
if [ -x "$SETTINGS_HOOK" ]; then
|
||||
"$SETTINGS_HOOK" remove "$SESSION_UPDATE" 2>/dev/null && REMOVED+=("SessionStart hook") || true
|
||||
# Cathedral T8 cleanup: also remove plan-tune PreToolUse + PostToolUse hooks.
|
||||
if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null | grep -q "removed [1-9]"; then
|
||||
REMOVED+=("plan-tune cathedral hooks")
|
||||
fi
|
||||
# Timeline Stop hook (#2553).
|
||||
if "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop 2>/dev/null | grep -q "removed [1-9]"; then
|
||||
REMOVED+=("timeline Stop hook")
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Remove global state ────────────────────────────────────
|
||||
if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then
|
||||
rm -rf "$STATE_DIR"
|
||||
|
||||
Reference in New Issue
Block a user