mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
feat(settings-hook): identity-aware remove-source + read-only list-items
remove-source used to inspect only entries still carrying the _gstack_source tag. Claude Code strips that tag when it rewrites settings.json, so an off switch built on remove-source alone silently no-oped on exactly the entries it was written for. Removal is now driven by KNOWN_HOOKS identity for the requested source (tagged or not), keeps the tagged-single-item legacy-stray rule, never touches another source's items, and leaves entries with nothing of ours byte-identical. list-items is the read-only view of the same identity table: one JSON string literal per matching hook command, filters (--owned-by, --command-regex as a JavaScript RegExp) applied inside the JS, empty stdout for no match, and the mutating verbs' exit codes (1 usage, 3 unparseable settings, 4 unexpected shape) so callers can decide mutations from its output without parsing raw command strings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
87eb4ded5d
commit
6259b37e48
+91
-15
@@ -14,10 +14,13 @@
|
||||
# gstack-settings-hook add-event --event <name — see the validator in add-event> \
|
||||
# --command <cmd> --source <tag> [--matcher <regex>] [--timeout <s>]
|
||||
# gstack-settings-hook ensure-event --event ... --command ... --source ... [--matcher ...] [--timeout <s>]
|
||||
# gstack-settings-hook remove-source --source <tag>
|
||||
# gstack-settings-hook remove-source --source <tag> # removes items the table identifies as <tag>'s, tagged or not
|
||||
# gstack-settings-hook diff-event --event ... --command ... --source ... [--matcher ...]
|
||||
# gstack-settings-hook rollback # restore latest backup (single-step undo)
|
||||
# gstack-settings-hook list-sources # show all gstack-tagged hook entries
|
||||
# gstack-settings-hook list-items --event <name> [--owned-by <tag>] [--command-regex <js-re>]
|
||||
# # read-only: one JSON string literal per matching hook COMMAND
|
||||
# # (identity via KNOWN_HOOKS, never the tag); empty stdout = none
|
||||
#
|
||||
# 3. Self-heal (phantom-hooks fix):
|
||||
# gstack-settings-hook prune-stale # prune dead gstack hook items
|
||||
@@ -76,11 +79,12 @@ Usage:
|
||||
gstack-settings-hook remove <hook-command> # legacy SessionStart remove
|
||||
gstack-settings-hook add-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
|
||||
gstack-settings-hook ensure-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
|
||||
gstack-settings-hook remove-source --source <tag>
|
||||
gstack-settings-hook remove-source --source <tag> # tagged OR table-identified items of <tag>
|
||||
gstack-settings-hook diff-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
|
||||
gstack-settings-hook prune-stale [--repoint <root>] [--all]
|
||||
gstack-settings-hook rollback
|
||||
gstack-settings-hook list-sources
|
||||
gstack-settings-hook list-items --event <name> [--owned-by <tag>] [--command-regex <js-re>]
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
@@ -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 <name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$SETTINGS_FILE" ] || exit 0
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_LI_EVENT="$LI_EVENT" GSTACK_LI_OWNED_BY="$LI_OWNED_BY" GSTACK_LI_CMD_RE="$LI_CMD_RE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
const event = process.env.GSTACK_LI_EVENT;
|
||||
const ownedBy = process.env.GSTACK_LI_OWNED_BY || "";
|
||||
const reSrc = process.env.GSTACK_LI_CMD_RE || "";
|
||||
let re = null;
|
||||
if (reSrc) {
|
||||
try { re = new RegExp(reSrc); }
|
||||
catch (e) {
|
||||
process.stderr.write("list-items: invalid --command-regex (" + e.message + ")\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const loaded = gsLoadSettings(process.env.GSTACK_SETTINGS_PATH);
|
||||
const hooks = loaded.settings.hooks || {};
|
||||
const entries = hooks[event];
|
||||
if (entries === undefined || entries === null) process.exit(0);
|
||||
if (!Array.isArray(entries)) throw new Error("hooks." + event + " is not an array");
|
||||
for (const entry of entries) {
|
||||
if (!entry || !Array.isArray(entry.hooks)) continue; // foreign shape, preserved by prune-stale too
|
||||
for (const h of entry.hooks) {
|
||||
const cmd = (h && typeof h.command === "string") ? h.command : "";
|
||||
if (!cmd) continue;
|
||||
const row = gsOwnedRow(cmd, event, entry.matcher || "");
|
||||
if (ownedBy && (!row || row.source !== ownedBy)) continue;
|
||||
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 () {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user