cd /news/ai-agents/does-your-claude-code-hook-see-every… · home › topics › ai-agents › article
[ARTICLE · art-160825] src=pub.towardsai.net ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Does Your Claude Code Hook See Every Edit? An Edit|Write Matcher Saw 3 of 7

A Claude Code PostToolUse hook configured with the documented Edit|MultiEdit|Write matcher captured only 3 of the 7 file changes Claude Code 2.1.312 made in a test repo, while a Stop hook running git status --porcelain -uall caught all 7, according to a hookcheck.mjs test built around a fake Messages API served on 127.0.0.1. The four missed changes came from Bash commands (sed -i, a > redirect, rm, and git mv), which bypass the file-tool matcher. The test follows Anthropic's Addy Osmani describing on the October 28 AI Engineer channel video that hooks run on edits and study the diffs so PRs get corrected fast, a loop that depends on the hook actually seeing each edit.

read9 min views1 publishedOct 30, 2026

Anthropic’s Addy Osmani says hooks study the diffs so Claude’s PRs get corrected fast. Bash edits skip the usual matcher; a Stop hook running git status caught all 7.

A Claude Code hook on the matcher Edit|MultiEdit|Write saw 3 of the 7 file changes Claude Code made in my test. A Stop hook that runs git status saw all 7.

On October 28, the AI Engineer channel posted “How Anthropic Merges 30,000 Code Changes a Day”, a conversation between Manish Kapur of Sonar and Addy Osmani of Anthropic. The video description says Anthropic went from about 2,500 to 30,000 merged changes a day, with Claude doing first-pass review backed by verification evidence.

Paraphrasing Osmani from the auto-generated captions: Claude reviews first and needs a way to try the change like a user would, and hooks run on edits and study the diffs so the PR gets corrected fast. That loop only works if the hook sees the edit. So I counted what a typical hook sees.

Claude Code reads ANTHROPIC_BASE_URL. The script starts a tiny fake Messages API on 127.0.0.1 that replies with a fixed list of tool calls. The real Claude Code 2.1.312 binary runs those tools and fires real hooks.

The fake model makes seven changes in a fresh git repo: three with Claude’s file tools (Edit, Write a new file, Write over an old one, plus two Reads first) and four with Bash (sed -i, a > redirect, rm, git mv). Two hooks watch. Hook A is PostToolUse with the matcher Edit|MultiEdit|Write, the docs' own Edit|Write example plus MultiEdit; it logs each payload. Hook B is a Stop hook that runs git status --porcelain -uall when Claude finishes.

Save as hookcheck.mjs. Tested on Node 22, with git.

