diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index d850d31bb..7c963b24b 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -14,10 +14,13 @@ # gstack-settings-hook add-event --event \ # --command --source [--matcher ] [--timeout ] # gstack-settings-hook ensure-event --event ... --command ... --source ... [--matcher ...] [--timeout ] -# gstack-settings-hook remove-source --source +# gstack-settings-hook remove-source --source # removes items the table identifies as 's, tagged or not # gstack-settings-hook diff-event --event ... --command ... --source ... [--matcher ...] # gstack-settings-hook rollback # restore latest backup (single-step undo) # gstack-settings-hook list-sources # show all gstack-tagged hook entries +# gstack-settings-hook list-items --event [--owned-by ] [--command-regex ] +# # read-only: one JSON string literal per matching hook COMMAND +# # (identity via KNOWN_HOOKS, never the tag); empty stdout = none # # 3. Self-heal (phantom-hooks fix): # gstack-settings-hook prune-stale # prune dead gstack hook items @@ -76,11 +79,12 @@ Usage: gstack-settings-hook remove # legacy SessionStart remove gstack-settings-hook add-event --event --command --source [--matcher ] [--timeout ] gstack-settings-hook ensure-event --event --command --source [--matcher ] [--timeout ] - gstack-settings-hook remove-source --source + gstack-settings-hook remove-source --source # tagged OR table-identified items of gstack-settings-hook diff-event --event --command --source [--matcher ] [--timeout ] gstack-settings-hook prune-stale [--repoint ] [--all] gstack-settings-hook rollback gstack-settings-hook list-sources + gstack-settings-hook list-items --event [--owned-by ] [--command-regex ] EOF exit 1 fi @@ -598,29 +602,45 @@ case "$ACTION" in if (!settings.hooks) { console.log("OK: removed 0 hook entry/entries tagged source=" + source); process.exit(0); } const before = JSON.stringify(settings, null, 2); let removed = 0; + // Identity-aware removal (tag OR table). Claude Code strips the + // _gstack_source tag when it rewrites settings.json, so a tag-only + // off switch silently no-ops on exactly the entries it was written + // for. Decision per item, identity first (D = drop, K = keep): + // + // item -> | row.source == SOURCE | row of another source | no table row + // entry tagged SOURCE | D | K | D if single item, else K + // untagged / other tag | D | K | K + // + // Entries with nothing of ours stay byte-identical (tag included); + // an entry we emptied is dropped; a tagged entry we trimmed loses + // the tag with its last owned item. Callers that need every gstack + // item gone still pair this with prune-stale --all. for (const event of Object.keys(settings.hooks)) { + const entries = settings.hooks[event]; + if (!Array.isArray(entries)) continue; // foreign shape: not ours to judge const kept = []; - for (const entry of settings.hooks[event]) { - if (entry._gstack_source !== source) { kept.push(entry); continue; } - if (!Array.isArray(entry.hooks) || entry.hooks.length === 0) { removed++; continue; } - // Item-aware: remove table-owned items (or the single item of a - // tagged legacy-stray entry); foreign items in a tagged multi-item - // entry are preserved and the tag is dropped with the last owned item. + for (const entry of entries) { + const tagged = !!entry && entry._gstack_source === source; + if (!entry || !Array.isArray(entry.hooks) || entry.hooks.length === 0) { + if (tagged) { removed++; continue; } // tagged but empty/malformed: legacy stray + kept.push(entry); continue; + } const single = entry.hooks.length === 1; + let touched = 0; const remain = entry.hooks.filter(h => { // Command-less items cannot be ours (gstack only writes // type:command items) -- preserve them. - const owned = (h && h.command) - ? gsOwnedRow(h.command, event, entry.matcher || "") !== null - : false; - // The single-item stray claim requires a command item (gstack - // never writes command-less items). - if (owned || (single && h && h.command)) { removed++; return false; } + const cmd = (h && typeof h.command === "string") ? h.command : ""; + const row = cmd ? gsOwnedRow(cmd, event, entry.matcher || "") : null; + const ours = !!row && row.source === source; + const stray = tagged && single && !!cmd && !row; + if (ours || stray) { removed++; touched++; return false; } return true; }); + if (touched === 0) { kept.push(entry); continue; } if (remain.length === 0) continue; entry.hooks = remain; - delete entry._gstack_source; + if (tagged) delete entry._gstack_source; kept.push(entry); } settings.hooks[event] = kept; @@ -818,6 +838,62 @@ case "$ACTION" in echo "OK: restored $SETTINGS_FILE from $LATEST" ;; + list-items) + # Read-only identity view: one JSON string literal per matching hook + # command (JSON.stringify, so a command containing tabs or newlines + # cannot split a line), filters applied inside the JS. Empty stdout + # means no match. Exit 1 usage, 3 unparseable settings, 4 unexpected + # shape -- the same codes the mutating verbs use, because callers + # (bin/gstack-memorable) decide mutations from this output. + LI_EVENT="" + LI_OWNED_BY="" + LI_CMD_RE="" + shift + while [ $# -gt 0 ]; do + case "$1" in + --event) LI_EVENT="$2"; shift 2 ;; + --owned-by) LI_OWNED_BY="$2"; shift 2 ;; + --command-regex) LI_CMD_RE="$2"; shift 2 ;; + *) echo "unknown flag: $1" >&2; exit 1 ;; + esac + done + if [ -z "$LI_EVENT" ]; then + echo "list-items requires --event " >&2 + exit 1 + fi + [ -f "$SETTINGS_FILE" ] || exit 0 + GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_LI_EVENT="$LI_EVENT" GSTACK_LI_OWNED_BY="$LI_OWNED_BY" GSTACK_LI_CMD_RE="$LI_CMD_RE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () { + const event = process.env.GSTACK_LI_EVENT; + const ownedBy = process.env.GSTACK_LI_OWNED_BY || ""; + const reSrc = process.env.GSTACK_LI_CMD_RE || ""; + let re = null; + if (reSrc) { + try { re = new RegExp(reSrc); } + catch (e) { + process.stderr.write("list-items: invalid --command-regex (" + e.message + ")\n"); + process.exit(1); + } + } + const loaded = gsLoadSettings(process.env.GSTACK_SETTINGS_PATH); + const hooks = loaded.settings.hooks || {}; + const entries = hooks[event]; + if (entries === undefined || entries === null) process.exit(0); + if (!Array.isArray(entries)) throw new Error("hooks." + event + " is not an array"); + for (const entry of entries) { + if (!entry || !Array.isArray(entry.hooks)) continue; // foreign shape, preserved by prune-stale too + for (const h of entry.hooks) { + const cmd = (h && typeof h.command === "string") ? h.command : ""; + if (!cmd) continue; + const row = gsOwnedRow(cmd, event, entry.matcher || ""); + if (ownedBy && (!row || row.source !== ownedBy)) continue; + if (re && (row || !re.test(cmd))) continue; // the regex only ever sees items no table row owns + console.log(JSON.stringify(cmd)); + } + } + }); + ' + ;; + list-sources) [ -f "$SETTINGS_FILE" ] || { echo "(no settings file)"; exit 0; } GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () { diff --git a/test/gstack-settings-hook-schema-aware.test.ts b/test/gstack-settings-hook-schema-aware.test.ts index 73b732de8..174892091 100644 --- a/test/gstack-settings-hook-schema-aware.test.ts +++ b/test/gstack-settings-hook-schema-aware.test.ts @@ -846,6 +846,152 @@ describe('Memorable UserPromptSubmit hook ownership', () => { }); }); +describe('remove-source: identity-aware (tag OR table)', () => { + // Claude Code strips _gstack_source when it rewrites settings.json. A + // tag-only remove-source therefore no-ops on exactly the entries it was + // written for (the PR #2831 disable bug). Identity via KNOWN_HOOKS now + // drives removal; the tag is metadata. + const memo = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook'; + const foreign = '/Users/me/my-user-prompt-hook'; + const vendor = '"/Users/me/.memorable/bin/memorable" hook user-prompt'; + + test('removes an UNTAGGED single-item memorable entry by identity', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: memo, timeout: 5 }] }] }, + }, null, 2)); + const r = run(['remove-source', '--source', 'gstack-memorable']); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/removed 1 /); + expect(settings().hooks).toBeUndefined(); + }); + + test('untagged mixed entry: only the memorable item goes, the foreign item stays, no tag is added', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { UserPromptSubmit: [{ hooks: [ + { type: 'command', command: foreign }, + { type: 'command', command: memo }, + ] }] }, + }, null, 2)); + const r = run(['remove-source', '--source', 'gstack-memorable']); + expect(r.stdout).toMatch(/removed 1 /); + const entries = settings().hooks.UserPromptSubmit; + expect(entries).toHaveLength(1); + expect(entries[0].hooks).toEqual([{ type: 'command', command: foreign }]); + expect(entries[0]._gstack_source).toBeUndefined(); + }); + + test('the bash-prefixed, quoted (Windows) form is recognised and removed', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `bash "${memo}"` }] }] }, + }, null, 2)); + const r = run(['remove-source', '--source', 'gstack-memorable']); + expect(r.stdout).toMatch(/removed 1 /); + expect(settings().hooks).toBeUndefined(); + }); + + test('CRITICAL regression: identity is per source -- another source\'s tag-stripped item is never touched', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }], + PostToolUse: [{ matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/question-log-hook' }] }], + }, + }, null, 2)); + const r = run(['remove-source', '--source', 'plan-tune-cathedral']); + expect(r.stdout).toMatch(/removed 1 /); // its own tag-stripped question-log item + const s = settings(); + expect(s.hooks.Stop).toHaveLength(1); // timeline (gstack-timeline-stop) untouched + expect(s.hooks.PostToolUse).toBeUndefined(); + }); + + test('a tagged entry of source A holding an item of source B keeps B\'s item and its tag (nothing of A inside)', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { Stop: [{ _gstack_source: 'plan-tune-cathedral', hooks: [ + { type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }, + ] }] }, + }, null, 2)); + const before = fs.readFileSync(settingsFile, 'utf-8'); + const r = run(['remove-source', '--source', 'plan-tune-cathedral']); + expect(r.stdout).toMatch(/removed 0 /); + expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before); + }); + + test('a foreign-only entry is untouched byte for byte and no backup is written', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: foreign }] }, { hooks: [{ type: 'command', command: vendor }] }] }, + }, null, 2)); + const before = fs.readFileSync(settingsFile, 'utf-8'); + const r = run(['remove-source', '--source', 'gstack-memorable']); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/removed 0 /); + expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before); + expect(backups()).toEqual([]); + }); + + test('a tagged legacy stray (single item, no table row) is still removed', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { UserPromptSubmit: [{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command', command: '/legacy/anything' }] }] }, + }, null, 2)); + const r = run(['remove-source', '--source', 'gstack-memorable']); + expect(r.stdout).toMatch(/removed 1 /); + expect(settings().hooks).toBeUndefined(); + }); +}); + +describe('list-items: read-only identity view', () => { + const memo = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook'; + const foreign = '/Users/me/my-user-prompt-hook'; + const vendor = '"/Users/me/.memorable/bin/memorable" hook user-prompt'; + const weird = '/tab\tand\nnewline/hook'; + const seed = () => fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { UserPromptSubmit: [ + { hooks: [{ type: 'command', command: foreign }, { type: 'command', command: memo }] }, + { hooks: [{ type: 'command', command: vendor }] }, + { hooks: [{ type: 'command', command: weird }] }, + ] }, + }, null, 2)); + + test('--owned-by prints only the table-identified item, as a JSON string literal, tag or no tag', () => { + seed(); + const r = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable']); + expect(r.exitCode).toBe(0); + expect(r.stdout.trim().split('\n')).toEqual([JSON.stringify(memo)]); + }); + + test('--command-regex is a JavaScript RegExp applied only to items no table row owns', () => { + seed(); + const r = run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', '[Mm]emorable.*hook\\s+user-prompt']); + expect(r.stdout.trim().split('\n')).toEqual([JSON.stringify(vendor)]); + }); + + test('every line is one JSON literal: tabs and newlines inside a command cannot split it', () => { + seed(); + const r = run(['list-items', '--event', 'UserPromptSubmit']); + const lines = r.stdout.trim().split('\n'); + expect(lines).toHaveLength(4); + expect(lines.map((l) => JSON.parse(l))).toEqual([foreign, memo, vendor, weird]); + }); + + test('no matches, an unknown event, or no settings file -> empty stdout, exit 0', () => { + seed(); + expect(run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'verify-gate'])).toMatchObject({ exitCode: 0, stdout: '' }); + expect(run(['list-items', '--event', 'Notification'])).toMatchObject({ exitCode: 0, stdout: '' }); + fs.rmSync(settingsFile); + expect(run(['list-items', '--event', 'UserPromptSubmit'])).toMatchObject({ exitCode: 0, stdout: '' }); + }); + + test('exit codes mirror the mutating verbs: 1 usage, 3 unparseable, 4 unexpected shape', () => { + seed(); + expect(run(['list-items']).exitCode).toBe(1); + expect(run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', '(']).exitCode).toBe(1); + fs.writeFileSync(settingsFile, '{bad json'); + expect(run(['list-items', '--event', 'UserPromptSubmit']).exitCode).toBe(3); + fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: {} } })); + const r = run(['list-items', '--event', 'UserPromptSubmit']); + expect(r.exitCode).toBe(4); + expect(r.stderr).toContain('not an array'); + }); +}); + describe('prune-stale', () => { test('prunes dead gstack items; keeps live gstack and dead non-gstack', () => { const canon = mkCanon(tmpDir);