v1.68.1.0 fix: phantom AskUserQuestion hooks — canonical-only registration + self-healing settings.json (#2631)

* fix(settings-hook): KNOWN_HOOKS identity healer — per-item ownership, mutation lock, fail-closed parse

Claude Code strips the unknown _gstack_source key when it rewrites
settings.json, so tag-based dedupe degraded to exact-command equality and
every Conductor worktree's setup appended a fresh hook entry; deleted
worktrees left dead hooks erroring on every AskUserQuestion fire.

- KNOWN_HOOKS identity table (shared JS prelude, single source of truth):
  ownership is intrinsic and PER HOOK ITEM — basename + relpath suffix +
  event (+ matcher where defined). Tags never claim foreign items.
- New `prune-stale [--repoint <root>] [--all]`: prune dead gstack items,
  re-point survivors at the stable install (tag restore from the table),
  exact-duplicate collapse, uninstall/no-team identity sweep. Explicit
  plan_tune_hooks:no is honored (dead pruned, live never re-pointed).
- add-event / remove-source become item-aware: replace/remove only the owned
  item; a user's co-located hook in the same entry is never collateral.
- Mutation safety: mkdir lock with owner token, ownership-checked release,
  atomic stale takeover; per-process-unique tmp + backup names;
  backup-on-change everywhere; fail-closed on parse failure (a corrupt
  settings.json is never overwritten — previously catch{} clobbered it);
  locked atomic rollback.

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

* feat(gstack-config): `has <key>` — key-presence provenance through STATE_DIR resolution

`get` returns the DEFAULTS value for absent keys, so callers that need to
know whether the USER decided something (vs inherited a default) had no
correct primitive — setup's consent logic was about to grep a hardcoded
~/.gstack/config.yaml, which misclassifies under GSTACK_STATE_ROOT /
GSTACK_HOME / GSTACK_STATE_DIR overrides. `has` exits 0 iff the key is
literally present in the resolved config file, with the same C-locale key
validation as get/set.

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

* fix(setup): canonical-only hook registration, heal-first, PT_EXPLICIT consent provenance

Three root causes of the phantom-AskUserQuestion-hooks class, all in the
registration path:

- Bug A: the Conductor auto-opt-in upgraded PT_DECISION "prompt" -> "yes"
  even when "prompt" was dev-setup's EXPLICIT --plan-tune-hooks=prompt pin,
  so every new Conductor workspace installed hooks. PT_EXPLICIT (flag/env/
  config-key-presence via `gstack-config has`) now gates the auto-opt-in to
  the true silent fall-through.
- Bug B: hook commands were baked from $SOURCE_GSTACK_DIR (`pwd -P` of the
  running tree — ephemeral for worktrees). Registration is now CANONICAL-ONLY
  via _hook_command_path (${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack);
  missing canonical hook = skip + log, never a baked tree path. SessionStart
  moves to schema-aware add-event under its identity source; whitespace paths
  are quoted.
- Bug C: nothing ever pruned, and dead tagged entries blocked the
  "already installed" guards forever. Setup now heals FIRST on every run
  (prune-stale --repoint at the stable install), surfaces a one-line summary
  only when something changed, surfaces the plan_tune_hooks:no-vs-live-hooks
  contradiction, and --no-team tears down all three sources plus an identity
  sweep for untagged strays.

dev-setup's no-mutation guarantee gains its stated repair exception (prune
dead / re-point existing, never ADD).

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

* fix(uninstall): run hook cleanup BEFORE install-root deletion + full identity sweep

SETTINGS_HOOK resolves via $(dirname "$0") INSIDE the install root, but the
cleanup ran after `rm -rf ~/.claude/skills/gstack` — a real global uninstall
(running the installed copy) silently no-op'd and orphaned every hook entry.
Tests masked it by running the uninstaller from the repo checkout.

The relocated block also removes the auq-error-fallback source (registered by
setup, previously never torn down) and finishes with a prune-stale --all
identity sweep so untagged strays (Claude Code strips _gstack_source) go too.

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

* test: phantom-hooks heal coverage — incident facsimile, per-item safety, lock, canonical tripwires

- gstack-settings-hook-schema-aware: 16 new cases — identity re-point (tag
  restore), foreign-basename rejection, mixed-entry per-item safety for
  add-event/remove-source/--all, prune-stale modes incl. bash-prefix +
  Windows-backslash + spaced-path idempotence, duplicate collapse preferring
  the tagged twin, plan_tune_hooks:no split, backup-on-change no-churn,
  fail-closed corrupt-JSON for every mutator, stale-lock takeover,
  fresh-foreign-lock skip, two-writer concurrency smoke, and an INCIDENT
  FACSIMILE replaying the exact 2026-08-17 production damage (6/3/2 entries,
  mixed tags, live-ephemeral Stop) healing to 2/1/1 canonical.
- NEW setup-hook-canonical-paths: static tripwires — canonical-only resolver
  (no $SOURCE_GSTACK_DIR anywhere in it), heal-before-guards ordering,
  unsuppressed heal output, ${VAR:-0} counter idiom, shared-prelude
  concatenation at every bun call site, KNOWN_HOOKS completeness vs setup's
  registrations, uninstall cleanup-before-deletion ordering, defect-class
  warning present.
- setup-plan-tune-hooks-noninteractive: PT_EXPLICIT pins + `gstack-config
  has` provenance + has-subcommand behavior (env-resolution, malformed keys).
- auq-error-fallback-hook: registration + both-teardown wiring (previously
  untested).
- uninstall: behavioral ordering test running the INSTALLED copy from inside
  the root it deletes.
- setup-windows-fallback / gstack-config-key-locale: pins updated for the new
  HOOK_CMD shape and the third C-locale validator.

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

* fix(test): banner-tripwire exec used JSON.stringify as shell quoting — vacuous pass + stray artifact

JSON escaping is not shell escaping. Interpolating JSON.stringify(script)
into `bash -c ${...}` left every JSON "\n" as a literal backslash-n inside
shell double quotes, collapsing the extracted release-body tripwire block
onto one line: `then\n` parsed as the command word `thenn`, and
`>&2\nelse\n` parsed as the redirect `>&2nelsen` — so every full-suite run
littered a `2nelsen` file (containing "bash: thenn: command not found") in
the repo root, and the test's single not-contains assertion passed
VACUOUSLY because all output had been redirected into that file. The
"and it actually fires" functional check never verified anything.

Fix: pass the script as an argv element (spawnSync array form) and assert
both branches for real — ABORT case must print the leak message to stderr,
clean case must print "banner tripwire clean" to stdout.

Verified: `bun test test/binding-template-drift.test.ts` previously created
the artifact deterministically; the full free suite now runs artifact-free.
The other shell-interpolation sites (evidence, schema-aware concurrency,
empty-find-fallthrough, branch-slug-hygiene) already use correct quoting.

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

* test: regression pin for legacy remove mixed-entry filtering + ownership negatives

Coverage-audit iron rule: the rewritten legacy `remove` action filters
per-item (pre-v1.67.2 it dropped the whole entry, destroying a user's
co-located SessionStart hook) — modified existing behavior, previously
untested. Also pins two ownership negatives: an owned basename+relpath under
the WRONG matcher stays foreign, and prune-stale on an absent settings file
exits 0 with removed 0.

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

* fix: pre-landing review fixes — review-army findings hardened

Specialist review (testing, maintainability, security, performance,
data-migration) findings, each verified against code before fixing:

- legacy remove: preserve malformed/foreign entries (hooks absent, non-array,
  or pre-existing empty) — only entries THIS pass emptied are dropped
- add-event: never tag a mixed entry (old gstack versions in sibling
  worktrees treat tags as entry-level ownership and would destroy the user's
  co-located items); tag only single-item entries; prune-stale drops tags
  from mixed entries for the same reason
- prune-stale: within-entry twin collapse (two dead copies of one hook
  re-pointed to the same canonical command no longer double-fire); command
  quoting hardened via gsQuoteCmd (escapes \\ " $ backtick; gsStripWrap
  unescapes so identity round-trips); NUL bytes in the dedupe key replaced
  with a JSON.stringify key (bash silently dropped the NULs, degrading the
  separator; the file also read as binary to tooling)
- gsIsAlive: only provable absence (ENOENT/ENOTDIR) counts as dead —
  EACCES/EIO/unmounted volumes no longer prune (one-way-ratchet guard)
- gsWriteIfChanged: preserves the live settings.json mode across rewrites
  (a user-tightened 0600 carrying API keys was silently broadened to 0644);
  fresh files start 0600; backups rotate (keep 10)
- remove-source: command-less items default to foreign (gstack only writes
  type:command items); single-item stray claim requires a command
- rollback: pointer target must be a sibling settings.json.bak.* file
- uninstall + setup --no-team + SessionStart registration: stderr stays
  attached — a lock give-up or fail-closed parse during TEARDOWN must be
  visible ("the next setup retries" does not apply after uninstall)
- setup: team-mode banner no longer claims an auto-update hook when
  registration was skipped; heal log documents the rollback-pointer caveat;
  SESSION_UPDATE_CMD quoting mirrors gsQuoteCmd; lock constants named
- list-sources: corrupt settings.json reports to stderr instead of silently
  printing nothing (setup guards must not misread corrupt as no-hooks)
- tests: 10 new pins (malformed-entry preservation, mixed no-tag, twin
  collapse, 0600 mode, metachar escaping round-trip, backup rotation,
  rollback pointer refusal, held-lock uninstall warning, matcher-drift
  tripwire, ownership negatives)

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

* fix: red-team findings — verify-gate identity, single quoting authority, Windows paths

Red-team pass over the hardened diff (several findings empirically verified
by the reviewer before reporting):

- KNOWN_HOOKS gains the sixth identity: gstack-verify-gate (README-documented
  opt-in Stop hook). A tag-stripped verify-gate entry previously survived
  prune-stale --all and errored at the end of EVERY turn after uninstall
  deleted the install root — the exact phantom-hook class this branch fixes.
  Uninstall also sweeps its tagged form.
- add-event is now the single quoting authority: every registered command is
  normalized through the same gsQuoteCmd/gsStripWrap round-trip the healer
  uses. Pre-fix, only SessionStart got caller-side quoting — a spaced/metachar
  canonical root registered broken plan-tune/AUQ/timeline hooks that the very
  next heal rewrote (the codebase disagreed with its own registrations).
- Windows: MSYS-form paths (/c/Users/...) are drive-translated for fs checks
  only (gsWinPath) — native bun resolved them drive-relative, so the heal
  judged every LIVE Windows hook dead and pruned it. The three AskUserQuestion
  hooks and the Stop hook now also get the mandatory 'bash ' prefix on
  Windows (previously only SessionStart did; extensionless bash shims
  otherwise hit the file-association dialog).
- CANONICAL_GSTACK_ROOT falls back to $HOME/.claude/skills/gstack when a
  CLAUDE_CONFIG_DIR-derived root was never installed (the installer hardcodes
  the home path — split-brain left such users permanently hookless).
- prune-stale preserves foreign entries that STARTED empty (they were
  silently deleted, uncounted, on every heal).
- The timeline Stop registration and its list-sources guard join the
  zero-silent-mutations contract (stderr attached).

Tests: verify-gate tag-stripped heal+sweep, started-empty preservation,
add-event quoting-authority round-trip.

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

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

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

* docs: update project documentation for v1.68.1.0

README: document canonical-only hook registration + the prune-stale
self-heal in the setup hooks section; expand the manual-uninstall note
to cover every gstack hook identity, not just timeline-stop-hook.
CONTRIBUTING: record PT_EXPLICIT provenance (Conductor auto-opt-in
fires only on the true silent fall-through) and the heal-first repair
exception in the dev-setup paragraph.

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

* fix(settings-hook): fail-loud hardening — gsMain umbrella, lock exit 5, prototype-safe ownership

bun in -e mode swallows uncaught exceptions thrown after a require() and
exits 0 (verified on 1.3.13; uncaughtException handlers never fire either),
so any runtime throw in a mutator was a SILENT SUCCESS. Every script body
now runs inside a gsMain try/catch that prints "internal error ... refusing
to mutate" and exits 4.

Also: lock give-up now exits 5 instead of 0 (callers must not report a
skipped mutation as registered); basename lookup uses hasOwnProperty so a
foreign hook named "toString"/"constructor" can't resolve to an inherited
Object.prototype member and abort the sweep; ownership-checked release also
clears an empty/missing owner file; backup rotation sorts by mtime, not
name; Windows-only backslash normalization (a legal Unix path containing a
backslash is no longer rewritten); GSTACK_SWEEP_EXCLUDE_SOURCES lets a
sweep spare named sources; lock tradeoffs documented at the lock helper.

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

* fix(setup): honest hook-registration reporting + verify-gate sweep exclusion

_install_plan_tune_hooks now propagates per-add-event failures (lock
contention exits 5, fail-closed settings errors exit 3) and both caller
sites branch on it: success logs the installed message, failure logs a
visible "NOT registered — re-run ./setup" warning instead of claiming
success for a mutation that never happened.

--no-team's identity sweep runs with GSTACK_SWEEP_EXCLUDE_SOURCES=
verify-gate: turning team mode off must not delete the user-registered
verify-gate opt-in whose binary still exists (uninstall still sweeps it,
correctly, because there the binary itself is being removed).

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

* test: adversarial regression pins — wrong-shape fail-loud, prototype basename, sweep exclusion, lock exit 5

New pins for the fail-loud hardening: a wrong-shape hooks value (object
where an array belongs) exits 4 with "refusing to mutate" and leaves the
file byte-identical (pre-gsMain this was a silent exit-0 no-op); a foreign
hook whose basename collides with Object.prototype ("toString") survives
an --all sweep that still removes gstack rows; GSTACK_SWEEP_EXCLUDE_SOURCES
preserves the verify-gate row during --all; the fresh-foreign-lock test now
asserts the loud exit 5 instead of a quiet skip.

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

* test(verify-gate): allow the --no-team sweep exclusion, keep registration banned

setup now legitimately mentions verify-gate once: the --no-team identity
sweep excludes it via GSTACK_SWEEP_EXCLUDE_SOURCES so team-mode teardown
can't delete a user-registered gate. The opt-in pin tightens from a blanket
not-contains to: every mention must be a comment or that exclusion, and no
mention may sit on an add-event line.

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

* fix(settings-hook): GNU-first stat in the lock stale check — Linux abort on held locks

On Linux, BSD-style `stat -f %m` prints a multi-line FILESYSTEM block to
stdout before exiting 1, so the BSD-first || chain captured that garbage
concatenated with the real `stat -c %Y` epoch. The non-numeric mtime made
`$(( now - mtime ))` a syntax error and set -e killed the binary with
exit 1 whenever a lock dir already existed — every contention path (stale
takeover, give-up, concurrent writers) broke on CI while staying green on
macOS, where BSD stat -f succeeds cleanly.

GNU `stat -c %Y` now goes first (BSD stat rejects -c with no stdout, so
macOS falls through cleanly), and a numeric guard blanks any residual
garbage so a future platform quirk degrades to the normal give-up path
instead of an arithmetic abort. Same defect class as gstack-repo-mode's
GNU-first ordering (#2195). Verified in an oven/bun Linux container:
the four CI-failing lock tests now pass (62/62 across both files).

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

* test(uninstall): 30s budgets for the two subprocess-heavy behavioral tests

Both tests spawn the copied uninstaller, which itself runs several
settings-hook bun -e children (the lock-contention one also waits out a
300ms give-up per call). On a loaded box those cold starts blow bun's
default 5s per-test timeout, and a timeout kill reports as a bare fail
with no assertion diff — observed at 5.6-8.5s under load avg 25+.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-19 16:53:19 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 28d59ad56c
commit 9da6692930
21 changed files with 2201 additions and 271 deletions
+33
View File
@@ -129,3 +129,36 @@ describe('hook integration — invoked as PostToolUse', () => {
expect(out.additionalContext).toBeUndefined();
});
});
// ----------------------------------------------------------------------
// Registration + teardown wiring (static). Setup registers this hook under
// its own source tag (sharing plan-tune-cathedral would overwrite the
// question-log entry — same event+matcher); both teardown surfaces
// (--no-team, uninstall) must remove it, which pre-v1.67.2 neither did.
// ----------------------------------------------------------------------
import * as fs from 'fs';
describe('setup registration + teardown wiring (static)', () => {
const ROOT = path.resolve(__dirname, '..');
const setupSrc = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const uninstallSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-uninstall'), 'utf-8');
test('setup registers the hook via the canonical resolver under --source auq-error-fallback', () => {
expect(setupSrc).toMatch(
/AUQ_ERROR_FALLBACK_HOOK="\$\(_hook_command_path hosts\/claude\/hooks\/auq-error-fallback-hook/,
);
expect(setupSrc).toContain('--source auq-error-fallback');
});
test('--no-team tears the hook down', () => {
const idx = setupSrc.indexOf('# Also tear down plan-tune');
expect(idx).toBeGreaterThan(-1);
const slice = setupSrc.slice(idx, idx + 900);
expect(slice).toContain('remove-source --source auq-error-fallback');
});
test('gstack-uninstall tears the hook down', () => {
expect(uninstallSrc).toContain('remove-source --source auq-error-fallback');
});
});
+34 -8
View File
@@ -68,21 +68,47 @@ describe('content-binding template drift', () => {
// Functional: execute the template's tripwire block against a 0-banner
// original and a 1-banner outgoing body — the ABORT branch must fire.
//
// Pass the script as an ARGV element (spawnSync array form), never by
// interpolating JSON.stringify into a shell line: JSON escaping is not
// shell escaping. Inside shell double quotes a JSON "\n" stays a literal
// backslash-n, which collapsed this multi-line script onto one line where
// `then\n` became the command word `thenn` and `>&2\nelse\n` became the
// redirect `>&2nelsen` — silently littering a `2nelsen` file (containing
// "bash: thenn: command not found") in the repo root on every suite run,
// while the old not-contains assertion passed vacuously because ALL
// output had been redirected into that file.
const block = body.match(/_ORIG_BANNERS=\$\(grep[\s\S]*?fi\n/);
expect(block).not.toBeNull();
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execSync } = require('child_process');
const { spawnSync } = require('child_process');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-banner-'));
try {
fs.writeFileSync(path.join(dir, 'orig.md'), 'clean body\n');
fs.writeFileSync(path.join(dir, 'new.md'), 'body with UNTRUSTED TRACKER CONTENT banner leak\n');
const script = block![0]
.replaceAll('/tmp/gstack-pr-body-orig-$$.md', path.join(dir, 'orig.md'))
.replaceAll('/tmp/gstack-pr-body-$$.md', path.join(dir, 'new.md'));
const out = execSync(`bash -c ${JSON.stringify(script + '; true')}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
expect(out).not.toContain('banner tripwire clean');
const scriptFor = (origContent: string, newContent: string) => {
fs.writeFileSync(path.join(dir, 'orig.md'), origContent);
fs.writeFileSync(path.join(dir, 'new.md'), newContent);
return block![0]
.replaceAll('/tmp/gstack-pr-body-orig-$$.md', path.join(dir, 'orig.md'))
.replaceAll('/tmp/gstack-pr-body-$$.md', path.join(dir, 'new.md'));
};
// Banner leaked into the outgoing body → the ABORT branch fires, loudly.
const abort = spawnSync('bash', ['-c', scriptFor(
'clean body\n',
'body with UNTRUSTED TRACKER CONTENT banner leak\n',
)], { encoding: 'utf-8' });
expect(abort.stderr).toContain('ABORT: envelope banner leaked');
expect(abort.stdout).not.toContain('banner tripwire clean');
// No banner delta → the clean branch fires.
const clean = spawnSync('bash', ['-c', scriptFor(
'clean body\n',
'also clean body\n',
)], { encoding: 'utf-8' });
expect(clean.stdout).toContain('banner tripwire clean');
expect(clean.stderr).not.toContain('ABORT');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
+2 -2
View File
@@ -46,13 +46,13 @@ afterEach(() => {
});
describe("gstack-config key validation is locale-independent", () => {
test("both get and set validate ASCII ranges under the C locale", () => {
test("get, has, and set all validate ASCII ranges under the C locale", () => {
const source = fs.readFileSync(CONFIG, "utf8");
const guardedValidators = source.match(
/LC_ALL=C grep -qE '\^\[a-zA-Z0-9_\]\+\(@\[a-zA-Z0-9\]\+\)\?\$'/g,
);
expect(guardedValidators).toHaveLength(2);
expect(guardedValidators).toHaveLength(3);
});
test("round-trips existing keys that contain i", () => {
@@ -420,3 +420,719 @@ describe('list-sources', () => {
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('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 },
});
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');
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Canonical-only hook registration (phantom-hooks fix, v1.67.2).
*
* Static tripwires over `setup` and `bin/gstack-settings-hook`. The defect
* class these pin against: hook commands baked from the SETUP-TIME tree
* (`$SOURCE_GSTACK_DIR` = `pwd -P` of the running tree) into the user's
* GLOBAL ~/.claude/settings.json. Conductor worktrees are ephemeral, so every
* deleted workspace left dead hooks erroring on each AskUserQuestion fire.
*
* The contract:
* - hook registration paths come ONLY from `_hook_command_path` (canonical
* install: ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack) an
* ephemeral tree can never be baked in; missing canonical = skip + log.
* - setup heals BEFORE any tag-presence guard (`prune-stale --repoint`),
* so a dead tagged entry can't block re-registration forever.
* - the heal is visible when it changes anything (no full output
* suppression at the call site).
* - the settings-hook binary's bun scripts share one JS prelude (KNOWN_HOOKS
* identity table + helpers) so the dedupe key and the prune predicate
* cannot drift.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const setupSrc = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const hookBinSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-settings-hook'), 'utf-8');
const uninstallSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-uninstall'), 'utf-8');
describe('setup: canonical-only hook paths', () => {
test('CANONICAL_GSTACK_ROOT honors CLAUDE_CONFIG_DIR with ~/.claude fallback', () => {
expect(setupSrc).toContain(
'CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack"',
);
});
test('_hook_command_path body never references the running tree', () => {
const start = setupSrc.indexOf('_hook_command_path() {');
expect(start).toBeGreaterThan(-1);
const end = setupSrc.indexOf('\n}', start);
const body = setupSrc.slice(start, end);
expect(body).not.toContain('SOURCE_GSTACK_DIR');
expect(body).toContain('CANONICAL_GSTACK_ROOT');
});
test('every hook var routes through _hook_command_path; no raw SOURCE_GSTACK_DIR hook assignment remains', () => {
for (const rel of [
'hosts/claude/hooks/question-log-hook',
'hosts/claude/hooks/question-preference-hook',
'hosts/claude/hooks/auq-error-fallback-hook',
'hosts/claude/hooks/timeline-stop-hook',
'bin/gstack-session-update',
]) {
expect(setupSrc).toContain(`$(_hook_command_path ${rel}`);
}
// The bug: FOO_HOOK="$SOURCE_GSTACK_DIR/hosts/claude/hooks/..."
expect(setupSrc).not.toMatch(/="\$SOURCE_GSTACK_DIR\/hosts\/claude\/hooks\//);
expect(setupSrc).not.toMatch(/HOOK_CMD="(bash )?\$SOURCE_GSTACK_DIR\/bin\/gstack-session-update"/);
});
test('SessionStart registers via schema-aware add-event under its identity source', () => {
expect(setupSrc).toMatch(
/add-event --event SessionStart --command "\$HOOK_CMD" --source gstack-session-update/,
);
});
test('every add-event source in setup has a KNOWN_HOOKS table row', () => {
// Future-hook tripwire: a new add-event registration whose hook basename
// is missing from the identity table would be invisible to the healer,
// the --no-team sweep, and uninstall.
const rels = [...setupSrc.matchAll(/_hook_command_path (\S+)/g)].map((m) => m[1]);
expect(rels.length).toBeGreaterThanOrEqual(5);
for (const rel of rels) {
const basename = rel.split('/').pop()!;
expect(hookBinSrc).toContain(`"${basename}":`);
}
});
});
describe('setup: heal-first ordering + visibility', () => {
test('prune-stale --repoint runs before any list-sources guard or add-event registration', () => {
const heal = setupSrc.indexOf('prune-stale --repoint');
const firstGuard = setupSrc.indexOf('list-sources');
const firstAdd = setupSrc.indexOf('add-event');
expect(heal).toBeGreaterThan(-1);
expect(heal).toBeLessThan(firstGuard);
expect(heal).toBeLessThan(firstAdd);
});
test('the heal call site is not output-suppressed (zero silent settings mutations)', () => {
const lines = setupSrc.split('\n').filter((l) => l.includes('prune-stale --repoint'));
expect(lines.length).toBeGreaterThanOrEqual(1);
for (const line of lines) {
expect(line).not.toContain('>/dev/null');
expect(line).not.toContain('2>&1');
}
// Captured for the change-only summary line.
expect(setupSrc).toMatch(/_HEAL_OUT=\$\("\$SETTINGS_HOOK" prune-stale/);
expect(setupSrc).toContain('healed hook registrations');
});
test('heal counters use the ${VAR:-0} idiom, never `grep -c || echo 0`', () => {
// Prior learning grep-c-double-emit-fail-open: `grep -c ... || echo 0`
// double-emits "0\n0" on no-match and breaks numeric guards open.
expect(setupSrc).toContain('${_HEAL_REMOVED:-0}');
expect(setupSrc).toContain('${_HEAL_REPOINTED:-0}');
const healRegion = setupSrc.slice(
setupSrc.indexOf('_HEAL_OUT='),
setupSrc.indexOf('healed hook registrations'),
);
expect(healRegion).not.toMatch(/grep -c .*\|\| echo 0/);
});
test('--no-team teardown includes the auq source and the identity sweep', () => {
const idx = setupSrc.indexOf('# Also tear down plan-tune');
expect(idx).toBeGreaterThan(-1);
const slice = setupSrc.slice(idx, idx + 900);
expect(slice).toContain('remove-source --source plan-tune-cathedral');
expect(slice).toContain('remove-source --source auq-error-fallback');
expect(slice).toContain('remove-source --source gstack-timeline-stop');
expect(slice).toContain('prune-stale --all');
});
});
describe('gstack-settings-hook: shared prelude (dedupe key == prune predicate)', () => {
test('every bun script call site uses the shared JS prelude concatenation', () => {
const codeLines = hookBinSrc.split('\n').filter((l) => !l.trim().startsWith('#'));
const bunCalls = codeLines.filter((l) => l.includes('bun -e '));
const preludeCalls = codeLines.filter((l) => l.includes(`bun -e "$_HOOK_JS_PRELUDE"'`));
expect(bunCalls.length).toBeGreaterThanOrEqual(6);
expect(preludeCalls.length).toBe(bunCalls.length);
});
test('the prelude contains no single quotes (single-quoted shell assignment)', () => {
const start = hookBinSrc.indexOf("_HOOK_JS_PRELUDE='");
expect(start).toBeGreaterThan(-1);
const end = hookBinSrc.indexOf("\n'", start);
const prelude = hookBinSrc.slice(start + "_HOOK_JS_PRELUDE='".length, end);
expect(prelude).not.toContain("'");
// And no shell-expansion hazards inside the double-quoted call-site expansion.
expect(prelude).not.toContain('`');
});
test('KNOWN_HOOKS table carries all five identities with source+event+relpath', () => {
for (const [name, source, event] of [
['question-log-hook', 'plan-tune-cathedral', 'PostToolUse'],
['question-preference-hook', 'plan-tune-cathedral', 'PreToolUse'],
['auq-error-fallback-hook', 'auq-error-fallback', 'PostToolUse'],
['timeline-stop-hook', 'gstack-timeline-stop', 'Stop'],
['gstack-session-update', 'gstack-session-update', 'SessionStart'],
]) {
const rowStart = hookBinSrc.indexOf(`"${name}":`);
expect(rowStart).toBeGreaterThan(-1);
const row = hookBinSrc.slice(rowStart, hookBinSrc.indexOf('}', rowStart));
expect(row).toContain(`source: "${source}"`);
expect(row).toContain(`event: "${event}"`);
expect(row).toContain('relpath: "');
}
});
});
describe('gstack-uninstall: hook cleanup runs before install-root deletion', () => {
test('the settings cleanup block precedes every install-root rm -rf', () => {
// Pre-fix bug: SETTINGS_HOOK=$(dirname "$0")/gstack-settings-hook resolved
// INSIDE the install root, which was already deleted by the time cleanup
// ran — a real global uninstall silently orphaned every hook.
const cleanup = uninstallSrc.indexOf('Remove gstack hooks from Claude Code settings');
const rootDelete = uninstallSrc.indexOf('rm -rf "$CLAUDE_SKILLS/gstack"');
expect(cleanup).toBeGreaterThan(-1);
expect(rootDelete).toBeGreaterThan(-1);
expect(cleanup).toBeLessThan(rootDelete);
});
test('uninstall removes all three sources and sweeps untagged strays', () => {
expect(uninstallSrc).toContain('remove-source --source plan-tune-cathedral');
expect(uninstallSrc).toContain('remove-source --source auq-error-fallback');
expect(uninstallSrc).toContain('remove-source --source gstack-timeline-stop');
expect(uninstallSrc).toContain('prune-stale --all');
});
});
describe('the defect-class warning is written down where the next author will see it', () => {
test('setup carries the never-register-tree-relative-paths warning', () => {
expect(setupSrc).toMatch(/NEVER register .*SOURCE_GSTACK_DIR.*hook paths/);
});
});
describe('matcher-literal drift tripwire (review-army)', () => {
test("every --matcher literal in setup equals its KNOWN_HOOKS row's matcher", () => {
// gsOwnedRow requires an EXACT matcher match — if setup's registration
// matcher drifts from the table row, identity re-pointing/pruning silently
// stops recognizing the hook and the phantom-duplicate class returns.
const rowMatcher = (name: string) => {
const rowStart = hookBinSrc.indexOf(`"${name}":`);
expect(rowStart).toBeGreaterThan(-1);
const row = hookBinSrc.slice(rowStart, hookBinSrc.indexOf('}', rowStart));
return row.match(/matcher: "([^"]*)"/)![1];
};
const pairs: Array<[string, string]> = [
['question-log-hook', '(AskUserQuestion|mcp__.*__AskUserQuestion)'],
['question-preference-hook', '(AskUserQuestion|mcp__.*__AskUserQuestion)'],
['auq-error-fallback-hook', '(AskUserQuestion|mcp__.*__AskUserQuestion)'],
];
for (const [name, expected] of pairs) {
expect(rowMatcher(name)).toBe(expected);
}
// And setup registers those hooks with exactly that matcher literal.
const matcherLiterals = [...setupSrc.matchAll(/--matcher '([^']+)'/g)].map((m) => m[1]);
expect(matcherLiterals.length).toBeGreaterThanOrEqual(3);
for (const lit of matcherLiterals) {
expect(lit).toBe('(AskUserQuestion|mcp__.*__AskUserQuestion)');
}
});
});
@@ -74,6 +74,75 @@ describe('dev-setup: never silently mutates global settings.json', () => {
});
});
describe('setup: PT_EXPLICIT provenance (Conductor auto-opt-in respects explicit decisions)', () => {
// The phantom-hooks root cause (Bug A): the Conductor auto-opt-in upgraded
// PT_DECISION "prompt" → "yes" even when "prompt" came from dev-setup's
// EXPLICIT --plan-tune-hooks=prompt flag, so every new Conductor workspace
// installed hooks pointing at its ephemeral worktree.
test('flag and env set PT_EXPLICIT=1', () => {
expect(setupSrc).toContain('PT_EXPLICIT=1');
const flagIdx = setupSrc.indexOf('PT_DECISION="$PLAN_TUNE_HOOKS_MODE"');
const explicitIdx = setupSrc.indexOf('PT_EXPLICIT=1', flagIdx);
expect(flagIdx).toBeGreaterThan(-1);
expect(explicitIdx).toBeGreaterThan(flagIdx);
});
test('the Conductor auto-opt-in fires only on the true silent fall-through', () => {
expect(setupSrc).toMatch(
/\[ "\$PT_DECISION" = "prompt" \] && \[ "\$PT_EXPLICIT" -eq 0 \] && \{ \[ -n "\$\{CONDUCTOR_WORKSPACE_PATH:-\}" \] \|\| \[ -n "\$\{CONDUCTOR_PORT:-\}" \]; \}/,
);
});
test('config provenance uses gstack-config has (env-resolution-safe), never a hardcoded config grep', () => {
// `gstack-config get` returns the default "prompt" for absent keys, so
// key PRESENCE must come from `has`, which resolves GSTACK_STATE_ROOT /
// GSTACK_HOME / GSTACK_STATE_DIR the same way `get` does. A hardcoded
// grep of ~/.gstack/config.yaml misclassifies under env overrides.
expect(setupSrc).toMatch(/"\$GSTACK_CONFIG" has plan_tune_hooks/);
expect(setupSrc).not.toMatch(/grep -q ["']\^plan_tune_hooks:/);
});
});
describe('gstack-config: has subcommand (key-presence provenance)', () => {
let tmpHome2: string;
let env2: NodeJS.ProcessEnv;
beforeAll(() => {
tmpHome2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-has-'));
env2 = { ...process.env, GSTACK_STATE_ROOT: tmpHome2 };
});
afterAll(() => {
fs.rmSync(tmpHome2, { recursive: true, force: true });
});
function has(key: string): number {
try {
execSync(`${GSTACK_CONFIG} has '${key}'`, { encoding: 'utf-8', env: env2 });
return 0;
} catch (e: any) {
return e.status ?? 1;
}
}
test('absent key exits nonzero even though get returns the default', () => {
expect(has('plan_tune_hooks')).not.toBe(0);
const got = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, { encoding: 'utf-8', env: env2 }).trim();
expect(got).toBe('prompt'); // default — indistinguishable from a saved value via get
});
test('present key exits 0 through the same STATE_DIR resolution as get', () => {
execSync(`${GSTACK_CONFIG} set plan_tune_hooks no`, { encoding: 'utf-8', env: env2 });
expect(has('plan_tune_hooks')).toBe(0);
// GSTACK_STATE_ROOT was the writer — a hardcoded ~/.gstack grep would miss it.
});
test('rejects malformed keys', () => {
expect(has('bad key$(touch /tmp/pwned)')).not.toBe(0);
});
});
describe('gstack-config: plan_tune_hooks key', () => {
// Isolate state: gstack-config reads $GSTACK_HOME/config.yaml. Point it at a
// fresh temp dir so `get` returns the built-in default rather than whatever
+5 -1
View File
@@ -61,7 +61,11 @@ describe('setup: _link_or_copy invariant (D7)', () => {
const hookEnd = SETUP_SRC.indexOf('\nif [ "$TEAM_MODE" -eq 1 ]', hookStart);
const hookSection = SETUP_SRC.slice(hookStart, hookEnd);
expect(hookSection).toContain('IS_WINDOWS');
expect(hookSection).toContain('bash $SOURCE_GSTACK_DIR/bin/gstack-session-update');
// v1.67.2 phantom-hooks fix: the command comes from the CANONICAL install
// via _hook_command_path (never $SOURCE_GSTACK_DIR — ephemeral trees were
// baked into settings.json), but the Windows bash prefix survives.
expect(hookSection).toContain('HOOK_CMD="bash $SESSION_UPDATE_CMD"');
expect(hookSection).toContain('_hook_command_path bin/gstack-session-update');
});
});
+26 -15
View File
@@ -261,37 +261,47 @@ describe('timeline-stop-hook wiring', () => {
expect(setup).not.toMatch(/list-sources 2>\/dev\/null \| grep -q "gstack-timeline-stop"/);
});
test('fresh register prefers the global-install hook path when present', () => {
// Drive setup's _hook_install_path directly: global install present → the
// registration survives deleting the worktree setup ran from.
test('hook path resolution is canonical-only: global install or skip, never the worktree', () => {
// Drive setup's _hook_command_path directly: canonical install present →
// that path (survives deleting the worktree setup ran from). Absent →
// non-zero and NO output — registration is skipped with a log line; the
// running tree's path is NEVER baked into settings.json (the SOURCE
// fallback was the phantom-hooks defect and is deliberately gone).
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const fn = setup.match(/_hook_install_path\(\) \{[\s\S]*?\n\}/);
const fn = setup.match(/_hook_command_path\(\) \{[\s\S]*?\n\}/);
expect(fn).not.toBeNull();
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hookpath-'));
try {
const globalHook = path.join(
fakeHome, '.claude', 'skills', 'gstack', 'hosts', 'claude', 'hooks', 'timeline-stop-hook',
);
const canonicalRoot = path.join(fakeHome, '.claude', 'skills', 'gstack');
const globalHook = path.join(canonicalRoot, 'hosts', 'claude', 'hooks', 'timeline-stop-hook');
fs.mkdirSync(path.dirname(globalHook), { recursive: true });
fs.writeFileSync(globalHook, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const env = { ...process.env, HOME: fakeHome, SOURCE_GSTACK_DIR: '/some/dev/worktree' };
const env = {
...process.env,
HOME: fakeHome,
SOURCE_GSTACK_DIR: '/some/dev/worktree',
CANONICAL_GSTACK_ROOT: canonicalRoot,
};
const withGlobal = spawnSync(
'bash',
['-c', `${fn![0]}\n_hook_install_path hosts/claude/hooks/timeline-stop-hook`],
['-c', `${fn![0]}\n_hook_command_path hosts/claude/hooks/timeline-stop-hook`],
{ env, encoding: 'utf-8', timeout: 10_000 },
);
expect(withGlobal.status).toBe(0);
expect(withGlobal.stdout.trim()).toBe(globalHook);
// No global install (fresh first install from a clone) → setup-time path.
// No canonical install → the resolver FAILS (caller logs a visible
// skip); it never falls back to the setup-time tree.
fs.rmSync(globalHook);
const withoutGlobal = spawnSync(
'bash',
['-c', `${fn![0]}\n_hook_install_path hosts/claude/hooks/timeline-stop-hook`],
['-c', `${fn![0]}\n_hook_command_path hosts/claude/hooks/timeline-stop-hook`],
{ env, encoding: 'utf-8', timeout: 10_000 },
);
expect(withoutGlobal.stdout.trim()).toBe('/some/dev/worktree/hosts/claude/hooks/timeline-stop-hook');
expect(withoutGlobal.status).not.toBe(0);
expect(withoutGlobal.stdout.trim()).toBe('');
} finally {
fs.rmSync(fakeHome, { recursive: true, force: true });
}
@@ -357,10 +367,11 @@ describe('timeline-stop-hook wiring', () => {
}
});
test('corrupt settings.json: ensure-event refuses (exit 1) and never rewrites the file', () => {
test('corrupt settings.json: ensure-event refuses (exit 3) and never rewrites the file', () => {
// The old catch{} folded an unparseable EXISTING settings.json into {}
// and the atomic write replaced the user's permissions/env/other hooks
// with just ours. Now: loud stderr error, exit 1, file byte-identical.
// with just ours. Now: loud stderr error, fail-closed exit 3 (the
// settings-hook parse-refusal code), file byte-identical.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-corrupt-'));
try {
const settingsFile = path.join(dir, 'settings.json');
@@ -375,7 +386,7 @@ describe('timeline-stop-hook wiring', () => {
'--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(1);
expect(r.status).toBe(3);
expect(r.stderr).toContain('not valid JSON');
// Never rewritten — the corrupt bytes (and whatever the user can still
// salvage from them) survive verbatim.
+123
View File
@@ -231,3 +231,126 @@ describe('gstack-uninstall', () => {
});
});
});
// ----------------------------------------------------------------------
// Hook-cleanup ordering (phantom-hooks fix). Pre-v1.67.2, the settings
// cleanup ran AFTER `rm -rf $CLAUDE_SKILLS/gstack` — and SETTINGS_HOOK
// resolves via $(dirname "$0") INSIDE that root, so a real global uninstall
// (running the installed copy) silently orphaned every hook. Prior tests
// masked this by running the uninstaller from the repo checkout. This test
// runs the INSTALLED copy.
// ----------------------------------------------------------------------
describe('hook cleanup runs before the install root is deleted', () => {
test('uninstall executed FROM the install root still removes hook entries', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-order-'));
try {
const mockHome = path.join(tmp, 'home');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
// The installed copies — the uninstaller under test IS the one inside
// the root it deletes.
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) {
const src = path.join(ROOT, 'bin', b);
const dst = path.join(installBin, b);
fs.copyFileSync(src, dst);
fs.chmodSync(dst, 0o755);
}
const settingsFile = path.join(mockHome, '.claude', 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [{
matcher: '(AskUserQuestion|mcp__.*__AskUserQuestion)',
_gstack_source: 'auq-error-fallback',
hooks: [{ type: 'command', command: '/dead/wt/hosts/claude/hooks/auq-error-fallback-hook', timeout: 5 }],
}],
Stop: [{
hooks: [{ type: 'command', command: `${installRoot}/hosts/claude/hooks/timeline-stop-hook`, timeout: 5 }],
}],
PreCompact: [{ hooks: [{ type: 'command', command: '/Users/me/my-own-hook' }] }],
},
}, null, 2));
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
stdio: 'pipe',
env: {
...process.env,
HOME: mockHome,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: path.join(mockHome, '.gstack'),
},
cwd: tmp,
});
expect(result.status).toBe(0);
// Install root gone…
expect(fs.existsSync(installRoot)).toBe(false);
// …and the hook entries were still cleaned (cleanup ran BEFORE deletion).
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
expect(s.hooks?.PostToolUse).toBeUndefined();
expect(s.hooks?.Stop).toBeUndefined();
// The user's own hook survives the sweep.
expect(s.hooks?.PreCompact?.[0]?.hooks?.[0]?.command).toBe('/Users/me/my-own-hook');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
// 30s: the copied uninstaller spawns several bun -e children; on a loaded
// box their cold starts blow bun's default 5s per-test timeout.
}, 30000);
});
describe('hook cleanup under lock contention is loud, never silent (review-army)', () => {
test('a held foreign lock during uninstall surfaces the give-up warning on stderr', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-lock-'));
try {
const mockHome = path.join(tmp, 'home');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
}
const settingsFile = path.join(mockHome, '.claude', 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [{
_gstack_source: 'gstack-timeline-stop',
hooks: [{ type: 'command', command: `${installRoot}/hosts/claude/hooks/timeline-stop-hook` }],
}],
},
}, null, 2));
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
// A fresh foreign lock: pre-fix, every cleanup call silently skipped and
// uninstall reported clean while orphaning the hooks forever.
fs.mkdirSync(`${settingsFile}.lock`);
fs.writeFileSync(path.join(`${settingsFile}.lock`, 'owner'), 'another-live-process');
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
stdio: 'pipe',
env: {
...process.env,
HOME: mockHome,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: path.join(mockHome, '.gstack'),
GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '300',
},
cwd: tmp,
});
const stderr = result.stderr.toString();
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
const cleaned = s.hooks?.Stop === undefined;
// Either the sweep still happened, or the user SEES why it didn't.
expect(cleaned || /could not acquire lock/.test(stderr)).toBe(true);
expect(/could not acquire lock/.test(stderr)).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
// 30s: several settings-hook calls each wait out the 300ms lock give-up,
// and their bun -e cold starts stack up under load — the default 5s
// per-test budget is too tight on a busy box.
}, 30000);
});
+11 -1
View File
@@ -349,7 +349,17 @@ describe('opt-in contract (adapted from the fork: NOT registered by default)', (
const gate = fs.readFileSync(GATE, 'utf-8');
test('./setup does NOT register the gate — a Stop hook running the verify command after every turn is opt-in', () => {
expect(setup).not.toContain('verify-gate');
// The only permitted mentions are comments and the --no-team sweep
// EXCLUSION (protecting a user-registered gate from team-mode teardown).
// A registration (add-event) referencing the gate stays banned.
const mentions = setup.split('\n').filter((l) => l.includes('verify-gate'));
expect(mentions.length).toBeGreaterThan(0); // the exclusion itself is pinned
for (const line of mentions) {
const t = line.trim();
const allowed = t.startsWith('#') || t.includes('GSTACK_SWEEP_EXCLUDE_SOURCES="verify-gate"');
expect(allowed).toBe(true);
expect(t).not.toContain('add-event');
}
});
test('the bin documents its own registration and removal commands', () => {