Files
gstack/test/gstack-settings-hook-schema-aware.test.ts
Garry TanandClaude Fable 5 3e674e4c01 fix: sweep — every sync spawn in the test trees carries a timeout (436 sites, 157 files)
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process
per-test timeout can never fire while one waits — a hung child (stdin read,
network probe, dead daemon) wedges the whole shard until the runner's
external wall-clock SIGKILL. This exact class reached main: free-tests run
33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2
at the 360s wall while its five siblings finished in ~65s.

Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified
against its call site): default timeout: 30_000 (matches the free runner's
per-test budget), 120_000 for genuinely slow ops (installs, builds,
playwright, provider CLIs), helper wrappers fixed ONCE where call sites
route through them. Sites that only LOOK like calls (string fixtures, grep
needles, comments) were skipped with reasons — the enforcement commit that
follows marks them exempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:49:57 +00:00

1140 lines
46 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('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');
});
});