fix: narrow PostToolUse matcher + remove Task from PreToolUse (#229, #241)

PostToolUse hook `matcher: ""` fired on ALL tools — 13 tools produced zero
events but caused false "hook error" display (Claude Code #41868/#35587).
PreToolUse `"Task"` matcher caught TaskCreate/TaskUpdate via substring
matching, corrupting todo tool inputs (#241).

Changes:
- PostToolUse matcher narrowed to 17 tools that extractEvents() handles
- "Task" removed from PreToolUse matchers (no standalone Task tool exists)
- Task branch removed from routing.mjs (Agent handles subagents)
- .claude-plugin/hooks/hooks.json deleted (duplicate, not in manifest)
- POST_TOOL_USE_MATCHERS constant added to hooks.ts
- Tests updated: Task passthrough verified, matcher whitelist tested

Closes #229
Closes #241

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mert Koseoglu
2026-04-13 22:10:15 +03:00
co-authored by Claude Opus 4.6
parent 04569b12a1
commit ece3abb1d3
10 changed files with 142 additions and 264 deletions
-132
View File
@@ -1,132 +0,0 @@
{
"description": "Context-mode hooks — PreToolUse routing, PostToolUse session capture, PreCompact snapshot, SessionStart context injection",
"hooks": {
"PostToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.mjs"
}
]
}
],
"PreCompact": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
},
{
"matcher": "WebFetch",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
},
{
"matcher": "Grep",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
},
{
"matcher": "Agent",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
},
{
"matcher": "mcp__plugin_context-mode_context-mode__ctx_execute",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
},
{
"matcher": "mcp__plugin_context-mode_context-mode__ctx_execute_file",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
},
{
"matcher": "mcp__plugin_context-mode_context-mode__ctx_batch_execute",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit.mjs"
}
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs"
}
]
}
]
}
}
+1 -1
View File
@@ -133,7 +133,7 @@ The symlink in step 2 ensures `hooks.json` (which registers PostToolUse, PreComp
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read|Grep|WebFetch|Agent|Task|mcp__plugin_context-mode_context-mode__ctx_execute|mcp__plugin_context-mode_context-mode__ctx_execute_file|mcp__plugin_context-mode_context-mode__ctx_batch_execute",
"matcher": "Bash|Read|Grep|WebFetch|Agent|mcp__plugin_context-mode_context-mode__ctx_execute|mcp__plugin_context-mode_context-mode__ctx_execute_file|mcp__plugin_context-mode_context-mode__ctx_batch_execute",
"hooks": [
{
"type": "command",
+2 -2
View File
@@ -294,8 +294,8 @@ export function routePreToolUse(toolName, toolInput, projectDir, platform) {
});
}
// ─── Agent/Task: inject context-mode routing into subagent prompts ───
if (canonical === "Agent" || canonical === "Task") {
// ─── Agent: inject context-mode routing into subagent prompts ───
if (canonical === "Agent") {
const subagentType = toolInput.subagent_type ?? "";
// Detect the correct field name for the prompt/request/objective/question/query
const fieldName = ["prompt", "request", "objective", "question", "query", "task"].find(f => f in toolInput) ?? "prompt";
+1 -10
View File
@@ -3,7 +3,7 @@
"hooks": {
"PostToolUse": [
{
"matcher": "",
"matcher": "Bash|Read|Write|Edit|NotebookEdit|Glob|Grep|TodoWrite|TaskCreate|TaskUpdate|EnterPlanMode|ExitPlanMode|Skill|Agent|AskUserQuestion|EnterWorktree|mcp__",
"hooks": [
{
"type": "command",
@@ -69,15 +69,6 @@
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs"
}
]
},
{
"matcher": "mcp__plugin_context-mode_context-mode__ctx_execute",
"hooks": [
+34 -1
View File
@@ -42,7 +42,6 @@ export const PRE_TOOL_USE_MATCHERS = [
"Read",
"Grep",
"Agent",
"Task",
"mcp__plugin_context-mode_context-mode__ctx_execute",
"mcp__plugin_context-mode_context-mode__ctx_execute_file",
"mcp__plugin_context-mode_context-mode__ctx_batch_execute",
@@ -54,6 +53,40 @@ export const PRE_TOOL_USE_MATCHERS = [
*/
export const PRE_TOOL_USE_MATCHER_PATTERN = PRE_TOOL_USE_MATCHERS.join("|");
// ─────────────────────────────────────────────────────────
// PostToolUse matchers (#229)
// ─────────────────────────────────────────────────────────
/**
* Tools that context-mode's PostToolUse hook should fire on.
* Only tools that extractEvents() actually handles — all others
* produce zero events and cause false "hook error" display.
*/
export const POST_TOOL_USE_MATCHERS = [
"Bash",
"Read",
"Write",
"Edit",
"NotebookEdit",
"Glob",
"Grep",
"TodoWrite",
"TaskCreate",
"TaskUpdate",
"EnterPlanMode",
"ExitPlanMode",
"Skill",
"Agent",
"AskUserQuestion",
"EnterWorktree",
"mcp__",
] as const;
/**
* Combined matcher pattern for PostToolUse in hooks.json / settings.json.
*/
export const POST_TOOL_USE_MATCHER_PATTERN = POST_TOOL_USE_MATCHERS.join("|");
// ─────────────────────────────────────────────────────────
// Hook script file names
// ─────────────────────────────────────────────────────────
+45
View File
@@ -6,6 +6,11 @@ import { join, resolve } from "node:path";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { ClaudeCodeAdapter } from "../../src/adapters/claude-code/index.js";
import { fakeHome, realHome } from "../setup-home";
import {
PRE_TOOL_USE_MATCHERS,
POST_TOOL_USE_MATCHERS,
POST_TOOL_USE_MATCHER_PATTERN,
} from "../../src/adapters/claude-code/hooks.js";
describe("ClaudeCodeAdapter", () => {
let adapter: ClaudeCodeAdapter;
@@ -557,6 +562,46 @@ describe("ClaudeCodeAdapter", () => {
});
});
// ── Hook matchers (#229, #241) ────────────────────────
describe("hook matchers (#229, #241)", () => {
it("PRE_TOOL_USE_MATCHERS does NOT contain 'Task' (#241)", () => {
expect(PRE_TOOL_USE_MATCHERS).not.toContain("Task");
});
it("PRE_TOOL_USE_MATCHERS contains 'Agent' for subagent routing", () => {
expect(PRE_TOOL_USE_MATCHERS).toContain("Agent");
});
it("POST_TOOL_USE_MATCHERS contains all tools that extractEvents handles", () => {
const required = [
"Bash", "Read", "Write", "Edit", "NotebookEdit", "Glob", "Grep",
"TodoWrite", "TaskCreate", "TaskUpdate",
"EnterPlanMode", "ExitPlanMode",
"Skill", "Agent", "AskUserQuestion", "EnterWorktree",
"mcp__",
];
for (const tool of required) {
expect(POST_TOOL_USE_MATCHERS).toContain(tool);
}
});
it("POST_TOOL_USE_MATCHERS does NOT contain tools that produce zero events (#229)", () => {
const excluded = [
"TaskGet", "TaskList", "TaskStop", "TaskOutput",
"ExitWorktree", "WebFetch", "WebSearch",
"RemoteTrigger", "CronCreate", "CronDelete", "CronList",
];
for (const tool of excluded) {
expect(POST_TOOL_USE_MATCHERS).not.toContain(tool);
}
});
it("POST_TOOL_USE_MATCHER_PATTERN is pipe-separated string", () => {
expect(POST_TOOL_USE_MATCHER_PATTERN).toBe(POST_TOOL_USE_MATCHERS.join("|"));
});
});
// ── parseSessionStartInput ────────────────────────────
describe("parseSessionStartInput", () => {
+30 -12
View File
@@ -2,50 +2,68 @@ import { describe, it, expect } from "vitest";
import { routePreToolUse } from "../../hooks/core/routing.mjs";
import { ROUTING_BLOCK } from "../../hooks/routing-block.mjs";
describe("Routing: Subagents (Agent/Task)", () => {
it("detects prompt in different field names (request, objective, etc.)", () => {
describe("Routing: Subagents (Agent only — Task removed per #241)", () => {
it("Agent tool injects routing block into prompt field", () => {
const fields = ["prompt", "request", "objective", "question", "query", "task"];
for (const field of fields) {
const toolInput = { [field]: "hello" };
const decision = routePreToolUse("Agent", toolInput, "/test");
expect(decision.action).toBe("modify");
expect(decision.updatedInput[field]).toBe("hello" + ROUTING_BLOCK);
}
});
it("falls back to 'prompt' field if no known field is present", () => {
it("Agent falls back to 'prompt' field if no known field is present", () => {
const toolInput = { unknown_field: "content" };
const decision = routePreToolUse("Agent", toolInput, "/test");
expect(decision.action).toBe("modify");
expect(decision.updatedInput.prompt).toBe(ROUTING_BLOCK);
});
it("converts subagent_type='Bash' to 'general-purpose' and injects routing", () => {
const toolInput = {
it("Agent converts subagent_type='Bash' to 'general-purpose'", () => {
const toolInput = {
prompt: "do something",
subagent_type: "Bash"
};
const decision = routePreToolUse("Agent", toolInput, "/test");
expect(decision.action).toBe("modify");
expect(decision.updatedInput.prompt).toBe("do something" + ROUTING_BLOCK);
expect(decision.updatedInput.subagent_type).toBe("general-purpose");
});
it("preserves other fields when modifying", () => {
const toolInput = {
it("Agent preserves other fields when modifying", () => {
const toolInput = {
request: "analyze this",
other_param: 123,
nested: { a: 1 }
};
const decision = routePreToolUse("Task", toolInput, "/test");
const decision = routePreToolUse("Agent", toolInput, "/test");
expect(decision.action).toBe("modify");
expect(decision.updatedInput.request).toBe("analyze this" + ROUTING_BLOCK);
expect(decision.updatedInput.other_param).toBe(123);
expect(decision.updatedInput.nested).toEqual({ a: 1 });
});
it("Task tool is NOT routed — returns null (passthrough) (#241)", () => {
const toolInput = { prompt: "create a task" };
const decision = routePreToolUse("Task", toolInput, "/test");
// Task should not be intercepted — it matches TaskCreate/TaskUpdate via substring
expect(decision).toBeNull();
});
it("TaskCreate is NOT routed — returns null (passthrough)", () => {
const decision = routePreToolUse("TaskCreate", { title: "my task" }, "/test");
expect(decision).toBeNull();
});
it("TaskUpdate is NOT routed — returns null (passthrough)", () => {
const decision = routePreToolUse("TaskUpdate", { id: "123", status: "done" }, "/test");
expect(decision).toBeNull();
});
});
+13 -30
View File
@@ -332,47 +332,30 @@ describe("routePreToolUse", () => {
});
});
// ─── Task routing ──────────────────────────────────────
// ─── Task routing (#241: removed — substring matching catches TaskCreate etc.) ──
describe("Task tool", () => {
it("injects ROUTING_BLOCK into prompt", () => {
describe("Task tool (#241)", () => {
it("returns null (passthrough) — no longer intercepted", () => {
const result = routePreToolUse("Task", {
prompt: "Analyze the codebase",
subagent_type: "general-purpose",
});
expect(result).not.toBeNull();
expect(result!.action).toBe("modify");
expect(result!.updatedInput).toBeDefined();
expect((result!.updatedInput as Record<string, string>).prompt).toContain(
"Analyze the codebase",
);
expect((result!.updatedInput as Record<string, string>).prompt).toContain(
"context_window_protection",
);
expect(result).toBeNull();
});
it("upgrades Bash subagent to general-purpose", () => {
const result = routePreToolUse("Task", {
prompt: "Run some commands",
subagent_type: "Bash",
it("TaskCreate returns null (passthrough)", () => {
const result = routePreToolUse("TaskCreate", {
title: "my task",
});
expect(result).not.toBeNull();
expect(result!.action).toBe("modify");
expect(
(result!.updatedInput as Record<string, string>).subagent_type,
).toBe("general-purpose");
expect(result).toBeNull();
});
it("keeps non-Bash subagent type unchanged", () => {
const result = routePreToolUse("Task", {
prompt: "Do research",
subagent_type: "general-purpose",
it("TaskUpdate returns null (passthrough)", () => {
const result = routePreToolUse("TaskUpdate", {
id: "123",
status: "done",
});
expect(result).not.toBeNull();
expect(result!.action).toBe("modify");
expect(
(result!.updatedInput as Record<string, string>).subagent_type,
).toBe("general-purpose");
expect(result).toBeNull();
});
});
+14 -70
View File
@@ -197,89 +197,33 @@ describe("WebFetch", () => {
});
});
describe("Task", () => {
test("Task + prompt: hookSpecificOutput with updatedInput containing routing block", () => {
describe("Task (#241: no longer routed)", () => {
test("Task tool returns empty stdout (passthrough, no routing)", () => {
const result = runHook({
tool_name: "Task",
tool_input: { prompt: "Analyze this codebase and summarize the architecture." },
});
assert.equal(result.exitCode, 0, `Expected exit 0, got ${result.exitCode}`);
assert.ok(result.stdout.length > 0, "Expected non-empty stdout");
const parsed = JSON.parse(result.stdout);
assert.ok(parsed.hookSpecificOutput, "Expected hookSpecificOutput");
assert.equal(parsed.hookSpecificOutput.hookEventName, "PreToolUse");
assert.ok(parsed.hookSpecificOutput.updatedInput, "Expected updatedInput");
assert.ok(
parsed.hookSpecificOutput.updatedInput.prompt.includes("<context_window_protection>"),
"Expected <context_window_protection> XML tag in updatedInput.prompt",
);
assert.ok(
parsed.hookSpecificOutput.updatedInput.prompt.includes("</context_window_protection>"),
"Expected </context_window_protection> closing tag in updatedInput.prompt",
);
assert.ok(
parsed.hookSpecificOutput.updatedInput.prompt.includes("<tool_selection_hierarchy>"),
"Expected <tool_selection_hierarchy> tag in updatedInput.prompt",
);
assert.ok(
parsed.hookSpecificOutput.updatedInput.prompt.includes("<forbidden_actions>"),
"Expected <forbidden_actions> tag in updatedInput.prompt",
);
assert.ok(
parsed.hookSpecificOutput.updatedInput.prompt.includes(
"Analyze this codebase and summarize the architecture.",
),
"Expected original prompt preserved in updatedInput.prompt",
);
// Task is no longer intercepted — hook produces no hookSpecificOutput
assert.equal(result.stdout.trim(), "", "Expected empty stdout for passthrough");
});
test("Task + Bash subagent: upgraded to general-purpose for MCP access", () => {
test("TaskCreate returns empty stdout (passthrough)", () => {
const result = runHook({
tool_name: "Task",
tool_input: {
prompt: "Research this GitHub repository.",
subagent_type: "Bash",
description: "Research repo",
},
tool_name: "TaskCreate",
tool_input: { title: "my task" },
});
assert.equal(result.exitCode, 0);
const parsed = JSON.parse(result.stdout);
const updated = parsed.hookSpecificOutput.updatedInput;
assert.equal(
updated.subagent_type,
"general-purpose",
`Expected subagent_type upgraded to general-purpose, got: ${updated.subagent_type}`,
);
assert.ok(
updated.prompt.includes("<context_window_protection>"),
"Expected XML routing block in prompt",
);
assert.ok(
updated.prompt.includes("Research this GitHub repository."),
"Expected original prompt preserved",
);
assert.equal(
updated.description,
"Research repo",
"Expected other fields preserved",
);
assert.equal(result.stdout.trim(), "", "Expected empty stdout for passthrough");
});
test("Task + Explore subagent: keeps original subagent_type", () => {
test("TaskUpdate returns empty stdout (passthrough)", () => {
const result = runHook({
tool_name: "Task",
tool_input: {
prompt: "Find all TypeScript files.",
subagent_type: "Explore",
},
tool_name: "TaskUpdate",
tool_input: { id: "123", status: "done" },
});
assert.equal(result.exitCode, 0);
const parsed = JSON.parse(result.stdout);
const updated = parsed.hookSpecificOutput.updatedInput;
assert.ok(
updated.subagent_type === undefined || updated.subagent_type === "Explore",
`Expected subagent_type to remain Explore or undefined, got: ${updated.subagent_type}`,
);
assert.equal(result.stdout.trim(), "", "Expected empty stdout for passthrough");
});
});
@@ -515,8 +459,8 @@ describe("Plugin Tool Name Format in ROUTING_BLOCK", () => {
const PLUGIN_PREFIX = "mcp__plugin_context-mode_context-mode__";
const SHORT_PREFIX = "mcp__context-mode__";
test("Task routing block uses plugin-format tool names", () => {
const result = runHook({ tool_name: "Task", tool_input: { prompt: "Do something." } });
test("Agent routing block uses plugin-format tool names", () => {
const result = runHook({ tool_name: "Agent", tool_input: { prompt: "Do something." } });
assert.equal(result.exitCode, 0);
const parsed = JSON.parse(result.stdout);
const prompt = parsed.hookSpecificOutput.updatedInput.prompt;
+2 -6
View File
@@ -271,15 +271,11 @@ describe("routePreToolUse with platform parameter", () => {
expect(result!.reason).toContain("@context-mode/ctx_search");
});
it("Task routing block uses opencode tool names when platform=opencode", () => {
it("Task is no longer routed — returns null (#241)", () => {
const result = routePreToolUse("Task", {
prompt: "Analyze the code",
}, "/tmp", "opencode");
expect(result).not.toBeNull();
const prompt = (result!.updatedInput as Record<string, string>).prompt;
expect(prompt).toContain("context-mode_ctx_batch_execute");
expect(prompt).toContain("context-mode_ctx_search");
expect(prompt).not.toContain("mcp__plugin_context-mode_context-mode__");
expect(result).toBeNull();
});
it("Read guidance uses vscode-copilot tool names when platform=vscode-copilot", () => {