Waitlist open Our new book, AI Engineering For Production, is coming before the end of 2026. Get the release date first.New book: AI Engineering For Production, coming before the end of 2026. Join the waitlist
Name: Towards AI Legal Name: Towards AI, Inc. Description: Towards AI is the world's leading artificial intelligence (AI) and technology publication. Read by thought-leaders and decision-makers around the world. Phone Number: +1-650-246-9381 Email: pub@towardsai.net
228 Park Avenue South New York, NY 10003 United States
Website: Publisher: https://towardsai.net/#publisher Diversity Policy: https://towardsai.net/about Ethics Policy: https://towardsai.net/about Masthead: https://towardsai.net/about
Name: Towards AI Legal Name: Towards AI, Inc. Description: Towards AI is the world's leading artificial intelligence (AI) and technology publication. Founders: Roberto Iriondo, , Job Title: Co-founder and Advisor Works for: Towards AI, Inc. Follow Roberto: X, LinkedIn, GitHub, Google Scholar, Towards AI Profile, Medium, ML@CMU, FreeCodeCamp, Crunchbase, Bloomberg, Roberto Iriondo, Generative AI Lab, Generative AI Lab VeloxTrend Ultrarix Capital Partners Denis Piffaretti, Job Title: Co-founder Works for: Towards AI, Inc. Louie Peters, Job Title: Co-founder Works for: Towards AI, Inc. Louis-François Bouchard, Job Title: Co-founder Works for: Towards AI, Inc. Cover:
Towards AI Cover
Logo:
Towards AI Logo
Areas Served: Worldwide Alternate Name: Towards AI, Inc. Alternate Name: Towards AI Co. Alternate Name: towards ai Alternate Name: towardsai Alternate Name: towards.ai Alternate Name: tai Alternate Name: toward ai Alternate Name: toward.ai Alternate Name: Towards AI, Inc. Alternate Name: towardsai.net Alternate Name: pub.towardsai.net
5 stars – based on 497 reviews

Frequently Used, Contextual References

TODO: Remember to copy unique IDs whenever it needs used. i.e., URL: 304b2e42315e

Resources

Does Your Claude Code Hook See Every Edit? An Edit
Latest   Machine Learning

Does Your Claude Code Hook See Every Edit? An Edit

Last Updated on November 2, 2026 by Editorial Team

Author(s): Chew Loong Nian – AI ENGINEER

Originally published on Towards AI.

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

Does Your Claude Code Hook See Every Edit? An Edit

​

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.

01 — Where this comes from

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.

02 — The test, with no API key

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.

03 — The script

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`); }

​

04 — Run it

npm i @anthropic-ai/claude-code@2.1.312
node 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 9
route file PostToolUse file_path patch Stop git
Edit tool a.txt fired yes 2 listed
Write, new file b.txt fired yes 0 listed
Write, overwrite c.txt fired yes 2 listed
Bash sed -i d.txt - - - listed
Bash > redirect e.txt - - - listed
Bash rm f.txt - - - listed
Bash git mv g.txt - - - listed
PostToolUse got a file path for 3 of 7 changes
Stop hook (git status --porcelain -uall) saw 7 of 7
raw Write(b.txt) response: type=create structuredPatch=[]
raw Stop output:
M a.txt
M c.txt
M d.txt
D f.txt
R 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.

05 — Adding Bash to the matcher is not enough

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 listed
Bash > redirect e.txt fired no 0 listed
Bash rm f.txt fired no 0 listed
Bash 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.

06 — A new file arrives with an empty patch

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.

Write on Medium

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

07 — MultiEdit names no tool here​

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.

08 — The fix: let git tell the hook

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-status
grep -q '"stop_hook_active":true' && exit 0
[ -s .git/hk-status ] || exit 0
cat .git/hk-status >&2
exit 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 9
Stop feedback reached the model: 7 of 7 status lines

One extra model call, carrying all seven status lines.

09 — What this does not show

  • Git skips new files that .gitignore names, files outside the repo, and anything Claude commits itself.
  • It also lists your own uncommitted work, and untracked .claude/ files unless ignored (the script ignores them).
  • Stop runs when Claude finishes, not per edit. Keep PostToolUse for instant checks.
  • Docs say PostToolUse also fires in subagents; I didn’t test it.
  • The model is scripted; a real one picks its own routes. Seven routes are a sample, not a census.

10 — Reproduce it​

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.

11 — Verdict

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.

Join thousands of data leaders on the AI newsletter. Join over 80,000 subscribers and keep up to date with the latest developments in AI. From research to projects and ideas. If you are building an AI startup, an AI-related product, or a service, we invite you to consider becoming a sponsor.

Published via Towards AI


Towards AI Academy

We Build Enterprise-Grade AI. We'll Teach You to Master It Too.

15 engineers. 100,000+ students. Towards AI Academy teaches what actually survives production.

Start free — no commitment:

→ AI Engineering For Production — our new book, coming before the end of 2026. Join the waitlist.

→ Agents Architecture Cheatsheet — 3 years of architecture decisions in 6 pages

Our courses:

→ AI Engineering Certification — 90+ lessons from project selection to deployed product. The most comprehensive practical LLM course out there.

→ Agent Engineering Course — Hands on with production agent architectures, memory, routing, and eval frameworks — built from real enterprise engagements.

→ AI for Work — Understand, evaluate, and apply AI for complex work tasks.

Note: Article content contains the views of the contributing authors and not Towards AI.