From 5a0880bf3550b3eeef7777e3abb59f11678d989c Mon Sep 17 00:00:00 2001 From: ManniX-ITA <20623405+mann1x@users.noreply.github.com> Date: Fri, 1 May 2026 13:39:50 +0200 Subject: [PATCH 1/2] init/update: tag hook entries with _managedBy: "openwolf" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `_managedBy: "openwolf"` to every hook object in `HOOK_SETTINGS` so Claude Code's settings round-tripper recognizes them as third-party managed entries and preserves them through `/effort`, `/config`, and similar rewrites. Without the tag, entries get silently dropped: a working OpenWolf install can be de-wired by typing `/effort medium` once, since Claude Code's merge logic only preserves entries it recognizes as owned (claude-hooks uses the same field, for example). Also tightens `replaceOpenWolfHooks` to recognize the new tag in addition to the legacy `.wolf/hooks/` substring — defensive against future path schema changes, and keeps the dedupe correct for installs upgrading from a pre-tag version. The two changes are minimal and backward-compatible: untagged entries from older installs still match the substring fallback, so upgrades clean up cleanly. New installs get tagged from the start. Fixes #31. --- src/cli/init.ts | 17 ++++++++++++++--- src/cli/update.ts | 27 ++++++++++++++++++--------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/cli/init.ts b/src/cli/init.ts index 0414bb7..62e4d5d 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -55,6 +55,7 @@ const HOOK_SETTINGS = { type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js"', timeout: 5, + _managedBy: "openwolf", }, ], }, @@ -67,6 +68,7 @@ const HOOK_SETTINGS = { type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-read.js"', timeout: 5, + _managedBy: "openwolf", }, ], }, @@ -77,6 +79,7 @@ const HOOK_SETTINGS = { type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-write.js"', timeout: 5, + _managedBy: "openwolf", }, ], }, @@ -89,6 +92,7 @@ const HOOK_SETTINGS = { type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-read.js"', timeout: 5, + _managedBy: "openwolf", }, ], }, @@ -99,6 +103,7 @@ const HOOK_SETTINGS = { type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"', timeout: 10, + _managedBy: "openwolf", }, ], }, @@ -111,6 +116,7 @@ const HOOK_SETTINGS = { type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js"', timeout: 10, + _managedBy: "openwolf", }, ], }, @@ -480,17 +486,22 @@ function replaceOpenWolfHooks( if (!merged.hooks) { merged.hooks = {}; } - const hooks = merged.hooks as Record }>>; + const hooks = merged.hooks as Record }>>; for (const [event, newMatchers] of Object.entries(hookSettings.hooks)) { if (!hooks[event]) { hooks[event] = []; } - // Remove any existing OpenWolf hook entries (match by .wolf/hooks/ in command) + // Remove any existing OpenWolf hook entries. Prefer the explicit + // `_managedBy: "openwolf"` tag, fall back to the legacy + // `.wolf/hooks/` substring match so we still clean up entries + // installed by versions before the tag existed. hooks[event] = hooks[event].filter((entry) => { const isOpenWolfHook = entry.hooks?.some( - (h) => h.command && h.command.includes(".wolf/hooks/") + (h) => + h._managedBy === "openwolf" || + (h.command && h.command.includes(".wolf/hooks/")) ); return !isOpenWolfHook; }); diff --git a/src/cli/update.ts b/src/cli/update.ts index 33cf5cd..47a8ac4 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -43,18 +43,22 @@ const BACKUP_FILES = [ ...USER_DATA_FILES, ]; +// Every hook entry carries `_managedBy: "openwolf"`. Claude Code's +// settings round-tripper preserves entries with this provenance tag +// across `/effort`, `/config`, and similar rewrites; untagged entries +// get silently dropped. See cytostack/openwolf#31. const HOOK_SETTINGS = { hooks: { - SessionStart: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js"', timeout: 5 }] }], + SessionStart: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js"', timeout: 5, _managedBy: "openwolf" }] }], PreToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-read.js"', timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-write.js"', timeout: 5 }] }, + { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-read.js"', timeout: 5, _managedBy: "openwolf" }] }, + { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-write.js"', timeout: 5, _managedBy: "openwolf" }] }, ], PostToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-read.js"', timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"', timeout: 10 }] }, + { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-read.js"', timeout: 5, _managedBy: "openwolf" }] }, + { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"', timeout: 10, _managedBy: "openwolf" }] }, ], - Stop: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js"', timeout: 10 }] }], + Stop: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js"', timeout: 10, _managedBy: "openwolf" }] }], }, }; @@ -358,15 +362,20 @@ function replaceOpenWolfHooks( ): Record { const merged = { ...existing }; if (!merged.hooks) merged.hooks = {}; - const hooks = merged.hooks as Record }>>; + const hooks = merged.hooks as Record }>>; for (const [event, newMatchers] of Object.entries(hookSettings.hooks)) { if (!hooks[event]) hooks[event] = []; - // Remove existing OpenWolf hook entries + // Remove existing OpenWolf hook entries. Prefer the explicit + // `_managedBy: "openwolf"` tag, fall back to the legacy + // `.wolf/hooks/` substring match so we still clean up entries + // installed by versions before the tag existed. hooks[event] = hooks[event].filter((entry) => { const isOpenWolfHook = entry.hooks?.some( - (h) => h.command && h.command.includes(".wolf/hooks/") + (h) => + h._managedBy === "openwolf" || + (h.command && h.command.includes(".wolf/hooks/")) ); return !isOpenWolfHook; }); From e02f4c4375fbd55e9745d47767a0334a777858dd Mon Sep 17 00:00:00 2001 From: Brian Summa Date: Thu, 14 May 2026 17:44:33 -0500 Subject: [PATCH 2/2] fix(hook-settings): address PR #8 review comments - isOpenWolfHook: check _managedBy as primary signal, path substring as backward-compat fallback for pre-tag installs - Add comment documenting that _managedBy is empirically observed passthrough, not a guaranteed Claude Code field (Warning #2) - Add comment in replaceOpenWolfHooks documenting co-location assumption: one inner hook per outer entry unsupported (Warning #1) - Reformat HOOK_SETTINGS to multi-line expanded style, respects 80-char line length rule (Info #4) - Code duplication between init.ts and update.ts already resolved by prior extraction to hook-settings.ts (Info #3) Co-Authored-By: Claude Sonnet 4.6 --- src/cli/hook-settings.ts | 89 +++++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/src/cli/hook-settings.ts b/src/cli/hook-settings.ts index d2f2f7b..8103eb6 100644 --- a/src/cli/hook-settings.ts +++ b/src/cli/hook-settings.ts @@ -22,31 +22,93 @@ export const WOLF_ROOT_SHELL = const hookCmd = (script: string): string => `${WOLF_ROOT_SHELL} && node "$WOLF_ROOT/.wolf/hooks/${script}"`; +// NOTE: `_managedBy` is NOT a documented Claude Code field. It is an +// empirically observed passthrough — Claude Code preserves unknown fields +// in settings.json during its own read/write cycles as of the versions +// tested. If a future Claude Code release performs schema-validated +// serialization and strips unknown fields, `_managedBy` will silently +// disappear and identification will fall back to the `.wolf/hooks/` +// substring match in `isOpenWolfHook`. Monitor for unexpected hook +// re-registration or spurious duplicate entries as a symptom of this. export const HOOK_SETTINGS = { SessionStart: [ - { matcher: "", hooks: [{ type: "command", command: hookCmd("session-start.js"), timeout: 5, _managedBy: "openwolf" }] }, + { + matcher: "", + hooks: [{ + type: "command", + command: hookCmd("session-start.js"), + timeout: 5, + _managedBy: "openwolf", + }], + }, ], PreToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: hookCmd("pre-read.js"), timeout: 5, _managedBy: "openwolf" }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("pre-write.js"), timeout: 5, _managedBy: "openwolf" }] }, + { + matcher: "Read", + hooks: [{ + type: "command", + command: hookCmd("pre-read.js"), + timeout: 5, + _managedBy: "openwolf", + }], + }, + { + matcher: "Write|Edit|MultiEdit", + hooks: [{ + type: "command", + command: hookCmd("pre-write.js"), + timeout: 5, + _managedBy: "openwolf", + }], + }, ], PostToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: hookCmd("post-read.js"), timeout: 5, _managedBy: "openwolf" }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("post-write.js"), timeout: 10, _managedBy: "openwolf" }] }, + { + matcher: "Read", + hooks: [{ + type: "command", + command: hookCmd("post-read.js"), + timeout: 5, + _managedBy: "openwolf", + }], + }, + { + matcher: "Write|Edit|MultiEdit", + hooks: [{ + type: "command", + command: hookCmd("post-write.js"), + timeout: 10, + _managedBy: "openwolf", + }], + }, ], Stop: [ - { matcher: "", hooks: [{ type: "command", command: hookCmd("stop.js"), timeout: 10, _managedBy: "openwolf" }] }, + { + matcher: "", + hooks: [{ + type: "command", + command: hookCmd("stop.js"), + timeout: 10, + _managedBy: "openwolf", + }], + }, ], }; /** - * Returns true if a hook entry was registered by OpenWolf - * (i.e., its command references .wolf/hooks/). + * Returns true if a hook entry was registered by OpenWolf. + * + * Primary check: `_managedBy === "openwolf"` (set on every hook object + * written by this module). Fallback: `.wolf/hooks/` path substring, for + * backward compatibility with pre-tag installs that predate this field. */ export function isOpenWolfHook(hook: unknown): boolean { if (typeof hook !== "object" || hook === null) return false; const h = hook as Record; - if (typeof h.command === "string" && h.command.includes(".wolf/hooks/")) return true; + if (h._managedBy === "openwolf") return true; + if (typeof h.command === "string" && h.command.includes(".wolf/hooks/")) { + return true; + } return false; } @@ -67,7 +129,14 @@ export function replaceOpenWolfHooks( const existing_entries = Array.isArray(existingHooks[event]) ? (existingHooks[event] as unknown[]) : []; - // Keep non-OpenWolf entries the user may have added + // Keep non-OpenWolf entries the user may have added. + // + // ASSUMPTION: OpenWolf writes exactly one inner hook per outer matcher + // entry. Co-locating a user-defined command inside the same outer entry + // as an OpenWolf hook is unsupported — the entire outer entry is dropped + // and replaced if *any* inner hook matches `isOpenWolfHook`. Users who + // need custom hooks for the same event should add a separate outer + // matcher entry in settings.json. const userEntries = existing_entries.filter((entry) => { if (typeof entry !== "object" || entry === null) return true; const e = entry as Record;