// hookcheck.mjs - which file changes does a Claude Code hook see?// Needs: npm i @anthropic-ai/claude-code@2.1.312. No API key; offline after install.// Env: MATCHER (PostToolUse matcher), BLOCK=1 (Stop hook sends git status// back to the model once, with exit 2).import http from 'node:http';import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import { execFileSync as run, spawn } from 'node:child_process';const CLAUDE = process.argv[2] && path.resolve(process.argv[2]);if (!CLAUDE || !fs.existsSync(CLAUDE)) {  console.log('usage: node hookcheck.mjs <path/to/claude>'); process.exit(1);}const MATCHER = process.env.MATCHER || 'Edit|MultiEdit|Write';const W = fs.mkdtempSync(path.join(os.tmpdir(), 'hk-'));const R = path.join(W, 'repo'), LOG = path.join(W, 'post.log');fs.mkdirSync(path.join(R, '.claude'), { recursive: true });for (const f of ['a', 'c', 'd', 'f', 'g']) fs.writeFileSync(`${R}/${f}.txt`, 'x\n');const git = (...a) => run('git', ['-C', R, ...a], { encoding: 'utf8' });git('init', '-q'); fs.appendFileSync(`${R}/.git/info/exclude`, '.claude/\n');git('add', '.');git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'init');const ST = '.git/hk-status', gs = `git status --porcelain -uall > ${ST}`;const CHECK = [gs, `grep -q '"stop_hook_active":true' && exit 0`,  `[ -s ${ST} ] || exit 0`, `cat ${ST} >&2`, 'exit 2'].join('\n') + '\n';fs.writeFileSync(`${R}/.claude/stop-check.sh`, CHECK);const hooks = { PostToolUse: [{ matcher: MATCHER, hooks: [{ type: 'command',  command: `cat >> ${LOG}; echo >> ${LOG}` }] }], Stop: [{ hooks: [{  type: 'command', command: 'cd "$CLAUDE_PROJECT_DIR" && ' +    (process.env.BLOCK === '1' ? 'sh .claude/stop-check.sh' : gs) }] }] };fs.writeFileSync(`${R}/.claude/settings.json`, JSON.stringify({ hooks }));const p = (f) => `${R}/${f}`, sh = (command) => ({ name: 'Bash',  input: { command, description: command } });const rd = (f) => ({ name: 'Read', input: { file_path: p(f) } });const ROUTES = [ // [label, file, tool calls that make the change]  ['Edit tool', 'a.txt', [rd('a.txt'), { name: 'Edit', input: {    file_path: p('a.txt'), old_string: 'x', new_string: 'y' } }]],  ['Write, new file', 'b.txt', [{ name: 'Write',    input: { file_path: p('b.txt'), content: 'new\n' } }]],  ['Write, overwrite', 'c.txt', [rd('c.txt'), { name: 'Write',    input: { file_path: p('c.txt'), content: 'y\n' } }]],  ['Bash sed -i', 'd.txt', [sh('sed -i s/x/y/ d.txt')]],  ['Bash > redirect', 'e.txt', [sh('printf "y\\n" > e.txt')]],  ['Bash rm', 'f.txt', [sh('rm f.txt')]],  ['Bash git mv', 'g.txt', [sh('git mv g.txt h.txt')]] ];const STEPS = ROUTES.flatMap((r, i) => r[2].map((t) => ({ ...t, route: i })));let calls = 0, fed = '';const srv = http.createServer((q, s) => { let b = '';  q.on('data', (c) => (b += c)); q.on('end', () => {    const j = b ? JSON.parse(b) : {}, msgs = j.messages || [];    const done = msgs.flatMap((m) => [].concat(m.content))      .filter((c) => c && c.type === 'tool_result').length;    const st = j.tools && done < STEPS.length ? STEPS[done] : null;    if (j.tools) calls++;    const last = JSON.stringify(msgs.at(-1)?.content ?? '');    if (j.tools && !st && last.includes('Stop hook feedback')) fed = last;    const blk = st ? { type: 'tool_use', id: `toolu_${done}`, name: st.name,      input: {} } : { type: 'text', text: '' };    const delta = st ? { type: 'input_json_delta',      partial_json: JSON.stringify(st.input) } : { type: 'text_delta', text: 'ok' };    const ev = [['message_start', { message: { id: `m${calls}`, type: 'message',      role: 'assistant', model: 'claude-sonnet-5-5', content: [],      usage: { input_tokens: 1, output_tokens: 1 } } }],      ['content_block_start', { index: 0, content_block: blk }],      ['content_block_delta', { index: 0, delta }],      ['content_block_stop', { index: 0 }],      ['message_delta', { delta: { stop_reason: st ? 'tool_use' : 'end_turn' },        usage: { output_tokens: 1 } }], ['message_stop', {}]];    s.writeHead(200, { 'content-type': 'text/event-stream' });    for (const [e, d] of ev) s.write(`event: ${e}\ndata: ` +      JSON.stringify({ type: e, ...d }) + '\n\n');    s.end(); }); });await new Promise((ok) => srv.listen(0, '127.0.0.1', ok));const env = { PATH: process.env.PATH, HOME: W, ANTHROPIC_API_KEY: 'sk-test',  ANTHROPIC_BASE_URL: `http://127.0.0.1:${srv.address().port}`,  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };await new Promise((ok) => spawn(CLAUDE, ['-p', 'go', '--allowedTools',  'Read,Edit,Write,Bash'], { cwd: R, env, stdio: 'ignore' }).on('exit', ok));srv.close();const post = fs.existsSync(LOG) ? fs.readFileSync(LOG, 'utf8').split('\n')  .filter(Boolean).map((l) => JSON.parse(l)) : [];const stop = fs.readFileSync(`${R}/${ST}`, 'utf8');console.log(` claude ${run(CLAUDE, ['--version']).toString().trim()}`);console.log(` matcher "${MATCHER}" | model calls ${calls}` +  ` | tool calls ${STEPS.length}`);console.log('route             file   PostToolUse  file_path  patch  Stop git');let a = 0, b = 0;ROUTES.forEach(([label, f], i) => {  const hit = post.filter((h) => STEPS[+h.tool_use_id.slice(6)].route === i &&    h.tool_name !== 'Read');  const fp = hit.some((h) => h.tool_input.file_path);  const n = hit.flatMap((h) => h.tool_response.structuredPatch || [])    .reduce((k, h) => k + h.lines.length, 0);  const inStop = stop.includes(f);  a += hit.length > 0 && fp; b += inStop;  const c = (v, w) => String(hit.length ? v : '-').padEnd(w);  console.log(`${label.padEnd(17)} ${f.padEnd(6)} ${c('fired', 12)} ` +    `${c(fp ? 'yes' : 'no', 10)} ${c(n, 6)} ${inStop ? 'listed' : '-'}`);});console.log(` PostToolUse got a file path for ${a} of ${ROUTES.length} changes`);console.log(` Stop hook (git status --porcelain -uall) saw ${b} of ${ROUTES.length}`);const nw = post.find((h) => h.tool_input.file_path === p('b.txt'));if (nw) console.log(`raw Write(b.txt) response: type=${nw.tool_response.type}` +  ` structuredPatch=${JSON.stringify(nw.tool_response.structuredPatch)}`);console.log('raw Stop output:\n' + stop.trimEnd());if (fed) { const got = stop.trim().split('\n').filter((l) => fed.includes(  JSON.stringify(l).slice(1, -1))).length;  console.log(`Stop feedback reached the model: ${got} of ${b} status lines`); }
npm i @anthropic-ai/claude-code@2.1.312node hookcheck.mjs node_modules/.bin/claude

