diff --git a/src/cli/hooks-cli.test.ts b/src/cli/hooks-cli.test.ts index 5322ae7606c..194c24a3072 100644 --- a/src/cli/hooks-cli.test.ts +++ b/src/cli/hooks-cli.test.ts @@ -20,9 +20,10 @@ const report: HookStatusReport = { homepage: "https://docs.openclaw.ai/automation/hooks#session-memory", events: ["command:new"], always: false, - disabled: false, - eligible: true, + enabledByConfig: true, requirementsSatisfied: true, + loadable: true, + blockedReason: undefined, managedByPlugin: false, ...createEmptyInstallChecks(), }, @@ -59,9 +60,10 @@ describe("hooks cli formatting", () => { homepage: undefined, events: ["command:new"], always: false, - disabled: false, - eligible: true, + enabledByConfig: true, requirementsSatisfied: true, + loadable: true, + blockedReason: undefined, managedByPlugin: true, ...createEmptyInstallChecks(), }, diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 1a463615b1d..ba6055a0d29 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -16,6 +16,7 @@ import { resolveHookInstallDir, } from "../hooks/install.js"; import { recordHookInstall } from "../hooks/installs.js"; +import { resolveHookEntries } from "../hooks/policy.js"; import type { HookEntry } from "../hooks/types.js"; import { loadWorkspaceHookEntries } from "../hooks/workspace.js"; import { resolveArchiveKind } from "../infra/archive.js"; @@ -53,14 +54,7 @@ export type HooksUpdateOptions = { }; function mergeHookEntries(pluginEntries: HookEntry[], workspaceEntries: HookEntry[]): HookEntry[] { - const merged = new Map(); - for (const entry of pluginEntries) { - merged.set(entry.hook.name, entry); - } - for (const entry of workspaceEntries) { - merged.set(entry.hook.name, entry); - } - return Array.from(merged.values()); + return resolveHookEntries([...pluginEntries, ...workspaceEntries]); } function buildHooksReport(config: OpenClawConfig): HookStatusReport { @@ -86,10 +80,7 @@ function resolveHookForToggle( `Hook "${hookName}" is managed by plugin "${hook.pluginId ?? "unknown"}" and cannot be enabled/disabled.`, ); } - if (opts?.requireEligible && !hook.eligible) { - if (hook.disabled && hook.requirementsSatisfied) { - return hook; - } + if (opts?.requireEligible && !hook.requirementsSatisfied) { throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`); } return hook; @@ -120,10 +111,10 @@ function buildConfigWithHookEnabled(params: { } function formatHookStatus(hook: HookStatusEntry): string { - if (hook.eligible) { + if (hook.loadable) { return theme.success("✓ ready"); } - if (hook.disabled) { + if (!hook.enabledByConfig) { return theme.warn("⏸ disabled"); } return theme.error("✗ missing"); @@ -245,7 +236,7 @@ function enableInternalHookEntries(config: OpenClawConfig, hookNames: string[]): * Format the hooks list output */ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions): string { - const hooks = opts.eligible ? report.hooks.filter((h) => h.eligible) : report.hooks; + const hooks = opts.eligible ? report.hooks.filter((h) => h.loadable) : report.hooks; if (opts.json) { const jsonReport = { @@ -255,8 +246,12 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions name: h.name, description: h.description, emoji: h.emoji, - eligible: h.eligible, - disabled: h.disabled, + eligible: h.loadable, + disabled: !h.enabledByConfig, + enabledByConfig: h.enabledByConfig, + requirementsSatisfied: h.requirementsSatisfied, + loadable: h.loadable, + blockedReason: h.blockedReason, source: h.source, pluginId: h.pluginId, events: h.events, @@ -275,7 +270,7 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions return message; } - const eligible = hooks.filter((h) => h.eligible); + const eligible = hooks.filter((h) => h.loadable); const tableWidth = getTerminalTableWidth(); const rows = hooks.map((hook) => { const missing = formatHookMissingSummary(hook); @@ -330,14 +325,22 @@ export function formatHookInfo( } if (opts.json) { - return JSON.stringify(hook, null, 2); + return JSON.stringify( + { + ...hook, + eligible: hook.loadable, + disabled: !hook.enabledByConfig, + }, + null, + 2, + ); } const lines: string[] = []; const emoji = hook.emoji ?? "🔗"; - const status = hook.eligible + const status = hook.loadable ? theme.success("✓ Ready") - : hook.disabled + : !hook.enabledByConfig ? theme.warn("⏸ Disabled") : theme.error("✗ Missing requirements"); @@ -364,6 +367,9 @@ export function formatHookInfo( if (hook.managedByPlugin) { lines.push(theme.muted(" Managed by plugin; enable/disable via hooks CLI not available.")); } + if (hook.blockedReason) { + lines.push(`${theme.muted(" Blocked reason:")} ${hook.blockedReason}`); + } // Requirements const hasRequirements = @@ -420,8 +426,8 @@ export function formatHookInfo( */ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptions): string { if (opts.json) { - const eligible = report.hooks.filter((h) => h.eligible); - const notEligible = report.hooks.filter((h) => !h.eligible); + const eligible = report.hooks.filter((h) => h.loadable); + const notEligible = report.hooks.filter((h) => !h.loadable); return JSON.stringify( { total: report.hooks.length, @@ -431,6 +437,7 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio eligible: eligible.map((h) => h.name), notEligible: notEligible.map((h) => ({ name: h.name, + blockedReason: h.blockedReason, missing: h.missing, })), }, @@ -440,8 +447,8 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio ); } - const eligible = report.hooks.filter((h) => h.eligible); - const notEligible = report.hooks.filter((h) => !h.eligible); + const eligible = report.hooks.filter((h) => h.loadable); + const notEligible = report.hooks.filter((h) => !h.loadable); const lines: string[] = []; lines.push(theme.heading("Hooks Status")); @@ -455,8 +462,8 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio lines.push(theme.heading("Hooks not ready:")); for (const hook of notEligible) { const reasons = []; - if (hook.disabled) { - reasons.push("disabled"); + if (hook.blockedReason && hook.blockedReason !== "missing requirements") { + reasons.push(hook.blockedReason); } if (hook.missing.bins.length > 0) { reasons.push(`bins: ${hook.missing.bins.join(", ")}`); diff --git a/src/commands/onboard-hooks.test.ts b/src/commands/onboard-hooks.test.ts index 095b61495d8..4dddce29b9e 100644 --- a/src/commands/onboard-hooks.test.ts +++ b/src/commands/onboard-hooks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { HookStatusReport } from "../hooks/hooks-status.js"; +import type { HookStatusEntry, HookStatusReport } from "../hooks/hooks-status.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { setupInternalHooks } from "./onboard-hooks.js"; @@ -53,14 +53,17 @@ describe("onboard-hooks", () => { }, eligible: boolean, ) => ({ + blockedReason: (eligible + ? undefined + : "missing requirements") as HookStatusEntry["blockedReason"], ...params, source: "openclaw-bundled" as const, pluginId: undefined, homepage: undefined, always: false, - disabled: false, - eligible, + enabledByConfig: eligible, requirementsSatisfied: eligible, + loadable: eligible, managedByPlugin: false, requirements: { bins: [], diff --git a/src/commands/onboard-hooks.ts b/src/commands/onboard-hooks.ts index 7286673ce29..6f3d0c0b2fe 100644 --- a/src/commands/onboard-hooks.ts +++ b/src/commands/onboard-hooks.ts @@ -25,7 +25,7 @@ export async function setupInternalHooks( const report = buildWorkspaceHookStatus(workspaceDir, { config: cfg }); // Show every eligible hook so users can opt in during setup. - const eligibleHooks = report.hooks.filter((h) => h.eligible); + const eligibleHooks = report.hooks.filter((h) => h.loadable); if (eligibleHooks.length === 0) { await prompter.note( diff --git a/src/hooks/config.ts b/src/hooks/config.ts index e0afb5c47b7..7d5e182fa09 100644 --- a/src/hooks/config.ts +++ b/src/hooks/config.ts @@ -6,7 +6,7 @@ import { resolveConfigPath, resolveRuntimePlatform, } from "../shared/config-eval.js"; -import { resolveHookKey } from "./frontmatter.js"; +import { resolveHookConfig, resolveHookEnableState } from "./policy.js"; import type { HookEligibilityContext, HookEntry } from "./types.js"; const DEFAULT_CONFIG_VALUES: Record = { @@ -21,37 +21,7 @@ export function isConfigPathTruthy(config: OpenClawConfig | undefined, pathStr: return isConfigPathTruthyWithDefaults(config, pathStr, DEFAULT_CONFIG_VALUES); } -export function resolveHookConfig( - config: OpenClawConfig | undefined, - hookKey: string, -): HookConfig | undefined { - const hooks = config?.hooks?.internal?.entries; - if (!hooks || typeof hooks !== "object") { - return undefined; - } - const entry = (hooks as Record)[hookKey]; - if (!entry || typeof entry !== "object") { - return undefined; - } - return entry; -} - -export function isHookDisabledByConfig(params: { - entry: HookEntry; - config?: OpenClawConfig; - hookConfig?: HookConfig; -}): boolean { - const { entry, config } = params; - const hookKey = resolveHookKey(entry.hook.name, entry); - const hookConfig = params.hookConfig ?? resolveHookConfig(config, hookKey); - if (entry.hook.source === "openclaw-plugin") { - return false; - } - if (hookConfig?.enabled === false) { - return true; - } - return entry.hook.source === "openclaw-workspace" && hookConfig?.enabled !== true; -} +export { resolveHookConfig }; function evaluateHookRuntimeEligibility(params: { entry: HookEntry; @@ -83,8 +53,11 @@ export function shouldIncludeHook(params: { eligibility?: HookEligibilityContext; }): boolean { const { entry, config, eligibility } = params; - const hookConfig = resolveHookConfig(config, resolveHookKey(entry.hook.name, entry)); - if (isHookDisabledByConfig({ entry, config, hookConfig })) { + const hookConfig = resolveHookConfig( + config, + params.entry.metadata?.hookKey ?? params.entry.hook.name, + ); + if (!resolveHookEnableState({ entry, config, hookConfig }).enabled) { return false; } diff --git a/src/hooks/hooks-status.ts b/src/hooks/hooks-status.ts index 9cf0aa6e9f9..318a9c4aefc 100644 --- a/src/hooks/hooks-status.ts +++ b/src/hooks/hooks-status.ts @@ -3,12 +3,13 @@ import type { OpenClawConfig } from "../config/config.js"; import { evaluateEntryRequirementsForCurrentPlatform } from "../shared/entry-status.js"; import type { RequirementConfigCheck, Requirements } from "../shared/requirements.js"; import { CONFIG_DIR } from "../utils.js"; +import { hasBinary, isConfigPathTruthy } from "./config.js"; import { - hasBinary, - isConfigPathTruthy, - isHookDisabledByConfig, resolveHookConfig, -} from "./config.js"; + resolveHookEnableState, + resolveHookEntries, + type HookEnableStateReason, +} from "./policy.js"; import type { HookEligibilityContext, HookEntry, HookInstallSpec } from "./types.js"; import { loadWorkspaceHookEntries } from "./workspace.js"; @@ -34,9 +35,10 @@ export type HookStatusEntry = { homepage?: string; events: string[]; always: boolean; - disabled: boolean; - eligible: boolean; + enabledByConfig: boolean; requirementsSatisfied: boolean; + loadable: boolean; + blockedReason?: HookEnableStateReason | "missing requirements"; managedByPlugin: boolean; requirements: Requirements; missing: Requirements; @@ -90,7 +92,7 @@ function buildHookStatus( const hookKey = resolveHookKey(entry); const hookConfig = resolveHookConfig(config, hookKey); const managedByPlugin = entry.hook.source === "openclaw-plugin"; - const disabled = isHookDisabledByConfig({ entry, config, hookConfig }); + const enableState = resolveHookEnableState({ entry, config, hookConfig }); const always = entry.metadata?.always === true; const events = entry.metadata?.events ?? []; const isEnvSatisfied = (envName: string) => @@ -107,7 +109,10 @@ function buildHookStatus( isConfigSatisfied, }); - const eligible = !disabled && requirementsSatisfied; + const enabledByConfig = enableState.enabled; + const loadable = enabledByConfig && requirementsSatisfied; + const blockedReason = + enableState.reason ?? (requirementsSatisfied ? undefined : "missing requirements"); return { name: entry.hook.name, @@ -122,9 +127,10 @@ function buildHookStatus( homepage, events, always, - disabled, - eligible, + enabledByConfig, requirementsSatisfied, + loadable, + blockedReason, managedByPlugin, requirements: required, missing, @@ -143,7 +149,9 @@ export function buildWorkspaceHookStatus( }, ): HookStatusReport { const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks"); - const hookEntries = opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts); + const hookEntries = resolveHookEntries( + opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts), + ); return { workspaceDir, diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts index 10dd8214a55..19554d1e1d8 100644 --- a/src/hooks/loader.ts +++ b/src/hooks/loader.ts @@ -11,7 +11,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { openBoundaryFile } from "../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { sanitizeForLog } from "../terminal/ansi.js"; -import { resolveHookConfig } from "./config.js"; import { shouldIncludeHook } from "./config.js"; import { buildImportUrl } from "./import-url.js"; import type { InternalHookHandler } from "./internal-hooks.js"; @@ -85,13 +84,6 @@ export async function loadInternalHooks( const eligible = hookEntries.filter((entry) => shouldIncludeHook({ entry, config: cfg })); for (const entry of eligible) { - const hookConfig = resolveHookConfig(cfg, entry.hook.name); - - // Skip if explicitly disabled in config - if (hookConfig?.enabled === false) { - continue; - } - try { const hookBaseDir = resolveExistingRealpath(entry.hook.baseDir); if (!hookBaseDir) { diff --git a/src/hooks/policy.test.ts b/src/hooks/policy.test.ts new file mode 100644 index 00000000000..39e9a6447c0 --- /dev/null +++ b/src/hooks/policy.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveHookEnableState, resolveHookEntries } from "./policy.js"; +import type { HookEntry, HookSource } from "./types.js"; + +function makeHookEntry(name: string, source: HookSource): HookEntry { + return { + hook: { + name, + description: `${name} description`, + source, + filePath: `/tmp/${source}/${name}/HOOK.md`, + baseDir: `/tmp/${source}/${name}`, + handlerPath: `/tmp/${source}/${name}/handler.js`, + }, + frontmatter: { + name, + }, + metadata: { + events: ["command:new"], + }, + invocation: { + enabled: true, + }, + }; +} + +describe("hook policy", () => { + describe("resolveHookEnableState", () => { + it("keeps workspace hooks disabled by default", () => { + const entry = makeHookEntry("workspace-hook", "openclaw-workspace"); + expect(resolveHookEnableState({ entry })).toEqual({ + enabled: false, + reason: "workspace hook (disabled by default)", + }); + }); + + it("allows workspace hooks when explicitly enabled", () => { + const entry = makeHookEntry("workspace-hook", "openclaw-workspace"); + const config: OpenClawConfig = { + hooks: { + internal: { + entries: { + "workspace-hook": { + enabled: true, + }, + }, + }, + }, + }; + expect(resolveHookEnableState({ entry, config })).toEqual({ enabled: true }); + }); + + it("keeps plugin hooks enabled without local hook toggles", () => { + const entry = makeHookEntry("plugin-hook", "openclaw-plugin"); + expect(resolveHookEnableState({ entry })).toEqual({ enabled: true }); + }); + }); + + describe("resolveHookEntries", () => { + it("lets managed hooks override bundled and plugin hooks", () => { + const bundled = makeHookEntry("shared", "openclaw-bundled"); + const plugin = makeHookEntry("shared", "openclaw-plugin"); + const managed = makeHookEntry("shared", "openclaw-managed"); + + const resolved = resolveHookEntries([bundled, plugin, managed]); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.hook.source).toBe("openclaw-managed"); + }); + + it("prevents workspace hooks from overriding non-workspace hooks", () => { + const managed = makeHookEntry("shared", "openclaw-managed"); + const workspace = makeHookEntry("shared", "openclaw-workspace"); + + const resolved = resolveHookEntries([managed, workspace]); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.hook.source).toBe("openclaw-managed"); + }); + + it("keeps later workspace entries for the same source/name", () => { + const first = makeHookEntry("shared", "openclaw-workspace"); + const second = makeHookEntry("shared", "openclaw-workspace"); + second.hook.handlerPath = "/tmp/openclaw-workspace/shared/handler-2.js"; + + const resolved = resolveHookEntries([first, second]); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.hook.handlerPath).toContain("handler-2"); + }); + }); +}); diff --git a/src/hooks/policy.ts b/src/hooks/policy.ts new file mode 100644 index 00000000000..2fbaa83cbe8 --- /dev/null +++ b/src/hooks/policy.ts @@ -0,0 +1,143 @@ +import type { OpenClawConfig, HookConfig } from "../config/config.js"; +import { resolveHookKey } from "./frontmatter.js"; +import type { HookEntry, HookSource } from "./types.js"; + +export type HookEnableStateReason = "disabled in config" | "workspace hook (disabled by default)"; + +export type HookEnableState = { + enabled: boolean; + reason?: HookEnableStateReason; +}; + +export type HookSourcePolicy = { + precedence: number; + trustedLocalCode: boolean; + defaultEnableMode: "default-on" | "explicit-opt-in"; + canOverride: HookSource[]; + canBeOverriddenBy: HookSource[]; +}; + +export type HookResolutionCollision = { + name: string; + kept: HookEntry; + ignored: HookEntry; +}; + +const HOOK_SOURCE_POLICIES: Record = { + "openclaw-bundled": { + precedence: 10, + trustedLocalCode: true, + defaultEnableMode: "default-on", + canOverride: ["openclaw-bundled"], + canBeOverriddenBy: ["openclaw-managed", "openclaw-plugin"], + }, + "openclaw-plugin": { + precedence: 20, + trustedLocalCode: true, + defaultEnableMode: "default-on", + canOverride: ["openclaw-bundled", "openclaw-plugin"], + canBeOverriddenBy: ["openclaw-managed"], + }, + "openclaw-managed": { + precedence: 30, + trustedLocalCode: true, + defaultEnableMode: "default-on", + canOverride: ["openclaw-bundled", "openclaw-managed", "openclaw-plugin"], + canBeOverriddenBy: ["openclaw-managed"], + }, + "openclaw-workspace": { + precedence: 40, + trustedLocalCode: true, + defaultEnableMode: "explicit-opt-in", + canOverride: ["openclaw-workspace"], + canBeOverriddenBy: ["openclaw-workspace"], + }, +}; + +export function getHookSourcePolicy(source: HookSource): HookSourcePolicy { + return HOOK_SOURCE_POLICIES[source]; +} + +export function resolveHookConfig( + config: OpenClawConfig | undefined, + hookKey: string, +): HookConfig | undefined { + const hooks = config?.hooks?.internal?.entries; + if (!hooks || typeof hooks !== "object") { + return undefined; + } + const entry = (hooks as Record)[hookKey]; + if (!entry || typeof entry !== "object") { + return undefined; + } + return entry; +} + +export function resolveHookEnableState(params: { + entry: HookEntry; + config?: OpenClawConfig; + hookConfig?: HookConfig; +}): HookEnableState { + const { entry, config } = params; + const hookKey = resolveHookKey(entry.hook.name, entry); + const hookConfig = params.hookConfig ?? resolveHookConfig(config, hookKey); + + if (entry.hook.source === "openclaw-plugin") { + return { enabled: true }; + } + if (hookConfig?.enabled === false) { + return { enabled: false, reason: "disabled in config" }; + } + + const sourcePolicy = getHookSourcePolicy(entry.hook.source); + if (sourcePolicy.defaultEnableMode === "explicit-opt-in" && hookConfig?.enabled !== true) { + return { enabled: false, reason: "workspace hook (disabled by default)" }; + } + + return { enabled: true }; +} + +function canOverrideHook(candidate: HookEntry, existing: HookEntry): boolean { + const candidatePolicy = getHookSourcePolicy(candidate.hook.source); + const existingPolicy = getHookSourcePolicy(existing.hook.source); + return ( + candidatePolicy.canOverride.includes(existing.hook.source) && + existingPolicy.canBeOverriddenBy.includes(candidate.hook.source) + ); +} + +export function resolveHookEntries( + entries: HookEntry[], + opts?: { + onCollisionIgnored?: (collision: HookResolutionCollision) => void; + }, +): HookEntry[] { + const ordered = entries + .map((entry, index) => ({ entry, index })) + .toSorted((a, b) => { + const precedenceDelta = + getHookSourcePolicy(a.entry.hook.source).precedence - + getHookSourcePolicy(b.entry.hook.source).precedence; + return precedenceDelta !== 0 ? precedenceDelta : a.index - b.index; + }); + + const merged = new Map(); + for (const { entry } of ordered) { + const existing = merged.get(entry.hook.name); + if (!existing) { + merged.set(entry.hook.name, entry); + continue; + } + if (canOverrideHook(entry, existing)) { + merged.set(entry.hook.name, entry); + continue; + } + opts?.onCollisionIgnored?.({ + name: entry.hook.name, + kept: existing, + ignored: entry, + }); + } + + return Array.from(merged.values()); +} diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index a963da04c56..f44ac139afa 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -14,6 +14,7 @@ import { resolveOpenClawMetadata, } from "./frontmatter.js"; import { resolvePluginHookDirs } from "./plugin-hooks.js"; +import { resolveHookEntries } from "./policy.js"; import type { Hook, HookEligibilityContext, @@ -238,7 +239,7 @@ export function loadHookEntriesFromDir(params: { }); } -function loadHookEntries( +export function discoverWorkspaceHookEntries( workspaceDir: string, opts?: { config?: OpenClawConfig; @@ -287,32 +288,7 @@ function loadHookEntries( source: "openclaw-workspace", }); - const merged = new Map(); - // Precedence: extra < bundled < plugin < managed < workspace (workspace wins) - for (const entry of extraHooks) { - merged.set(entry.hook.name, entry); - } - for (const entry of bundledHooks) { - merged.set(entry.hook.name, entry); - } - for (const entry of pluginHooks) { - merged.set(entry.hook.name, entry); - } - for (const entry of managedHooks) { - merged.set(entry.hook.name, entry); - } - for (const entry of workspaceHooks) { - const existing = merged.get(entry.hook.name); - if (existing && existing.hook.source !== "openclaw-workspace") { - log.warn( - `Ignoring workspace hook "${entry.hook.name}" because it collides with ${existing.hook.source} hook code`, - ); - continue; - } - merged.set(entry.hook.name, entry); - } - - return Array.from(merged.values()); + return [...extraHooks, ...bundledHooks, ...pluginHooks, ...managedHooks, ...workspaceHooks]; } export function buildWorkspaceHookSnapshot( @@ -326,7 +302,7 @@ export function buildWorkspaceHookSnapshot( snapshotVersion?: number; }, ): HookSnapshot { - const hookEntries = opts?.entries ?? loadHookEntries(workspaceDir, opts); + const hookEntries = opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts); const eligible = filterHookEntries(hookEntries, opts?.config, opts?.eligibility); return { @@ -345,9 +321,16 @@ export function loadWorkspaceHookEntries( config?: OpenClawConfig; managedHooksDir?: string; bundledHooksDir?: string; + entries?: HookEntry[]; }, ): HookEntry[] { - return loadHookEntries(workspaceDir, opts); + return resolveHookEntries(opts?.entries ?? discoverWorkspaceHookEntries(workspaceDir, opts), { + onCollisionIgnored: ({ name, kept, ignored }) => { + log.warn( + `Ignoring ${ignored.hook.source} hook "${name}" because it cannot override ${kept.hook.source} hook code`, + ); + }, + }); } function readBoundaryFileUtf8(params: {