Build your own Claude Code
Hooks·28 min

Matcher + dispatch

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage matcher-and-dispatch --watch

You know the contract; now build the two pieces that gate every hook. First, the matcher: given a configured matcher string and the tool_name that just fired, does this hook apply? Second, the exit-code interpreter: given the code a hook subprocess exited with (and its stderr), what does the agent do — proceed, block, or warn? These are small, total functions with no side effects, which is exactly why they're graded as pure adapters: every (matcher, tool) and every (code, stderr) pair has one correct answer.

What to build

Your task

Implement matchHook and interpretExitCode

In src/hooks.ts, implement two functions.

matchHook(matcher, toolName) returns whether a hook with this matcher fires for toolName:

  • matcher is undefined, "", or "*" → true (matches every tool).
  • matcher is only word characters and pipes (/^[\w|]+$/) → split on | and return whether toolName is in the list. So "Bash" matches only "Bash"; "Edit|Write" matches "Edit" or "Write".
  • Otherwise → compile matcher as a JS RegExp and .test(toolName). Wrap in try/catch: an invalid pattern returns false, never throws. (This is why "mcp__.*" matches "mcp__github__create".)

interpretExitCode(code, stderr) returns { block, warn, reason }:

  • code === 0 → { block: false, warn: false, reason: null } (success — the caller then parses stdout).
  • code === 2 → { block: true, warn: false, reason: stderr.trim() || "hook blocked" }.
  • any other code → { block: false, warn: true, reason: stderr.trim() || null }.

Then make program.ts hook-match <fixture> map matchHook over case.cases and interpretExitCode over case.exitCases, printing { matches, exits }.

src/hooks.ts
/** Match a hook `matcher` against a tool name. `*`/""/undefined = all;
 *  `A|B` = name list; anything else = JS regex (invalid → false). */
export function matchHook(matcher: string | undefined, toolName: string): boolean {
  // 1. undefined / "" / "*"  -> true
  // 2. /^[\w|]+$/.test(matcher)  -> matcher.split("|").includes(toolName)
  // 3. else  -> try { new RegExp(matcher).test(toolName) } catch { false }
}

/** Interpret a hook subprocess exit code: 0 = parse stdout, 2 = block
 *  (stderr is the reason), other = non-blocking warning. */
export function interpretExitCode(
  code: number,
  stderr: string,
): { block: boolean; warn: boolean; reason: string | null } {
  // 0 -> { block:false, warn:false, reason:null }
  // 2 -> { block:true,  warn:false, reason: stderr.trim() || "hook blocked" }
  // _ -> { block:false, warn:true,  reason: stderr.trim() || null }
}
A pipe list is a name list, not a regex alternation

The single trickiest case: "Edit|Write" passes the /^[\w|]+$/ test, so it's a literal name list — split on |, check membership. Do not fall through to new RegExp("Edit|Write"), because as a regex that would also match "Rewrite", "EditConfig", anything containing Edit or Write. The lexical test is what keeps Edit|Write exact. Only matchers with non-word characters (., *, ^) reach the regex branch.

Exit 0 means "success", not "allow"

code === 0 returns block:false, warn:false — but that doesn't mean "allow." It means "the hook ran cleanly; now parse its stdout" (next step's job). A 0 hook that printed { "decision": "block" } still blocks — via stdout, not the exit code. interpretExitCode only handles the exit-code half of the verdict; exit 2 is the one code that blocks all on its own, with stderr as the reason.

How it's graded

adapter mode, stdout_json_match. program.ts hook-match runs matchHook over case.cases (each { matcher, tool }) and interpretExitCode over case.exitCases (each { code, stderr? }), then prints { matches: boolean[], exits: {...}[] }. The fixture's case.json carries Bash/Bash, Edit|Write/Write, mcp__.*/mcp__github__create, and Bash/Read, plus exit codes 0, 2, and 1 — so the expected output is matches:[true,true,true,false] and the three exit verdicts. Every classification is compared exactly.

How the grader runs it
./your_program.sh hook-match fixtures/case.json
# cases:     Bash/Bash, Edit|Write/Write, mcp__.*/mcp__github__create, Bash/Read
# exitCases: {0}, {2,"blocked"}, {1,"warn"}
# -> {"matches":[true,true,true,false],
#     "exits":[{"block":false,"warn":false,"reason":null},
#              {"block":true,"warn":false,"reason":"blocked"},
#              {"block":false,"warn":true,"reason":"warn"}]}