Matcher + dispatch
# grade this stage (from your course directory)
npx theinference test --stage matcher-and-dispatch --watchYou 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
Implement matchHook and interpretExitCode
In src/hooks.ts, implement two functions.
matchHook(matcher, toolName) returns whether a hook with this matcher fires for toolName:
matcherisundefined,"", or"*"→true(matches every tool).matcheris only word characters and pipes (/^[\w|]+$/) → split on|and return whethertoolNameis in the list. So"Bash"matches only"Bash";"Edit|Write"matches"Edit"or"Write".- Otherwise → compile
matcheras a JSRegExpand.test(toolName). Wrap intry/catch: an invalid pattern returnsfalse, 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 }.
/** 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 }
}
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.
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.
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.
./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"}]}