mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
* feat: add optional Memorable workflow memory (cherry picked from commit6bd5d477b2) * fix: refuse the second registration, and say what leaves the machine Two things the first cut got wrong. Memorable's own installer registers the same UserPromptSubmit hook, under its own name and outside gstack's table. `memorable start`, `memorable setup` and `memorable install-hooks` all do it, and that is the documented way to install the CLI, so on most machines it is already there before gstack is asked. Registering ours beside it ran the same command twice on every prompt: context injected twice, and the session captured twice against the user's own extraction allowance. `enable` now looks for it and refuses, naming the entry and the file it lives in; `status` says who registered it rather than reporting none. Matched on the command rather than on a tag, for the reason the hook table already gives: Claude Code rewrites settings and private tags do not survive it. The removal instruction says to delete the entry by hand because Memorable has no command that removes its own hook. `uninstall-hooks` is not a command in 0.5.18; it answers "unknown command". The README said "Memorable, not gstack, owns the captured data and any network access", which answers the question by pointing away from it. It now carries a per-command table of exactly what leaves the machine, in the shape the adopted gbrain section uses, and it is explicit that the hook makes no network call of its own, that every row is the third-party CLI acting under its own consent, and that `gstack-egress` will therefore not show any of it. Under it, the split between what gstack pin-tests (the gating and the wiring) and what is Memorable's claim (storage, sending, and what disable and forget erase). The CHANGELOG entry is removed. This file has never carried an [Unreleased] heading; every entry is a version and a date, written at release. The text is in the pull request for whoever cuts the next one. Three tests added: enable refuses and touches neither consent nor settings when Memorable already holds the hook, status names that registration, and a foreign UserPromptSubmit hook is not mistaken for Memorable's. (cherry picked from commite0899afa8c) * docs: a guide for the Memorable bridge, in the gbrain-sync shape README carries the section and the egress table; this is the page it links to for anyone who wants the whole thing. Same shape as docs/gbrain-sync.md, which is the closest thing in the repo: an optional integration whose interesting questions are all about what leaves the machine and how to turn it off. What it covers that the README cannot at that length: that the hook sees every Claude Code prompt rather than only the ones a skill produced; that capture is a separate consent from this bridge, so turning the bridge off does not turn capture off; what to do when Memorable has already registered the hook itself, which is the common case because its own installer does it; and why the hook has no loud failure path. The egress table is repeated here rather than linked, because the sentence it is answering ("what does this send") is the one somebody arrives on this page already asking. Co-authored-by: Advaiyt Sane <advaiyt.sane@gmail.com> Co-authored-by: Nikhil Krishnaswamy <krishnaswamynikhil@gmail.com> (cherry picked from commit5c108cc0f7) * feat(settings-hook): identity-aware remove-source + read-only list-items remove-source used to inspect only entries still carrying the _gstack_source tag. Claude Code strips that tag when it rewrites settings.json, so an off switch built on remove-source alone silently no-oped on exactly the entries it was written for. Removal is now driven by KNOWN_HOOKS identity for the requested source (tagged or not), keeps the tagged-single-item legacy-stray rule, never touches another source's items, and leaves entries with nothing of ours byte-identical. list-items is the read-only view of the same identity table: one JSON string literal per matching hook command, filters (--owned-by, --command-regex as a JavaScript RegExp) applied inside the JS, empty stdout for no match, and the mutating verbs' exit codes (1 usage, 3 unparseable settings, 4 unexpected shape) so callers can decide mutations from its output without parsing raw command strings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(config): memorable_recall consent key (on|off, default off, reject-and-preserve) The gstack-side gate for the Memorable UserPromptSubmit bridge. `on` lets a Claude Code hook hand every prompt to a third-party binary, so the key follows the codex_reviews rule: an invalid value is rejected and the stored value kept, never coerced in either direction. Registered in all four places gstack-config keeps in sync (annotated header, DEFAULTS table, the set validator, and both enumeration loops). Memorable's own capture consent (`memorable enable`) is a separate thing gstack never sets. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(hooks): memorable-user-prompt-hook.ts — consent gate, deny veto, HIGH-tier pre-scan, fail-closed receipt, trust envelope; runExternal in spawn-bin The PR's hook exec'd the vendor binary with the full environment and passed its stdout to Claude verbatim. It is now the house pattern: a fail-open bash shim over a .ts twin that (1) gates on the memorable_recall consent key, (2) skips repos whose trust policy is deny or read-only, (3) scans the prompt (raw bytes and decoded string leaves) and refuses to hand over a HIGH-tier credential shape, (4) writes a fail-closed egress receipt naming the local executable it ran, (5) spawns the vendor in its own process group with an allowlisted environment and group-kills it on timeout, (6) accepts only a string additionalContext back, caps it at 8 KiB on a UTF-8 boundary and wraps it in the trust envelope, and (7) records an `output-written` outcome after the stdout write completes. One deadline clock (4.5 s) undercuts Claude Code's 5 s kill and bounds both ledger writes through the new lockBudgetMs option on writeReceipt/writeOutcome (default unchanged). spawn-bin gains runExternal for external executables (detached group, stderr drained, stdin EPIPE handled, stdout capped, win32 refused). The wiring test pins the sink fail-closed and sweeps hosts/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(gstack-memorable): canonical hook path, no vendor consent, --timeout 5, identity-based status, verified disable, lifecycle lock enable used to bake the hook path from whatever tree the CLI ran in and to run the vendor's own `memorable enable` (its consent for storing AND uploading session traces) before registering anything. It now resolves the canonical install like setup does and refuses when that install does not carry this bridge (version and hook-twin check), registers through the canonical hook manager with --timeout 5, records gstack's own consent in memorable_recall, never executes the vendor, and restores the captured prior state if consent cannot be recorded. disable flips the gate first, removes the entry by identity (tag or no tag), verifies both states and reports partial failure instead of a blended success. status reads only: resolution path, gate, registration by identity (gstack / vendor-own / both / unknown), mismatch lines, receipt count, recent hook errors. enable and disable serialise under a lock with stale takeover. Windows is refused (TODOS.md D21). Exit codes mirror the hook manager (3/4/5). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(setup): --no-team sweep keeps the opt-in gstack-memorable hook `./setup --no-team` finishes its teardown with `prune-stale --all`, which removes every KNOWN_HOOKS item. The Memorable bridge hook is a user-registered opt-in unrelated to team mode, exactly like verify-gate, so it joins the sweep exclusion list. The verify-gate pin now accepts the comma-extended list; a schema-aware case proves the exclusion keeps both opt-ins (tagged or tag-stripped) while the uninstall sweep still removes them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(uninstall): named gstack-memorable arm, vendor-consent notice, honest kept config The identity sweep already removed the Memorable bridge hook as an unnamed stray. It now has a named arm like every other source, so the summary says what went, and says plainly that Memorable's own consent (if the user granted it) is theirs to revoke. Under --keep-state the kept config is set memorable_recall=off so it never claims a hook that is gone. The canonical-paths pins cover the sixth KNOWN_HOOKS row and the new uninstall source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(egress): memorable-recall row in gstack-egress grants `gstack-egress grants` promises every standing consent in force with the command that revokes it. The Memorable bridge's memorable_recall key is one, so it gets a row: off by default, granted only when `gstack-memorable enable` set it, revoked by `gstack-memorable disable`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(memorable): accurate bridge guide; README row, Docs table, privacy pointer; PROJECT_STRUCTURE The README section becomes one row in the Standalone binaries table (the shape every other binary uses) plus a Docs-table row and one Privacy bullet saying that optional third-party bridges are off by default and receipted. The guide now separates the two consents, says what gstack hands to the vendor binary and what the receipt can and cannot attest, attributes every statement about the vendor's network activity to the vendor, describes the hook manager accurately (identity via KNOWN_HOOKS, list-items vs list-sources, rollback is whole-file), states the Claude Code hot-reload behaviour, adds a troubleshooting runbook, and keeps the contributors' credit in the CHANGELOG idiom. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(todos): Memorable bridge follow-ups Filed from the CEO and eng reviews of the bridge fix-up: the generic third-party hook seam, Windows support (deferred whole), the envelope kind parameter, a vendor payload-minimization contract, a latency and timeout revisit, resolver and canonical-root consolidation, a non-interactive MEDIUM-tier redaction policy for hooks, and adopting list-items at setup's plan-tune check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings-hook): list-items --owned-by with --command-regex intersects When both filters are given, an item must satisfy both: owned by the requested source AND matching the pattern. Before, the regex branch skipped every owned row, so the combination could never match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(gstack-memorable): compat_check reads list-items output before grepping Under pipefail, piping the probe straight into grep -q let a non-zero probe exit mask the match, so a hook manager without list-items was sometimes reported as compatible. Capture the output, then grep. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: coverage for the memorable bridge (remove-source regression for every KNOWN_HOOKS source) - settings-hook: identity removal pinned for each source in KNOWN_HOOKS; list-items unknown flag and combined --owned-by/--command-regex - gstack-memorable: enable/disable failure paths (lock give-up exit 5 with the test-only lock timeout override, consent-write failures guarded by canRevokeWrites, canonical-version mismatch, no-bun status) - hook: non-object JSON, missing cwd, non-ASCII bytes, held-open stdin, shim without bun, stripControl, resolveVendor, runExternal ENOENT - egress-receipt: lockBudgetMs 0 and writeOutcome on garbage input - uninstall: no memorable entry present reports nothing removed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(memorable): gbrain backend note and the settings-rewrite race Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(redact-engine): line/col by binary search over a per-scan line index lineColAt walked the input from offset 0 for every finding, so a match-dense input (a pasted log full of emails and IPs) cost O(findings x bytes): 128 KiB took ~400 ms and 900 KiB tens of seconds. The line starts are now indexed once per scan, on the first finding, and each finding is a binary search. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(gbrain-repo-policy-client): repoPolicyTier accepts a spawn timeout The policy script spawn was fixed at 10 s, more than twice the memorable hook's whole budget. Callers on their own deadline pass what they can afford; a timeout reads as unreadable and polarity stays the caller's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(spawn-bin): runExternal resolves on the child's exit, keeps stdin errors advisory - A vendor that exits 0 but leaves a background child holding its pipes was held to the deadline, group-killed and reported as a timeout with its answer dropped. 'exit' now starts a short stdout drain, then resolves with the real exit code and kills whatever still holds the group. - EPIPE on the child's stdin (it answered before reading a large input) is reported as stdinError, separate from error, so a delivered answer is not classified as a spawn failure. - Stdio streams are destroyed and the child unref'd on resolve so a straggler cannot pin the hook process; tail/grace/drain sizes are named constants. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(hooks): memorable hook closes the review army's gaps - Vendor failures are logged even with empty stderr (a silently hanging vendor taxed every prompt invisibly); the stderr tail is withheld when the redaction engine finds a credential or PII shape in it; hook-errors.log is created 0600. - Trust-policy veto fails closed when git cannot run or answer in time (it read as 'no remote' before); the policy script spawn is bounded by the hook's clock; a payload cwd that is not a directory falls back. - Each secret scan is admitted by the deadline clock (the engine's cost grows with match density); stdin is decoded once. - The pre-spawn gate re-check logs a config failure instead of swallowing it; an incomplete stdin read is named as such, not as 'not JSON'. - Carriage returns are stripped with the other controls. - The vendor env allowlist adds the standard proxy, TLS and XDG variables so a vendor behind a corporate proxy or private CA still reaches its service. - A stdin EPIPE on a delivered answer is recorded in the outcome, not treated as a spawn error. - Stage caps and the truncation marker are named constants; a test-only GSTACK_MEMORABLE_TEST_BUDGET_MS can shorten (never widen) the budget. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(gstack-memorable): lock staleness from the directory mtime; honest messages - A contender that looked between the holder's mkdir and its ts write read a missing ts as 0, called the lock stale and reclaimed it; staleness now comes from the lock directory's own mtime (the settings-hook idiom). - The ensure-event failure is no longer labelled 'warning'; the consent-write rollback message says what was actually kept; a removal that left no entry is reported on stdout, not as an error; receipts are counted from the filtered JSON array, not a formatting artefact; the resolution order and lock tuning are named once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(uninstall): memorable_recall goes off whether or not state is kept gstack-config resolves its root through GSTACK_STATE_ROOT/GSTACK_HOME, which can differ from the STATE_DIR uninstall removes; a full uninstall could leave memorable_recall=on in a config that survived. Flipped only when currently on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: pin the review-army fixes for the memorable bridge Nonce-scoped orphan checks (the system-wide ps grep could see another shard's sleeper); exit-with-lingering-grandchild; advisory stdin EPIPE; withheld stderr; vendor timeout logged with empty stderr; CR stripping; budget seam; rate-limit expiry and 0600 log; unreadable policy store fails closed; file-as-cwd fallback; mtime-based lock staleness and the mkdir gap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(memorable): vendor environment allowlist and stderr policy; two follow-ups Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(hooks): memorable hook second-pass review fixes - Trust-policy lookup fails closed on any git failure that is not 'no such remote' or 'not a git repository' (a corrupt or unreadable .git/config and dubious ownership exit 128 and used to read as 'no remote'). - pickAdditionalContext takes the first complete top-level JSON object, so a vendor whose background helper appends a line to stdout (or prints a banner first) does not lose its answer. - The hook-errors.log rate limiter keys on a stable string (a vendor's timestamped stderr no longer defeats it); the log is chmod 0600 on every append because sibling hooks create the same file without a mode. - Scan admission is sized by payload bytes (scan() is uninterruptible). - The receipt payload class is a stable token; the prose moved to the docs. - Header, constants and comments match the behaviour (silent skips vs logged refusals; HIGH/MEDIUM withholding; STAGE_CAP_MS scope; runExternal in the spawn-bin header; the ledger lock budget doc). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(gstack-memorable): stale-lock takeover by atomic rename; comments match behaviour Two contenders that both saw a stale lock could both reclaim it with rm -rf; the settings-hook idiom (mv to a private name, exactly one winner) is used instead. The hook-manager fallback comment now says every verb falls back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: second-pass coverage for the memorable bridge Policy lookup outcomes (plain dir, repo without policy, corrupt .git/config fails closed); repoPolicyTier timeoutMs; line/col at line starts, after blank lines, CRLF and first char; tolerant first-JSON-object parsing; keyed rate limit; uninstall never creates a config just to say off and flips consent in a GSTACK_STATE_ROOT outside the removed state dir. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(memorable): silent skips vs logged refusals; payload class token; D21/D24 anchors Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(hooks): memorable hook survives host termination and brace-bearing banners - The bash shim runs bun as a job and forwards SIGTERM/SIGINT/SIGHUP (bash holds a signal until a foreground child exits); the .ts kills the in-flight vendor's process group on the way out (runExternal exposes the group kill through onSpawn), so a hook the host terminates cannot leave the vendor running with the prompt on its stdin. - The tolerant stdout parser tries every complete top-level object (bounded) and takes the first carrying a string additionalContext, so a banner with braces or quotes, or a progress object, no longer costs the answer. - git runs with LC_ALL=C and the not-a-repository check is anchored to the start of its message: a localized git or a repository path containing the phrase can no longer flip the lookup. - The rate limiter remembers up to 32 live keys, so alternating failures cost two lines, not one per prompt. - Unicode format characters (bidi overrides, zero-width spaces) are stripped from vendor text at egress; the zero-width joiner stays for emoji. - A killed child (timeout, ENOBUFS) resolves on exit without the stdout drain, and the post-kill grace is 100 ms, so the timeout outcome fits the reserve. - The ledger size warning, which the host discards from an exit-0 hook's stderr, is logged where status looks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(gstack-memorable): failed stale-lock takeover reaches the give-up; disable runs without gstack-config; status shows ledger size Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: host termination kills the vendor group; brace banners, decoys and format characters; non-reclaimable stale lock gives up; ledger line in status Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(memorable): the vendor dies with a terminated hook Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: deterministic stdin EPIPE case for runExternal (child closes stdin, stays alive) Under parallel shard load a child that merely exits fast raced the write and the EPIPE was not always observed; closing the read end first makes it so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(hooks): outside-model review fixes for the memorable hook and runExternal - Nothing in the vendor's process group outlives the call: the group is killed on every resolve, the clean 'close' path included (a helper the vendor forked with redirected stdio ran on unsupervised before). - A child that already exited when the deadline fires keeps its result; the deadline only ends the drain instead of rewriting a completed exit as a timeout. - The decoded-leaf scan reports when its node/depth bound cut the walk short and the hook refuses the hand-off as unscanned; object keys are scanned too. - git for the trust-policy lookup runs without inherited GIT_* selectors (GIT_DIR, GIT_WORK_TREE, GIT_CONFIG_*), so it inspects the session's repository and no other; a host cancellation kills that git as well. - An unmatched brace in a vendor banner no longer hides the answer after it. - The stderr tail is scanned whole before it is cropped for the log, so a credential's prefix cannot be cropped away from its secret half. - The vendor override reads an empty GSTACK_MEMORABLE_BIN as unset, exactly as bin/gstack-memorable does, so enable checks the binary the hook runs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(gstack-memorable): stale-lock reclaim checks the inode it judged and the owner's liveness; status reports a failed receipt query as unknown Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(uninstall): revoke memorable_recall independently of the hook manager and name a failed revocation Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: group kill on clean close, walk exhaustion refused, GIT_DIR cannot bypass the deny, unmatched-brace banner, whole-tail scan, empty-override parity, uninstall revocation without the hook manager Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(memorable): the process-group guarantee and its setsid boundary Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(release): v1.83.0.0 — Memorable recall bridge, identity-aware hook removal, faster redaction line/col CHANGELOG entry for the Memorable workflow-memory bridge (opt-in, off by default, Claude Code only), the identity-aware remove-source and read-only list-items in the hook manager, the memorable-recall egress sink, runExternal, lockBudgetMs, and the binary-search line/col index in the redaction engine. No migration. Contributed by @AdvaiytSane and @NIkhil-cmd-cmd (#2831). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: stdin EPIPE observation is scheduling-dependent under shard load; pin the invariant instead A delivered answer is never reclassified as a spawn error; when the EPIPE is observed it is reported as stdinError. Whether it is observed before the child's exit resolves the call is not something the test can force. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: list the memorable-recall sink among the fail-closed egress sinks CLAUDE.md and ARCHITECTURE.md enumerate the receipt sinks that refuse to send when the ledger cannot be written; the Memorable bridge's per-prompt hand-off is pinned fail-closed in test/egress-receipt-wiring.test.ts but was missing from both lists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: memorable bridge tests pass on a runner that enforces file modes; scanner-proof key fixture - The consent-write-failure tests make the state dir read-only, which also blocked the bridge lock directory (exit 5 before the path under test); the locks dir is pre-created so only the consent write fails. - The unreadable-store test leaves a 0600 directory behind (the policy script chmods the store path); cleanup restores the search bit and the suite's afterEach reopens directories before removing. - The AWS-key-shaped fixture is built by concatenation, as every sibling test does, so the CI credential gate does not read it as a live key. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(gstack-memorable): name an unwritable state directory when the lock cannot be created Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: AdvaiytSane <advaiyt.sane@gmail.com> Co-authored-by: Nikhil Krishnaswamy <krishnaswamynikhil@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
933 lines
43 KiB
Bash
Executable File
933 lines
43 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> # removes items the table identifies as <tag>'s, tagged or not
|
|
# 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
|
|
# gstack-settings-hook list-items --event <name> [--owned-by <tag>] [--command-regex <js-re>]
|
|
# # read-only: one JSON string literal per matching hook COMMAND
|
|
# # (identity via KNOWN_HOOKS, never the tag); empty stdout = none
|
|
#
|
|
# 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> # tagged OR table-identified items of <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
|
|
gstack-settings-hook list-items --event <name> [--owned-by <tag>] [--command-regex <js-re>]
|
|
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" },
|
|
"memorable-user-prompt-hook": { source: "gstack-memorable", event: "UserPromptSubmit", matcher: "", relpath: "hosts/claude/hooks/memorable-user-prompt-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;
|
|
// Identity-aware removal (tag OR table). Claude Code strips the
|
|
// _gstack_source tag when it rewrites settings.json, so a tag-only
|
|
// off switch silently no-ops on exactly the entries it was written
|
|
// for. Decision per item, identity first (D = drop, K = keep):
|
|
//
|
|
// item -> | row.source == SOURCE | row of another source | no table row
|
|
// entry tagged SOURCE | D | K | D if single item, else K
|
|
// untagged / other tag | D | K | K
|
|
//
|
|
// Entries with nothing of ours stay byte-identical (tag included);
|
|
// an entry we emptied is dropped; a tagged entry we trimmed loses
|
|
// the tag with its last owned item. Callers that need every gstack
|
|
// item gone still pair this with prune-stale --all.
|
|
for (const event of Object.keys(settings.hooks)) {
|
|
const entries = settings.hooks[event];
|
|
if (!Array.isArray(entries)) continue; // foreign shape: not ours to judge
|
|
const kept = [];
|
|
for (const entry of entries) {
|
|
const tagged = !!entry && entry._gstack_source === source;
|
|
if (!entry || !Array.isArray(entry.hooks) || entry.hooks.length === 0) {
|
|
if (tagged) { removed++; continue; } // tagged but empty/malformed: legacy stray
|
|
kept.push(entry); continue;
|
|
}
|
|
const single = entry.hooks.length === 1;
|
|
let touched = 0;
|
|
const remain = entry.hooks.filter(h => {
|
|
// Command-less items cannot be ours (gstack only writes
|
|
// type:command items) -- preserve them.
|
|
const cmd = (h && typeof h.command === "string") ? h.command : "";
|
|
const row = cmd ? gsOwnedRow(cmd, event, entry.matcher || "") : null;
|
|
const ours = !!row && row.source === source;
|
|
const stray = tagged && single && !!cmd && !row;
|
|
if (ours || stray) { removed++; touched++; return false; }
|
|
return true;
|
|
});
|
|
if (touched === 0) { kept.push(entry); continue; }
|
|
if (remain.length === 0) continue;
|
|
entry.hooks = remain;
|
|
if (tagged) 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-items)
|
|
# Read-only identity view: one JSON string literal per matching hook
|
|
# command (JSON.stringify, so a command containing tabs or newlines
|
|
# cannot split a line), filters applied inside the JS. Empty stdout
|
|
# means no match. Exit 1 usage, 3 unparseable settings, 4 unexpected
|
|
# shape -- the same codes the mutating verbs use, because callers
|
|
# (bin/gstack-memorable) decide mutations from this output.
|
|
LI_EVENT=""
|
|
LI_OWNED_BY=""
|
|
LI_CMD_RE=""
|
|
shift
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--event) LI_EVENT="$2"; shift 2 ;;
|
|
--owned-by) LI_OWNED_BY="$2"; shift 2 ;;
|
|
--command-regex) LI_CMD_RE="$2"; shift 2 ;;
|
|
*) echo "unknown flag: $1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
if [ -z "$LI_EVENT" ]; then
|
|
echo "list-items requires --event <name>" >&2
|
|
exit 1
|
|
fi
|
|
[ -f "$SETTINGS_FILE" ] || exit 0
|
|
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_LI_EVENT="$LI_EVENT" GSTACK_LI_OWNED_BY="$LI_OWNED_BY" GSTACK_LI_CMD_RE="$LI_CMD_RE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
|
const event = process.env.GSTACK_LI_EVENT;
|
|
const ownedBy = process.env.GSTACK_LI_OWNED_BY || "";
|
|
const reSrc = process.env.GSTACK_LI_CMD_RE || "";
|
|
let re = null;
|
|
if (reSrc) {
|
|
try { re = new RegExp(reSrc); }
|
|
catch (e) {
|
|
process.stderr.write("list-items: invalid --command-regex (" + e.message + ")\n");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
const loaded = gsLoadSettings(process.env.GSTACK_SETTINGS_PATH);
|
|
const hooks = loaded.settings.hooks || {};
|
|
const entries = hooks[event];
|
|
if (entries === undefined || entries === null) process.exit(0);
|
|
if (!Array.isArray(entries)) throw new Error("hooks." + event + " is not an array");
|
|
for (const entry of entries) {
|
|
if (!entry || !Array.isArray(entry.hooks)) continue; // foreign shape, preserved by prune-stale too
|
|
for (const h of entry.hooks) {
|
|
const cmd = (h && typeof h.command === "string") ? h.command : "";
|
|
if (!cmd) continue;
|
|
const row = gsOwnedRow(cmd, event, entry.matcher || "");
|
|
if (ownedBy && (!row || row.source !== ownedBy)) continue;
|
|
// Alone, the regex only ever sees items no table row owns (the
|
|
// vendor-own probe). Combined with --owned-by it narrows THAT set:
|
|
// filters intersect, a regex never widens a selection.
|
|
if (re && !ownedBy && row) continue;
|
|
if (re && !re.test(cmd)) continue;
|
|
console.log(JSON.stringify(cmd));
|
|
}
|
|
}
|
|
});
|
|
'
|
|
;;
|
|
|
|
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
|