Files
gstack/test/gstack-settings-hook-schema-aware.test.ts
caba78fefa v1.83.0.0 feat: Memorable recall bridge, opt-in and receipted (supersedes #2831) (#2836)
* feat: add optional Memorable workflow memory

(cherry picked from commit 6bd5d477b2)

* 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 commit e0899afa8c)

* 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 commit 5c108cc0f7)

* 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>
2026-09-08 22:05:41 -07:00

1459 lines
61 KiB
TypeScript

/**
* gstack-settings-hook schema-aware surface (T3 plan-tune cathedral).
*
* Verifies add-event / remove-source / diff-event / rollback / list-sources
* for PreToolUse + PostToolUse registration. Existing team-mode.test.ts
* covers the legacy `add <cmd>` / `remove <cmd>` shape; this file only
* covers the new surface introduced for the plan-tune cathedral.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const SETTINGS_HOOK = path.join(ROOT, 'bin', 'gstack-settings-hook');
let tmpDir: string;
let settingsFile: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-shsa-'));
settingsFile = path.join(tmpDir, 'settings.json');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function run(args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), {
env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile },
encoding: 'utf-8',
timeout: 10000,
});
return { stdout, stderr: '', exitCode: 0 };
} catch (e: any) {
return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 };
}
}
function settings(): any {
return JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
}
// ----------------------------------------------------------------------
// add-event
// ----------------------------------------------------------------------
describe('add-event', () => {
test('registers a PreToolUse hook with matcher + source tag', () => {
const r = run([
'add-event',
'--event', 'PreToolUse',
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
'--command', '/abs/path/to/question-preference-hook',
'--source', 'plan-tune-cathedral',
'--timeout', '5',
]);
expect(r.exitCode).toBe(0);
const s = settings();
expect(s.hooks.PreToolUse).toHaveLength(1);
expect(s.hooks.PreToolUse[0].matcher).toBe('(AskUserQuestion|mcp__.*__AskUserQuestion)');
expect(s.hooks.PreToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/abs/path/to/question-preference-hook');
expect(s.hooks.PreToolUse[0].hooks[0].timeout).toBe(5);
});
test('registers a PostToolUse hook independently of PreToolUse', () => {
run([
'add-event',
'--event', 'PreToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/pre',
'--source', 'plan-tune-cathedral',
]);
const r = run([
'add-event',
'--event', 'PostToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/post',
'--source', 'plan-tune-cathedral',
]);
expect(r.exitCode).toBe(0);
const s = settings();
expect(s.hooks.PreToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/pre');
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/post');
});
test('idempotent: re-adding same (event, matcher, source) updates in place', () => {
run([
'add-event',
'--event', 'PreToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/v1',
'--source', 'plan-tune-cathedral',
]);
run([
'add-event',
'--event', 'PreToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/v2',
'--source', 'plan-tune-cathedral',
]);
const s = settings();
expect(s.hooks.PreToolUse).toHaveLength(1);
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/v2');
});
test('dedup includes command: same (event, matcher, command) with different source updates in place', () => {
run([
'add-event',
'--event', 'PostToolUse',
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
'--command', '/abs/path/to/question-log-hook',
'--source', 'source-A',
'--timeout', '5',
]);
run([
'add-event',
'--event', 'PostToolUse',
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
'--command', '/abs/path/to/question-log-hook',
'--source', 'source-B',
'--timeout', '5',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('source-B');
});
test('dedup includes command: untagged entry with same command is updated not duplicated', () => {
fs.writeFileSync(
settingsFile,
JSON.stringify({
hooks: {
PostToolUse: [
{
matcher: '(AskUserQuestion|mcp__.*__AskUserQuestion)',
hooks: [{ type: 'command', command: '/abs/path/to/question-log-hook', timeout: 5 }],
},
],
},
}, null, 2),
);
run([
'add-event',
'--event', 'PostToolUse',
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
'--command', '/abs/path/to/question-log-hook',
'--source', 'plan-tune-cathedral',
'--timeout', '5',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
});
test('preserves unrelated existing hooks', () => {
fs.writeFileSync(
settingsFile,
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: '/user-own-hook' }],
},
],
},
}, null, 2),
);
run([
'add-event',
'--event', 'PreToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/gstack-hook',
'--source', 'plan-tune-cathedral',
]);
const s = settings();
expect(s.hooks.PreToolUse).toHaveLength(2);
// User's Bash hook still present
const bash = s.hooks.PreToolUse.find((e: any) => e.matcher === 'Bash');
expect(bash).toBeDefined();
expect(bash.hooks[0].command).toBe('/user-own-hook');
});
test('writes a timestamped backup before mutating', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ existing: 'value' }));
run([
'add-event',
'--event', 'PreToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/gstack',
'--source', 'plan-tune-cathedral',
]);
const backups = fs
.readdirSync(tmpDir)
.filter((f) => f.startsWith('settings.json.bak.'));
expect(backups.length).toBeGreaterThanOrEqual(1);
const backupContent = JSON.parse(fs.readFileSync(path.join(tmpDir, backups[0]), 'utf-8'));
expect(backupContent.existing).toBe('value');
expect(backupContent.hooks).toBeUndefined();
});
test('rejects invalid --event', () => {
const r = run([
'add-event',
'--event', 'NotAnEvent',
'--command', '/x',
'--source', 'plan-tune',
]);
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toMatch(/invalid --event/);
});
});
// ----------------------------------------------------------------------
// ensure-event: duplicate (event, source) collapse
// ----------------------------------------------------------------------
describe('ensure-event collapses duplicate (event, source) entries', () => {
test('two same-source entries from the old matcher-keyed dedup collapse to ONE updated entry', () => {
// Pre-existing installs can carry two entries with the same
// (event, _gstack_source) — the old dedup keyed on the matcher too, so a
// matcher change pushed a second registration. `.find()` updated only the
// first and left the stale twin running forever.
const { spawnSync } = require('child_process');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherA', hooks: [{ type: 'command', command: '/old-a', timeout: 5 }] },
{ matcher: 'Bash', hooks: [{ type: 'command', command: '/user-own-hook' }] },
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherB', hooks: [{ type: 'command', command: '/old-b', timeout: 5 }] },
],
},
}, null, 2));
const r = spawnSync('bash', [
SETTINGS_HOOK, 'ensure-event',
'--event', 'PostToolUse',
'--matcher', 'NewMatcher',
'--command', '/canonical',
'--source', 'plan-tune-cathedral',
'--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(0);
// The collapse is reported on stderr, never silent.
expect(r.stderr).toContain('collapsed 1 duplicate');
const s = settings();
const mine = s.hooks.PostToolUse.filter((e: any) => e._gstack_source === 'plan-tune-cathedral');
expect(mine).toHaveLength(1); // ONE canonical entry — the stale twin is gone
expect(mine[0].matcher).toBe('NewMatcher');
expect(mine[0].hooks[0].command).toBe('/canonical');
// Unrelated user hook untouched.
const bash = s.hooks.PostToolUse.find((e: any) => e.matcher === 'Bash');
expect(bash.hooks[0].command).toBe('/user-own-hook');
expect(s.hooks.PostToolUse).toHaveLength(2);
});
test('no duplicates → no collapse message, single entry updated as before', () => {
const { spawnSync } = require('child_process');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcher', hooks: [{ type: 'command', command: '/old', timeout: 5 }] },
],
},
}, null, 2));
const r = spawnSync('bash', [
SETTINGS_HOOK, 'ensure-event',
'--event', 'PostToolUse',
'--matcher', 'NewMatcher',
'--command', '/new',
'--source', 'plan-tune-cathedral',
'--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(0);
expect(r.stderr).not.toContain('collapsed');
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/new');
});
});
// ----------------------------------------------------------------------
// remove-source
// ----------------------------------------------------------------------
describe('remove-source', () => {
test('removes all entries with a given source tag, leaves others alone', () => {
fs.writeFileSync(
settingsFile,
JSON.stringify({
hooks: {
PreToolUse: [
{ matcher: 'Bash', hooks: [{ command: '/keep-me' }] },
],
},
}),
);
run([
'add-event',
'--event', 'PreToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/a',
'--source', 'plan-tune-cathedral',
]);
run([
'add-event',
'--event', 'PostToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/b',
'--source', 'plan-tune-cathedral',
]);
const r = run(['remove-source', '--source', 'plan-tune-cathedral']);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 2 hook/);
const s = settings();
expect(s.hooks.PostToolUse).toBeUndefined();
expect(s.hooks.PreToolUse).toHaveLength(1);
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/keep-me');
});
test('safely no-ops when settings.json missing', () => {
const r = run(['remove-source', '--source', 'plan-tune-cathedral']);
expect(r.exitCode).toBe(0);
});
});
// ----------------------------------------------------------------------
// diff-event
// ----------------------------------------------------------------------
describe('diff-event', () => {
test('emits BEFORE + AFTER without mutating settings.json', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ existing: 'value' }));
const r = run([
'diff-event',
'--event', 'PreToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/gstack',
'--source', 'plan-tune-cathedral',
]);
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain('--- BEFORE');
expect(r.stdout).toContain('--- AFTER');
expect(r.stdout).toContain('plan-tune-cathedral');
// Settings file unchanged.
expect(JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))).toEqual({ existing: 'value' });
});
});
// ----------------------------------------------------------------------
// rollback
// ----------------------------------------------------------------------
describe('rollback', () => {
test('restores latest backup', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ original: true }));
run([
'add-event',
'--event', 'PreToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/gstack',
'--source', 'plan-tune-cathedral',
]);
expect(settings().hooks).toBeDefined();
const r = run(['rollback']);
expect(r.exitCode).toBe(0);
const s = settings();
expect(s.original).toBe(true);
expect(s.hooks).toBeUndefined();
});
test('fails clearly when no backup pointer exists', () => {
const r = run(['rollback']);
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toMatch(/no backup pointer/);
});
});
// ----------------------------------------------------------------------
// list-sources
// ----------------------------------------------------------------------
describe('list-sources', () => {
test('shows source-tagged hooks across all events', () => {
run([
'add-event',
'--event', 'PreToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/pre',
'--source', 'plan-tune-cathedral',
]);
run([
'add-event',
'--event', 'PostToolUse',
'--matcher', 'AskUserQuestion',
'--command', '/post',
'--source', 'plan-tune-cathedral',
]);
const r = run(['list-sources']);
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain('PreToolUse');
expect(r.stdout).toContain('PostToolUse');
expect(r.stdout).toContain('plan-tune-cathedral');
});
test('empty when no settings file', () => {
const r = run(['list-sources']);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/no settings file/);
});
});
// ----------------------------------------------------------------------
// Phantom-hooks heal surface (v1.67.2): KNOWN_HOOKS identity table,
// per-item mutation, prune-stale, mutation lock, fail-closed parse.
//
// Ownership is intrinsic (basename + relpath suffix + event/matcher against
// the fixed table) because Claude Code strips the _gstack_source key when it
// rewrites settings.json — tag-only dedupe is what let every Conductor
// worktree append a fresh dead entry.
// ----------------------------------------------------------------------
const AUQ_MATCHER = '(AskUserQuestion|mcp__.*__AskUserQuestion)';
const HOOK_NAMES = [
'question-log-hook',
'question-preference-hook',
'auq-error-fallback-hook',
'timeline-stop-hook',
];
/** run() with hermetic gstack-config state (prune-stale consults plan_tune_hooks). */
function runIso(args: string[], extraEnv: Record<string, string> = {}) {
try {
const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), {
env: {
...process.env,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: tmpDir,
...extraEnv,
},
encoding: 'utf-8',
timeout: 15000,
});
return { stdout, stderr: '', exitCode: 0 };
} catch (e: any) {
return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 };
}
}
/** A fake stable install with executable hooks, under `base`. */
function mkCanon(base: string, name = 'canon'): string {
const canon = path.join(base, name);
fs.mkdirSync(path.join(canon, 'hosts', 'claude', 'hooks'), { recursive: true });
fs.mkdirSync(path.join(canon, 'bin'), { recursive: true });
for (const h of HOOK_NAMES) {
const p = path.join(canon, 'hosts', 'claude', 'hooks', h);
fs.writeFileSync(p, '#!/bin/sh\n');
fs.chmodSync(p, 0o755);
}
const su = path.join(canon, 'bin', 'gstack-session-update');
fs.writeFileSync(su, '#!/bin/sh\n');
fs.chmodSync(su, 0o755);
return canon;
}
function hookEntry(cmd: string, matcher?: string, src?: string, extraItems: any[] = []) {
const e: any = { hooks: [...extraItems, { type: 'command', command: cmd, timeout: 5 }] };
if (matcher) e.matcher = matcher;
if (src) e._gstack_source = src;
return e;
}
function backups(): string[] {
return fs.readdirSync(tmpDir).filter((f) => f.startsWith('settings.json.bak.'));
}
describe('add-event: per-item identity re-point', () => {
test('tag-stripped stale worktree path is re-pointed in place, tag restored', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { PostToolUse: [hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER)] },
}, null, 2));
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral', '--timeout', '5',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
});
test('foreign path with a gstack basename is NOT claimed (wrong relpath suffix)', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { PostToolUse: [hookEntry('/home/u/myhooks/question-log-hook', AUQ_MATCHER)] },
}, null, 2));
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(2);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/home/u/myhooks/question-log-hook');
});
test('mixed entry: only the gstack item (index > 0) is replaced; the user item survives', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry(
'/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER, undefined,
[{ type: 'command', command: '/Users/me/my-own-hook' }],
)],
},
}, null, 2));
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
const items = s.hooks.PostToolUse[0].hooks;
expect(items).toHaveLength(2);
expect(items[0].command).toBe('/Users/me/my-own-hook');
expect(items[1].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
});
});
describe('legacy remove: per-item (regression)', () => {
test('mixed SessionStart entry: user item survives in place, gstack item removed', () => {
// REGRESSION pin: the pre-v1.67.2 legacy `remove` dropped the ENTIRE
// entry when any item matched gstack-session-update, destroying a user's
// co-located hook. The rewrite filters per-item; this is the only test of
// that branch (team-mode.test.ts covers single-item entries only).
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
SessionStart: [{
hooks: [
{ type: 'command', command: '/Users/me/my-own-session-hook' },
{ type: 'command', command: '/old/install/bin/gstack-session-update' },
],
}],
},
}, null, 2));
const r = runIso(['remove', '/old/install/bin/gstack-session-update']);
expect(r.exitCode).toBe(0);
const s = settings();
expect(s.hooks.SessionStart).toHaveLength(1);
expect(s.hooks.SessionStart[0].hooks).toHaveLength(1);
expect(s.hooks.SessionStart[0].hooks[0].command).toBe('/Users/me/my-own-session-hook');
});
});
describe('review-army hardening (specialist findings)', () => {
test('legacy remove preserves malformed/foreign entries it never touched', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
SessionStart: [
{ comment: 'no hooks array at all' },
{ hooks: 'not-an-array' },
{ hooks: [] },
{ hooks: [{ type: 'command', command: '/x/bin/gstack-session-update' }] },
],
},
}, null, 2));
runIso(['remove', '/x/bin/gstack-session-update']);
const s = settings();
// Only the entry we emptied is gone; the three malformed/foreign ones stay.
expect(s.hooks.SessionStart).toHaveLength(3);
});
test('add-event never tags a mixed entry (old-version ratchet guard)', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [{
matcher: AUQ_MATCHER,
_gstack_source: 'plan-tune-cathedral',
hooks: [
{ type: 'command', command: '/Users/me/my-own-hook' },
{ type: 'command', command: '/dead/wt/hosts/claude/hooks/question-log-hook' },
],
}],
},
}, null, 2));
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
const e = s.hooks.PostToolUse[0];
expect(e.hooks).toHaveLength(2);
expect(e.hooks[0].command).toBe('/Users/me/my-own-hook');
// A tag on a mixed entry hands old-version remove-source permission to
// destroy the user's item — it must be gone.
expect(e._gstack_source).toBeUndefined();
});
test('two dead twins of one hook in ONE entry collapse to a single item after --repoint', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [{
matcher: AUQ_MATCHER,
hooks: [
{ type: 'command', command: '/dead/a/hosts/claude/hooks/question-log-hook' },
{ type: 'command', command: '/dead/b/hosts/claude/hooks/question-log-hook' },
],
}],
},
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const items = settings().hooks.PostToolUse[0].hooks;
expect(items).toHaveLength(1); // pre-fix: two identical items → hook fires twice per event
expect(items[0].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
});
test('a 0600 settings.json keeps its mode across mutations (API keys stay private)', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ env: { SECRET: 'x' } }, null, 2));
fs.chmodSync(settingsFile, 0o600);
runIso(['add-event', '--event', 'Stop', '--command', '/x/hosts/claude/hooks/timeline-stop-hook', '--source', 'gstack-timeline-stop']);
const mode = fs.statSync(settingsFile).mode & 0o777;
expect(mode).toBe(0o600);
});
test('a canonical root containing $ is escaped in the registered command', () => {
const trickyBase = path.join(tmpDir, 'weird$dir');
fs.mkdirSync(trickyBase, { recursive: true });
const canon = mkCanon(trickyBase);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')] },
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const cmd = settings().hooks.Stop[0].hooks[0].command;
expect(cmd.startsWith('"')).toBe(true);
expect(cmd).toContain('\\$'); // $ neutralized — shell must not expand it at hook-fire time
// Idempotent: the escaped command is still recognized as ours.
const before = fs.readFileSync(settingsFile, 'utf-8');
const r2 = runIso(['prune-stale', '--repoint', canon]);
expect(r2.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('backups rotate: at most 10 .bak files survive repeated mutations', () => {
for (let i = 0; i < 13; i++) {
runIso(['add-event', '--event', 'Stop', '--command', `/x/hosts/claude/hooks/timeline-stop-hook-${i}`, '--source', 'gstack-timeline-stop']);
}
expect(backups().length).toBeLessThanOrEqual(10);
// The rollback pointer still resolves to an existing backup.
const latest = fs.readFileSync(path.join(tmpDir, 'settings.json.bak-latest'), 'utf-8').trim();
expect(fs.existsSync(latest)).toBe(true);
});
test('tag-stripped verify-gate entry is table-owned: healed by --repoint, swept by --all', () => {
// Red-team catch: verify-gate is a README-documented opt-in Stop hook.
// Without a KNOWN_HOOKS row, a tag-stripped entry survived uninstall and
// errored at the end of EVERY turn after the install root was deleted.
const canon = mkCanon(tmpDir);
const vg = path.join(canon, 'bin', 'gstack-verify-gate');
fs.writeFileSync(vg, '#!/bin/sh\n');
fs.chmodSync(vg, 0o755);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { Stop: [hookEntry('/dead/install/bin/gstack-verify-gate')] }, // tag STRIPPED
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
let s = settings();
expect(s.hooks.Stop[0].hooks[0].command).toBe(vg);
expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate');
const r = runIso(['prune-stale', '--all']);
expect(r.stdout).toMatch(/removed 1/);
expect(settings().hooks).toBeUndefined();
});
test('a foreign entry that STARTED empty survives prune-stale untouched', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [] }] },
}, null, 2) + '\n');
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = runIso(['prune-stale', '--repoint', mkCanon(tmpDir, 'c2')]);
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('add-event is the quoting authority: spaced canonical path stored escaped-quoted, healer idempotent', () => {
// Red-team catch (empirically verified pre-fix): setup registered raw
// paths and the very next heal rewrote them — fresh installs shipped a
// form the codebase itself considered wrong.
const spacedBase = path.join(tmpDir, 'canon root');
fs.mkdirSync(spacedBase, { recursive: true });
const canon = mkCanon(spacedBase);
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral', '--timeout', '5',
]);
const stored = settings().hooks.PostToolUse[0].hooks[0].command;
expect(stored).toBe(`"${canon}/hosts/claude/hooks/question-log-hook"`);
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = runIso(['prune-stale', '--repoint', canon]);
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('rollback refuses a pointer that names a non-backup file', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ a: 1 }, null, 2));
const evil = path.join(tmpDir, 'evil.json');
fs.writeFileSync(evil, JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: '/evil' }] }] } }));
fs.writeFileSync(path.join(tmpDir, 'settings.json.bak-latest'), evil + '\n');
const r = runIso(['rollback']);
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toMatch(/refusing/);
expect(settings().a).toBe(1);
});
});
describe('ownership negatives', () => {
test('owned basename+relpath under the WRONG matcher stays foreign (not re-pointed)', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', 'Bash')],
},
}, null, 2));
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = runIso(['prune-stale', '--repoint', canon]);
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('prune-stale on an absent settings file exits 0 with removed 0', () => {
const r = runIso(['prune-stale', '--repoint', '/nonexistent-root']);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.existsSync(settingsFile)).toBe(false);
});
});
describe('remove-source: per-item', () => {
test('mixed tagged entry: gstack item removed, user item survives, tag dropped', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry(
'/x/hosts/claude/hooks/question-log-hook', AUQ_MATCHER, 'plan-tune-cathedral',
[{ type: 'command', command: '/Users/me/my-own-hook' }],
)],
},
}, null, 2));
const r = runIso(['remove-source', '--source', 'plan-tune-cathedral']);
expect(r.stdout).toMatch(/removed 1 hook/);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/Users/me/my-own-hook');
expect(s.hooks.PostToolUse[0]._gstack_source).toBeUndefined();
});
});
describe('Memorable UserPromptSubmit hook ownership', () => {
const source = 'gstack-memorable';
const stale = '/old/worktree/hosts/claude/hooks/memorable-user-prompt-hook';
const canonical = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook';
const foreign = '/Users/me/my-user-prompt-hook';
test('ensure-event is idempotent once the canonical wrapper is registered', () => {
const args = [
'ensure-event', '--event', 'UserPromptSubmit',
'--command', canonical, '--source', source,
];
const first = runIso(args);
expect(first.exitCode).toBe(0);
expect(first.stdout).toContain('hook registered');
const afterFirst = fs.readFileSync(settingsFile, 'utf-8');
const backupsAfterFirst = backups();
const second = runIso(args);
expect(second.exitCode).toBe(0);
expect(second.stdout).toContain('hook unchanged');
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(afterFirst);
expect(backups()).toEqual(backupsAfterFirst);
expect(settings().hooks.UserPromptSubmit).toHaveLength(1);
});
test('ensure-event re-points only the wrapper in a mixed entry and preserves the foreign hook', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
UserPromptSubmit: [{
hooks: [
{ type: 'command', command: foreign },
{ type: 'command', command: stale },
],
}],
},
}, null, 2));
const r = runIso([
'ensure-event', '--event', 'UserPromptSubmit',
'--command', canonical, '--source', source,
]);
expect(r.exitCode).toBe(0);
const entries = settings().hooks.UserPromptSubmit;
expect(entries).toHaveLength(1);
expect(entries[0].hooks).toEqual([
{ type: 'command', command: foreign },
{ type: 'command', command: canonical },
]);
expect(entries[0]._gstack_source).toBeUndefined();
});
test('remove-source removes only the Memorable wrapper from a tagged mixed entry', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
UserPromptSubmit: [{
_gstack_source: source,
hooks: [
{ type: 'command', command: foreign },
{ type: 'command', command: stale },
],
}],
},
}, null, 2));
const r = runIso(['remove-source', '--source', source]);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 1 hook/);
const entries = settings().hooks.UserPromptSubmit;
expect(entries).toHaveLength(1);
expect(entries[0].hooks).toEqual([{ type: 'command', command: foreign }]);
expect(entries[0]._gstack_source).toBeUndefined();
});
});
describe('remove-source: identity-aware (tag OR table)', () => {
// Claude Code strips _gstack_source when it rewrites settings.json. A
// tag-only remove-source therefore no-ops on exactly the entries it was
// written for (the PR #2831 disable bug). Identity via KNOWN_HOOKS now
// drives removal; the tag is metadata.
const memo = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook';
const foreign = '/Users/me/my-user-prompt-hook';
const vendor = '"/Users/me/.memorable/bin/memorable" hook user-prompt';
test('removes an UNTAGGED single-item memorable entry by identity', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: memo, timeout: 5 }] }] },
}, null, 2));
const r = run(['remove-source', '--source', 'gstack-memorable']);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 1 /);
expect(settings().hooks).toBeUndefined();
});
test('untagged mixed entry: only the memorable item goes, the foreign item stays, no tag is added', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { UserPromptSubmit: [{ hooks: [
{ type: 'command', command: foreign },
{ type: 'command', command: memo },
] }] },
}, null, 2));
const r = run(['remove-source', '--source', 'gstack-memorable']);
expect(r.stdout).toMatch(/removed 1 /);
const entries = settings().hooks.UserPromptSubmit;
expect(entries).toHaveLength(1);
expect(entries[0].hooks).toEqual([{ type: 'command', command: foreign }]);
expect(entries[0]._gstack_source).toBeUndefined();
});
test('the bash-prefixed, quoted (Windows) form is recognised and removed', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `bash "${memo}"` }] }] },
}, null, 2));
const r = run(['remove-source', '--source', 'gstack-memorable']);
expect(r.stdout).toMatch(/removed 1 /);
expect(settings().hooks).toBeUndefined();
});
test('CRITICAL regression: identity is per source -- another source\'s tag-stripped item is never touched', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }],
PostToolUse: [{ matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/question-log-hook' }] }],
},
}, null, 2));
const r = run(['remove-source', '--source', 'plan-tune-cathedral']);
expect(r.stdout).toMatch(/removed 1 /); // its own tag-stripped question-log item
const s = settings();
expect(s.hooks.Stop).toHaveLength(1); // timeline (gstack-timeline-stop) untouched
expect(s.hooks.PostToolUse).toBeUndefined();
});
test('a tagged entry of source A holding an item of source B keeps B\'s item and its tag (nothing of A inside)', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { Stop: [{ _gstack_source: 'plan-tune-cathedral', hooks: [
{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' },
] }] },
}, null, 2));
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = run(['remove-source', '--source', 'plan-tune-cathedral']);
expect(r.stdout).toMatch(/removed 0 /);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('a foreign-only entry is untouched byte for byte and no backup is written', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: foreign }] }, { hooks: [{ type: 'command', command: vendor }] }] },
}, null, 2));
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = run(['remove-source', '--source', 'gstack-memorable']);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 0 /);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
expect(backups()).toEqual([]);
});
test('setup --no-team sweep: GSTACK_SWEEP_EXCLUDE_SOURCES keeps verify-gate AND gstack-memorable (tagged or tag-stripped), sweeps timeline', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [
{ _gstack_source: 'verify-gate', hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] },
{ _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
],
UserPromptSubmit: [
{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command', command: memo }] },
{ hooks: [{ type: 'command', command: `bash "${memo}"` }] }, // tag stripped by Claude Code
],
},
}, null, 2));
const r = runIso(['prune-stale', '--all'], { GSTACK_SWEEP_EXCLUDE_SOURCES: 'verify-gate,gstack-memorable' });
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 1 /);
const s = settings();
expect(s.hooks.Stop).toHaveLength(1);
expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate');
expect(s.hooks.UserPromptSubmit).toHaveLength(2);
// and WITHOUT the exclusion (uninstall) the memorable items go too
const r2 = runIso(['prune-stale', '--all']);
expect(r2.stdout).toMatch(/removed 3 /);
expect(settings().hooks).toBeUndefined();
});
test('a tagged legacy stray (single item, no table row) is still removed', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { UserPromptSubmit: [{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command', command: '/legacy/anything' }] }] },
}, null, 2));
const r = run(['remove-source', '--source', 'gstack-memorable']);
expect(r.stdout).toMatch(/removed 1 /);
expect(settings().hooks).toBeUndefined();
});
});
describe('remove-source: identity removal holds for EVERY KNOWN_HOOKS source (regression)', () => {
// The semantics change applies to all six rows, but setup's --no-team path
// and uninstall lean on four sources this file never exercised behaviourally.
const seedAll = () => fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
{ hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] },
{ hooks: [{ type: 'command', command: '/Users/me/my-stop-hook' }] },
],
PostToolUse: [
{ matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/auq-error-fallback-hook' }] },
{ matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/question-log-hook' }] },
],
SessionStart: [
{ hooks: [{ type: 'command', command: '/x/bin/gstack-session-update' }] },
{ hooks: [{ type: 'command', command: '/Users/me/my-session-hook' }] },
],
},
}, null, 2));
const allCommands = () => {
const h = settings().hooks ?? {};
return Object.values(h).flatMap((entries: any) => entries.flatMap((e: any) => e.hooks.map((i: any) => i.command))).sort();
};
for (const [source, own] of [
['gstack-timeline-stop', '/x/hosts/claude/hooks/timeline-stop-hook'],
['verify-gate', '/x/bin/gstack-verify-gate'],
['auq-error-fallback', '/x/hosts/claude/hooks/auq-error-fallback-hook'],
['gstack-session-update', '/x/bin/gstack-session-update'],
['plan-tune-cathedral', '/x/hosts/claude/hooks/question-log-hook'],
] as const) {
test(`remove-source --source ${source} removes exactly its own UNTAGGED item and nothing else`, () => {
seedAll();
const before = allCommands();
const r = run(['remove-source', '--source', source]);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 1 /);
expect(allCommands()).toEqual(before.filter((c) => c !== own));
});
}
test('a non-array hooks.<event> value is never touched (foreign shape), exit 0', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: { weird: true }, Stop: [{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }] } }, null, 2));
const r = run(['remove-source', '--source', 'gstack-memorable']);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 0 /);
expect(settings().hooks.UserPromptSubmit).toEqual({ weird: true });
});
test('a tagged entry holding only a command-less item, and a tagged multi-item entry with no table rows, are kept with their tags', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: [
{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command' }] },
{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command', command: '/a/foreign' }, { type: 'command', command: '/b/foreign' }] },
] } }, null, 2));
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = run(['remove-source', '--source', 'gstack-memorable']);
expect(r.stdout).toMatch(/removed 0 /);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
});
describe('list-items: read-only identity view', () => {
const memo = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook';
const foreign = '/Users/me/my-user-prompt-hook';
const vendor = '"/Users/me/.memorable/bin/memorable" hook user-prompt';
const weird = '/tab\tand\nnewline/hook';
const seed = () => fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { UserPromptSubmit: [
{ hooks: [{ type: 'command', command: foreign }, { type: 'command', command: memo }] },
{ hooks: [{ type: 'command', command: vendor }] },
{ hooks: [{ type: 'command', command: weird }] },
] },
}, null, 2));
test('--owned-by prints only the table-identified item, as a JSON string literal, tag or no tag', () => {
seed();
const r = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable']);
expect(r.exitCode).toBe(0);
expect(r.stdout.trim().split('\n')).toEqual([JSON.stringify(memo)]);
});
test('--command-regex is a JavaScript RegExp applied only to items no table row owns', () => {
seed();
const r = run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', '[Mm]emorable.*hook\\s+user-prompt']);
expect(r.stdout.trim().split('\n')).toEqual([JSON.stringify(vendor)]);
});
test('every line is one JSON literal: tabs and newlines inside a command cannot split it', () => {
seed();
const r = run(['list-items', '--event', 'UserPromptSubmit']);
const lines = r.stdout.trim().split('\n');
expect(lines).toHaveLength(4);
expect(lines.map((l) => JSON.parse(l))).toEqual([foreign, memo, vendor, weird]);
});
test('no matches, an unknown event, or no settings file -> empty stdout, exit 0', () => {
seed();
expect(run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'verify-gate'])).toMatchObject({ exitCode: 0, stdout: '' });
expect(run(['list-items', '--event', 'Notification'])).toMatchObject({ exitCode: 0, stdout: '' });
fs.rmSync(settingsFile);
expect(run(['list-items', '--event', 'UserPromptSubmit'])).toMatchObject({ exitCode: 0, stdout: '' });
});
test('an unknown flag exits 1; --owned-by combined with --command-regex intersects (a regex never widens a selection)', () => {
seed();
expect(run(['list-items', '--event', 'UserPromptSubmit', '--bogus', 'x']).exitCode).toBe(1);
const both = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable', '--command-regex', 'memorable-user-prompt-hook$']);
expect(both.stdout.trim().split('\n')).toEqual([JSON.stringify(memo)]);
const none = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable', '--command-regex', 'no-such-thing']);
expect(none).toMatchObject({ exitCode: 0, stdout: '' });
const vendorOnly = run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', 'memorable']);
expect(vendorOnly.stdout.trim().split('\n')).toEqual([JSON.stringify(vendor)]); // regex alone still excludes owned items
});
test('exit codes mirror the mutating verbs: 1 usage, 3 unparseable, 4 unexpected shape', () => {
seed();
expect(run(['list-items']).exitCode).toBe(1);
expect(run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', '(']).exitCode).toBe(1);
fs.writeFileSync(settingsFile, '{bad json');
expect(run(['list-items', '--event', 'UserPromptSubmit']).exitCode).toBe(3);
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: {} } }));
const r = run(['list-items', '--event', 'UserPromptSubmit']);
expect(r.exitCode).toBe(4);
expect(r.stderr).toContain('not an array');
});
});
describe('prune-stale', () => {
test('prunes dead gstack items; keeps live gstack and dead non-gstack', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER), // live gstack
hookEntry('/dead/wt/hosts/claude/hooks/auq-error-fallback-hook', AUQ_MATCHER), // dead gstack
hookEntry('/dead/user/own-hook', AUQ_MATCHER), // dead NON-gstack
],
},
}, null, 2));
const r = runIso(['prune-stale']);
expect(r.stdout).toMatch(/removed 1 gstack hook entries/);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(2);
const cmds = s.hooks.PostToolUse.map((e: any) => e.hooks[0].command);
expect(cmds).toContain(`${canon}/hosts/claude/hooks/question-log-hook`);
expect(cmds).toContain('/dead/user/own-hook');
});
test('no-op run writes no backup and leaves the file byte-identical', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { PreToolUse: [hookEntry('/Users/me/my-own-hook', 'Bash')] },
}, null, 2) + '\n');
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = runIso(['prune-stale']);
expect(r.exitCode).toBe(0);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
expect(backups()).toHaveLength(0);
expect(fs.existsSync(path.join(tmpDir, 'settings.json.bak-latest'))).toBe(false);
});
test('--repoint re-points dead AND live items, preserves bash prefix, restores tags', () => {
const canon = mkCanon(tmpDir);
const live = mkCanon(tmpDir, 'live-worktree');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [hookEntry(`${live}/hosts/claude/hooks/timeline-stop-hook`)], // LIVE but ephemeral
PostToolUse: [hookEntry('bash /dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER)],
},
}, null, 2));
const r = runIso(['prune-stale', '--repoint', canon]);
expect(r.stdout).toMatch(/repointed 2/);
const s = settings();
expect(s.hooks.Stop[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/timeline-stop-hook`);
expect(s.hooks.Stop[0]._gstack_source).toBe('gstack-timeline-stop');
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`bash ${canon}/hosts/claude/hooks/question-log-hook`);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
});
test('--repoint collapses exact duplicates preferring the tagged twin', () => {
const canon = mkCanon(tmpDir);
const cmd = `${canon}/hosts/claude/hooks/question-log-hook`;
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
hookEntry('/dead/a/hosts/claude/hooks/question-log-hook', AUQ_MATCHER),
hookEntry(cmd, AUQ_MATCHER, 'plan-tune-cathedral'),
],
},
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(cmd);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
});
test('--repoint never ADDS entries (repair, not registration)', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({ theme: 'dark' }, null, 2) + '\n');
const before = fs.readFileSync(settingsFile, 'utf-8');
runIso(['prune-stale', '--repoint', canon]);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('Windows backslash path is classified as gstack-owned and pruned when dead', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry('C:\\dead\\wt\\hosts\\claude\\hooks\\question-log-hook', AUQ_MATCHER)],
},
}, null, 2));
const r = runIso(['prune-stale']);
expect(r.stdout).toMatch(/removed 1/);
expect(settings().hooks).toBeUndefined();
});
test('spaced canonical root produces a quoted command that stays owned (idempotent)', () => {
const spacedBase = path.join(tmpDir, 'My Claude');
fs.mkdirSync(spacedBase, { recursive: true });
const canon = mkCanon(spacedBase);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')] },
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const s = settings();
expect(s.hooks.Stop[0].hooks[0].command).toBe(`"${canon}/hosts/claude/hooks/timeline-stop-hook"`);
// Second run: the quoted command is still recognized as ours — no churn.
const before = fs.readFileSync(settingsFile, 'utf-8');
const r2 = runIso(['prune-stale', '--repoint', canon]);
expect(r2.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('--all removes live untagged gstack items, spares user hooks and mixed-entry user items', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER)],
Stop: [hookEntry(
`${canon}/hosts/claude/hooks/timeline-stop-hook`, undefined, 'gstack-timeline-stop',
[{ type: 'command', command: '/Users/me/custom-stop-hook' }],
)],
PreCompact: [hookEntry('/Users/me/my-own-hook')],
},
}, null, 2));
const r = runIso(['prune-stale', '--all']);
expect(r.stdout).toMatch(/removed 2/);
const s = settings();
expect(s.hooks.PostToolUse).toBeUndefined();
expect(s.hooks.Stop[0].hooks).toHaveLength(1);
expect(s.hooks.Stop[0].hooks[0].command).toBe('/Users/me/custom-stop-hook');
expect(s.hooks.Stop[0]._gstack_source).toBeUndefined();
expect(s.hooks.PreCompact[0].hooks[0].command).toBe('/Users/me/my-own-hook');
});
test('--all removes tagged single-item legacy strays (no table match)', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { Stop: [hookEntry('/old/install/bin/gstack-verify-gate', undefined, 'gstack-verify-gate')] },
}, null, 2));
const r = runIso(['prune-stale', '--all']);
expect(r.stdout).toMatch(/removed 1/);
expect(settings().hooks).toBeUndefined();
});
test('--all and --repoint are mutually exclusive', () => {
const r = runIso(['prune-stale', '--all', '--repoint', '/x']);
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toMatch(/mutually exclusive/);
});
test('explicit plan_tune_hooks:no — dead plan-tune pruned, live plan-tune NOT re-pointed, Stop still re-pointed', () => {
const canon = mkCanon(tmpDir);
const live = mkCanon(tmpDir, 'live-worktree');
execSync(`'${path.join(ROOT, 'bin', 'gstack-config')}' set plan_tune_hooks no`, {
env: { ...process.env, GSTACK_STATE_ROOT: tmpDir },
timeout: 30_000,
});
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER), // dead plan-tune
hookEntry(`${live}/hosts/claude/hooks/auq-error-fallback-hook`, AUQ_MATCHER), // LIVE plan-tune
],
Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')],
},
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
// Live plan-tune hook left exactly where it was (no re-activation without consent).
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`${live}/hosts/claude/hooks/auq-error-fallback-hook`);
// Stop hook is not part of the opt-out — re-pointed to canonical.
expect(s.hooks.Stop[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/timeline-stop-hook`);
});
test('incident facsimile: the exact live-damage shape heals to canonical', () => {
// Replays the 2026-08-17 production state: 6 PostToolUse / 3 PreToolUse /
// 2 Stop entries; 6 dead (deleted worktrees), tags stripped on some, one
// live-but-ephemeral Stop hook, plus a user hook that must survive.
const canon = mkCanon(tmpDir);
const cebu = mkCanon(tmpDir, 'cebu-v4');
const dead = (n: string) => `/dead/biarritz-v3/hosts/claude/hooks/${n}`;
const dead2 = (n: string) => `/dead/taipei-v2/hosts/claude/hooks/${n}`;
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
SessionStart: [hookEntry(`${canon}/bin/gstack-session-update`)],
PostToolUse: [
hookEntry(`${canon}/hosts/claude/hooks/auq-error-fallback-hook`, AUQ_MATCHER),
hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER),
hookEntry(dead('question-log-hook'), AUQ_MATCHER),
hookEntry(dead('auq-error-fallback-hook'), AUQ_MATCHER),
hookEntry(dead2('question-log-hook'), AUQ_MATCHER, 'plan-tune-cathedral'),
hookEntry(dead2('auq-error-fallback-hook'), AUQ_MATCHER, 'auq-error-fallback'),
],
PreToolUse: [
hookEntry(`${canon}/hosts/claude/hooks/question-preference-hook`, AUQ_MATCHER),
hookEntry(dead('question-preference-hook'), AUQ_MATCHER),
hookEntry(dead2('question-preference-hook'), AUQ_MATCHER, 'plan-tune-cathedral'),
],
Stop: [
hookEntry(`${cebu}/hosts/claude/hooks/timeline-stop-hook`),
hookEntry(dead2('timeline-stop-hook'), undefined, 'gstack-timeline-stop'),
],
PreCompact: [hookEntry('/Users/me/my-own-hook')],
},
}, null, 2));
const r = runIso(['prune-stale', '--repoint', canon]);
expect(r.exitCode).toBe(0);
const s = settings();
expect(s.hooks.SessionStart).toHaveLength(1);
expect(s.hooks.PostToolUse).toHaveLength(2);
expect(s.hooks.PreToolUse).toHaveLength(1);
expect(s.hooks.Stop).toHaveLength(1);
expect(s.hooks.PreCompact[0].hooks[0].command).toBe('/Users/me/my-own-hook');
for (const ev of ['SessionStart', 'PostToolUse', 'PreToolUse', 'Stop']) {
for (const e of s.hooks[ev]) {
expect(e._gstack_source).toBeDefined();
for (const it of e.hooks) expect(it.command.startsWith(canon)).toBe(true);
}
}
const postSources = s.hooks.PostToolUse.map((e: any) => e._gstack_source).sort();
expect(postSources).toEqual(['auq-error-fallback', 'plan-tune-cathedral']);
});
});
describe('fail-closed parse (pre-existing data-loss fix)', () => {
const MUTATORS: string[][] = [
['add', '/x/bin/gstack-session-update'],
['remove', '/x/bin/gstack-session-update'],
['add-event', '--event', 'Stop', '--command', '/x', '--source', 's'],
['remove-source', '--source', 'plan-tune-cathedral'],
['prune-stale'],
];
test('every mutator refuses to touch a corrupt settings.json', () => {
for (const args of MUTATORS) {
fs.writeFileSync(settingsFile, '{definitely not json');
const r = runIso(args);
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toMatch(/refusing to mutate/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe('{definitely not json');
}
});
});
describe('mutation lock', () => {
test('stale lock (old mtime) is taken over; mutation proceeds', () => {
const lockDir = `${settingsFile}.lock`;
fs.mkdirSync(lockDir);
fs.writeFileSync(path.join(lockDir, 'owner'), 'dead-process');
const old = new Date(Date.now() - 120_000);
fs.utimesSync(lockDir, old, old);
const r = runIso(['add-event', '--event', 'Stop', '--command', '/x/hosts/claude/hooks/timeline-stop-hook', '--source', 'gstack-timeline-stop']);
expect(r.exitCode).toBe(0);
expect(settings().hooks.Stop).toHaveLength(1);
expect(fs.existsSync(lockDir)).toBe(false); // released after the mutation
});
test('fresh foreign lock: mutation skipped loudly (exit 5), file untouched', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ theme: 'dark' }, null, 2) + '\n');
const before = fs.readFileSync(settingsFile, 'utf-8');
const lockDir = `${settingsFile}.lock`;
fs.mkdirSync(lockDir);
fs.writeFileSync(path.join(lockDir, 'owner'), 'another-live-process');
const r = runIso(
['add-event', '--event', 'Stop', '--command', '/x', '--source', 's'],
{ GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '300' },
);
expect(r.exitCode).toBe(5); // loud give-up, not silent skip
expect(r.stderr).toMatch(/could not acquire lock/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
expect(fs.existsSync(lockDir)).toBe(true); // foreign lock NOT stolen
});
test('two concurrent add-events both land (lock serializes; file stays valid JSON)', () => {
const q = (args: string[]) =>
[SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' ');
const a = q(['add-event', '--event', 'PreToolUse', '--matcher', AUQ_MATCHER, '--command', '/pre-hook', '--source', 'src-a']);
const b = q(['add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER, '--command', '/post-hook', '--source', 'src-b']);
execSync(`sh -c "${a} & ${b} & wait"`, {
env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile, GSTACK_STATE_ROOT: tmpDir },
encoding: 'utf-8',
timeout: 20000,
});
const s = settings(); // throws if the file is corrupt
expect(s.hooks.PreToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse).toHaveLength(1);
});
});
describe('gstack-settings-hook adversarial hardening', () => {
let tmpDir: string;
let settingsFile: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-settings-adv-'));
settingsFile = path.join(tmpDir, 'settings.json');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function runAdv(args: string[], extraEnv: Record<string, string> = {}) {
try {
const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), {
env: {
...process.env,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: tmpDir,
...extraEnv,
},
encoding: 'utf-8',
timeout: 15000,
});
return { stdout, stderr: '', exitCode: 0 };
} catch (e: any) {
return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 };
}
}
const advSettings = (): any => JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
test('wrong-shape hooks value fails LOUD (exit 4), file untouched', () => {
// bun -e swallows uncaught exceptions after a require() and exits 0
// (verified on bun 1.3.13) -- without the gsMain umbrella this exact
// input produced a silent exit-0 no-op that reported clean.
const raw = JSON.stringify({ hooks: { Stop: { bogus: 'shape' } } }, null, 2) + '\n';
fs.writeFileSync(settingsFile, raw);
const r = runAdv(['prune-stale', '--all']);
expect(r.exitCode).toBe(4);
expect(r.stderr).toMatch(/internal error/);
expect(r.stderr).toMatch(/refusing to mutate/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(raw);
});
test('foreign hook whose basename collides with Object.prototype survives --all', () => {
// KNOWN_HOOKS["toString"] returns an inherited member without the
// hasOwnProperty guard -- pre-fix, this threw mid-scan and turned the
// sweep into a silent no-op.
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: '/usr/local/bin/toString' }] },
{ _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
],
},
}, null, 2) + '\n');
const r = runAdv(['prune-stale', '--all']);
expect(r.exitCode).toBe(0);
const s = advSettings();
expect(s.hooks.Stop).toHaveLength(1); // gstack entry swept...
expect(s.hooks.Stop[0].hooks[0].command).toBe('/usr/local/bin/toString'); // ...foreign one kept
});
test('GSTACK_SWEEP_EXCLUDE_SOURCES preserves verify-gate during an --all sweep', () => {
// `setup --no-team` sweeps team hooks but must not delete the
// user-registered verify-gate opt-in whose binary still exists.
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [
{ _gstack_source: 'verify-gate', hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] },
{ _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
],
},
}, null, 2) + '\n');
const r = runAdv(['prune-stale', '--all'], { GSTACK_SWEEP_EXCLUDE_SOURCES: 'verify-gate' });
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 1 /);
const s = advSettings();
expect(s.hooks.Stop).toHaveLength(1);
expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate');
});
});