This is the exact output on Node 22.23.4 and git 2.43.0:

claude 2.1.312 (Claude Code)matcher "Edit|MultiEdit|Write" | model calls 10 | tool calls 9route             file   PostToolUse  file_path  patch  Stop gitEdit tool         a.txt  fired        yes        2      listedWrite, new file   b.txt  fired        yes        0      listedWrite, overwrite  c.txt  fired        yes        2      listedBash sed -i       d.txt  -            -          -      listedBash > redirect   e.txt  -            -          -      listedBash rm           f.txt  -            -          -      listedBash git mv       g.txt  -            -          -      listedPostToolUse got a file path for 3 of 7 changesStop hook (git status --porcelain -uall) saw 7 of 7raw Write(b.txt) response: type=create structuredPatch=[]raw Stop output: M a.txt M c.txt M d.txt D f.txtR  g.txt -> h.txt?? b.txt?? e.txt

The four Bash changes never reached the edit hook. The Stop hook listed all seven, including the delete and the rename.

The obvious patch is adding Bash to the matcher. The four Bash rows of that run:

MATCHER='Edit|Write|Bash' node hookcheck.mjs node_modules/.bin/claude
Bash sed -i       d.txt  fired        no         0      listedBash > redirect   e.txt  fired        no         0      listedBash rm           f.txt  fired        no         0      listedBash git mv       g.txt  fired        no         0      listed

Now the Bash rows fire too, but their payloads hold the command, with no file_path and no patch. To learn which files rm or git mv touched, your hook would have to parse shell. The script still reports 3 of 7: it only counts a payload that names a file.

In the “patch” column, the Edit and the overwrite each came with 2 patch lines; the new file came with 0. A created file reports type=create and structuredPatch=[]; the text sits in tool_response.content.

A hook that reviews structuredPatch sees nothing for a file Claude creates with Write. Read content when type is create.

Older examples include MultiEdit. I logged the request Claude Code 2.1.312 sends the model: "MultiEdit" appears 0 times, either in the tool list or in the list of deferred tools. It widens nothing.

NotebookEdit does appear, as a deferred tool loaded on demand, and the usual matcher does not name it. I did not run a notebook edit, so I can't say what its payload holds.

Git already sees uncommitted changes in the repo, whoever made them. This .claude/stop-check.sh is the file the script writes:

git status --porcelain -uall > .git/hk-statusgrep -q '"stop_hook_active":true' && exit 0[ -s .git/hk-status ] || exit 0cat .git/hk-status >&2exit 2

Register it in .claude/settings.json as a Stop hook with the command cd "$CLAUDE_PROJECT_DIR" && sh .claude/stop-check.sh. The cd matters: hooks run in Claude's current directory, which moves when Claude runs cd. When Claude tries to finish with uncommitted changes, the hook prints them to stderr and exits 2, which Claude Code treats as "don't stop yet", handing stderr to the model. The stop_hook_active check lets the second stop through, so it can't loop.

Run the script with BLOCK=1 to watch it work:

matcher "Edit|MultiEdit|Write" | model calls 11 | tool calls 9Stop feedback reached the model: 7 of 7 status lines

One extra model call, carrying all seven status lines.

Every measured number in sections 04–08 comes from hookcheck.mjs on Claude Code 2.1.312, except section 07: section 04 is the default run, section 05 shows lines 7-10 of the MATCHER run, section 08 shows lines 2 and 22 of the BLOCK=1 run. Section 07's counts come from a separate dump of the first request body.

A PostToolUse hook on Claude’s file tools saw 3 of 7 changes, and adding Bash gave it command strings, not files. A Stop hook that asks git saw 7 of 7 and fed them back to the model in one extra call. If your review loop depends on knowing what Claude changed, ask git, not just the tool name.

Which tool does your agent reach for most when it edits: Edit, or Bash?

If this saved you a blind spot in your review loop, a clap helps others find it.

Follow me for daily LLM deep-dives.

Does Your Claude Code Hook See Every Edit? An Edit|Write Matcher Saw 3 of 7 was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.

── more in #ai-agents 4 stories · sorted by recency
── more on @anthropic 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/does-your-claude-cod…] indexed:0 read:9min 2026-10-30 · —