Back in college, 100,000 yen a month was my entire budget. Stacking side jobs, I got that up to 600,000 a month — then I was laid off and it went straight back to zero. Six months later, after building out an autonomous Claude Code setup, I'm at 1.2 million yen a month in revenue. Once the environment is right, the hours where "the system is running" outnumber the hours where you're moving your hands. This post takes apart one piece of that system.
Why this setup works
After a few days of touching dozens of files a day with Claude Code, it hits you: "the TypeScript check is running every single time, and the response keeps stalling."
If you wire up the default hooks in the obvious way, you end up calling Prettier on PostToolUse every time a file is saved, and the TypeScript compiler on Stop. It looks conscientious. In practice it's a pile of waste.
tsc takes 2–4 seconds just to start up.
Booting the Node.js process, parsing tsconfig.json, loading type definition files — all of that repeats every time you touch a single file. Edit 10 files, that's 10 times. In a monorepo, 10 times per package. Days where you touch 100 files during a Claude Code session are not unusual, and launching tsc each time pushes the cumulative cost into minutes.
Worse, this directly slows down "the response to the edit." The moment Claude Code finishes writing a file, the hook fires, and the next operation is blocked until tsc finishes. An AI that writes code stopping for several seconds every time it writes fundamentally undermines throughput.
The problem isn't "checking." It's "checking every time."
Plenty of people work in text editors with an IDE that runs a whole-project check on every save, and it doesn't stress them out — because the UI response and the background process are decoupled. Claude Code hooks are called synchronously. Claude stops until the hook finishes. Continuing to "check every time" under that constraint is like a sparring partner who freezes for five seconds after every return.
The fix is "check once per session, all at once."
Just accumulate paths on every edit, and only run the check at the moment Claude finishes all the work for that turn (Stop). Process multiple files in one launch and the startup cost drops to 1/N. Even if you touched 50 files during a session, tsc launches (per tsconfig) 1–3 times.
My rule is "set up the environment before doing the work."
When my revenue hit zero, the first thing I did was re-read the Claude Code hook docs. I decided that zeroing out the loss per write mattered more than increasing the number of writes I could get Claude to do. In the end, two days spent on hooks determined the efficiency of the several hundred hours that followed.
Wire up your hooks correctly and you get an environment where the AI doesn't stall. The difference is dramatic in practice. When the cycle from firing off a request to getting the next result tightens up, you get more attempts, and the density of what you can ship in a day goes up. The increase in side-job work I could take on, and the improvement in code quality, are about half attributable to this kind of accumulated environment work.
The overall flow
The implementation splits across two files: post-edit-accumulator.js (the PostToolUse hook) and stop-format-typecheck.js (the Stop hook). The responsibilities are cleanly separated. The former only "stacks paths"; the latter only "processes them in bulk."
[Claude がファイルを編集(Edit / Write / MultiEdit)]
│
▼
PostToolUse フック
post-edit-accumulator.js
├─ .ts / .tsx / .js / .jsx か判定
└─ appendFileSync でパスを1行追記(並行安全)
│
▼
/tmp/ecc-edited-{sessionId}.txt
(1行1パス・重複あり・セッションスコープ)
│
▼ ── Claude がターンを終了(Stop イベント発火)──
│
Stop フック
stop-format-typecheck.js
├─ ファイル読み込み → 即 unlink(2重処理防止)
├─ [...new Set(...)] で重複排除
├─ プロジェクトルート別にグループ化
│ └─ formatter 1回(biome check --write / prettier --write)
└─ tsconfig.json 起点でグループ化
└─ npx tsc --noEmit 1回(per tsconfig)
└─ エラーは編集ファイル関連行のみ最大10行 stderr へ
Designing the PostToolUse hook
post-edit-accumulator.js has a simple job. It gets called every time Claude Code edits a file, and just appends that path as one line to a tmp file.
// post-edit-accumulator.js L38-44
const JS_TS_EXT = /\.(ts|tsx|js|jsx)$/;
function appendPath(filePath) {
if (filePath && JS_TS_EXT.test(filePath)) {
fs.appendFileSync(getAccumFile(), filePath + '\n', 'utf8');
}
}
The reason for using appendFileSync is concurrency safety. Claude Code sometimes fires multiple tool calls in parallel, which means several PostToolUse hook processes can run at the same time. appendFileSync is an atomic append-to-end at the OS level, so even with processes running side by side, their writes don't overwrite each other. No lock files, no mutual exclusion needed.
There's also some care in how the tmp file is named (L24-32).
function getAccumFile() {
const raw =
process.env.CLAUDE_SESSION_ID ||
crypto.createHash('sha1').update(process.cwd()).digest('hex').slice(0, 12);
const sessionId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
return path.join(os.tmpdir(), `ecc-edited-${sessionId}.txt`);
}
If CLAUDE_SESSION_ID is available it uses that; otherwise it identifies the session by the first 12 characters of the SHA1 hash of process.cwd(). Making the filename session-specific means multiple Claude Code sessions running at once don't interfere. Even if you're working on /Users/xxx/project-a and /Users/xxx/project-b in parallel sessions, each stacks paths into its own independent tmp file.
Path separators and traversal characters (.., /, etc.) are replaced with underscores via the regex /[^a-zA-Z0-9_-]/g before use. Whatever CLAUDE_SESSION_ID happens to contain, it can be safely embedded in a filename.
It also absorbs the differences between tool types (L53-59).
// Edit / Write: single file_path
appendPath(input.tool_input?.file_path);
// MultiEdit: array of edits, each with its own file_path
const edits = input.tool_input?.edits;
if (Array.isArray(edits)) {
for (const edit of edits) appendPath(edit?.file_path);
}
The Edit tool, the Write tool, and the MultiEdit tool all stack into the same tmp file. The accumulator doesn't care which tool Claude Code used to touch the file. By absorbing the tool-type differences upstream, the Stop hook only has to look at an array of paths.
Not deduplicating here is deliberate. appendFileSync appends, so editing the same file 10 times lines up the same path 10 times. That's fine. Deduplication is the reader's job (the Stop hook). Keeping the write simple means the PostToolUse hook itself finishes in a few milliseconds. The only thing that stalls Claude's edit operation is "the time it takes to write one line."
Designing the Stop hook
When Claude finishes a turn, stop-format-typecheck.js is called. This is the main engine that actually does the work.
The first thing it does is read the tmp file and immediately delete it (L139-148).
let raw;
try {
raw = fs.readFileSync(accumFile, 'utf8');
} catch {
return; // No accumulator — nothing edited this response
}
try { fs.unlinkSync(accumFile); } catch { /* best-effort */ }
const files = parseAccumulator(raw);
By calling unlink right after reading, the same file won't be double-processed even if the Stop hook is called twice. If the file doesn't exist at read time (a turn where no JS/TS files were touched), it just returns. The cost of the hook running is zero.
Deduplication is a one-liner (L36-38).
function parseAccumulator(raw) {
return [...new Set(raw.split('\n').map(l => l.trim()).filter(Boolean))];
}
Just run it through a Set. However many times the same path was stacked, the result is one entry. A file edited 100 times is handed to tsc exactly once.
Budget distribution is the core of this design (L173-175).
const totalBatches = byProjectRoot.size + byTsConfigDir.size;
const perBatchMs = totalBatches > 0
? Math.floor(TOTAL_BUDGET_MS / totalBatches)
: 60_000;
TOTAL_BUDGET_MS is 270_000 (270 seconds) (L29). Claude Code's Stop hook has a 300-second timeout, so this leaves 90 seconds for overhead and allocates 270 seconds to batch processing. With just one project, you get the whole 270 seconds. In a monorepo with 3 tsconfigs and 2 project roots, that's 5 batches, so 54 seconds per batch. Time is distributed automatically according to the batch count. More tsconfigs means less time per batch, but the total never exceeds 300 seconds.
Let's look at how grouping works (L161-169).
const byTsConfigDir = new Map();
for (const filePath of files) {
if (!/\.(ts|tsx)$/.test(filePath)) continue;
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) continue;
const tsDir = findTsConfigDir(resolved);
if (!tsDir) continue;
if (!byTsConfigDir.has(tsDir)) byTsConfigDir.set(tsDir, []);
byTsConfigDir.get(tsDir).push(resolved);
}
The findTsConfigDir function walks up to 20 levels of parent directories from a file path looking for tsconfig.json (L83-88). So when you edit packages/api/src/handlers/user.ts, tsc --noEmit runs rooted at packages/api/tsconfig.json. The tsconfig.json under packages/web/ is handled independently in a separate batch. Even within the same monorepo, unrelated packages don't get checked.
TypeScript error output is narrowed too (L122-133). Instead of piping the entire output of tsc --noEmit through, it filters to only lines containing an edited file's path, up to 10 lines, and writes them to stderr.
const relevantLines = lines
.filter(line => {
for (const c of candidates) { if (line.includes(c)) return true; }
return false;
})
.slice(0, 10);
if (relevantLines.length > 0) {
process.stderr.write(`[Hook] TypeScript errors in ${path.basename(filePath)}:\n`);
relevantLines.forEach(line => process.stderr.write(line + '\n'));
}
Rather than dumping 1000 lines of tsc errors into the terminal, it narrows down to "only the lines related to the files just touched." Since the filtered errors are all Claude Code reads on the next turn, the context stays clean.
Implementation details (deep dive)
Auto-detecting and running the formatter
After the "grouping by project root" mentioned earlier, the function that actually calls the formatter is formatBatch (L48-77). This part is genuinely complex, and I rewrote it several times before it worked.
function formatBatch(projectRoot, files, timeoutMs) {
const formatter = detectFormatter(projectRoot);
if (!formatter) return;
const resolved = resolveFormatterBin(projectRoot, formatter);
if (!resolved) return;
const existingFiles = files.filter(f => fs.existsSync(f));
if (existingFiles.length === 0) return;
const fileArgs =
formatter === 'biome'
? [...resolved.prefix, 'check', '--write', ...existingFiles]
: [...resolved.prefix, '--write', ...existingFiles];
detectFormatter is a lib-side function: if biome.json or biome.jsonc exists it returns 'biome', and if package.json mentions prettier it returns 'prettier'. If no formatter is found it returns null, and formatBatch immediately returns. The design is such that using this hook on a project with no formatter configured does nothing at all.
The important part is how existingFiles is built (L55).
const existingFiles = files.filter(f => fs.existsSync(f));
if (existingFiles.length === 0) return;
When Claude does something like "write a file and delete it right after" (creating a temp file then cleaning up, moving a file via rename, etc.), the path may be stacked in the accumulator while the file no longer exists by the time the Stop hook runs. Passing it to the formatter without an existence check crashes with "file not found." existsSync is mandatory here. I'll go into this problem in more detail in the section on where I got stuck.
The difference in command argument structure between biome and prettier is also worth noting. biome does formatting and static analysis together with check --write, while prettier works with just --write. That flag difference is absorbed in a single ternary expression.
The Windows .cmd problem (L64-76) is remote from anyone developing on macOS, but it's an interesting piece of implementation.
if (process.platform === 'win32' && resolved.bin.endsWith('.cmd')) {
if (existingFiles.some(f => UNSAFE_PATH_CHARS.test(f))) {
process.stderr.write('[Hook] stop-format-typecheck: skipping batch — unsafe path chars\n');
return;
}
const result = spawnSync(resolved.bin, fileArgs, { cwd: projectRoot, shell: true, stdio: 'pipe', timeout: timeoutMs });
On Windows, executing .cmd files like npx.cmd or biome.cmd requires the shell: true option. But with shell: true, paths get interpreted through the shell, and paths containing spaces or & get mangled. So UNSAFE_PATH_CHARS at L33 checks in advance, and if a dangerous path is present the whole batch is skipped with a warning to stderr.
On macOS/Linux it uses execFileSync and calls the binary directly without a shell in between. Arguments are passed as an array, so paths containing spaces are safe.
OS branching in typecheckBatch
typecheckBatch (L91-133), which handles the TypeScript check, has a similar branch.
function typecheckBatch(tsConfigDir, editedFiles, timeoutMs) {
const isWin = process.platform === 'win32';
const npxBin = isWin ? 'npx.cmd' : 'npx';
const args = ['tsc', '--noEmit', '--pretty', 'false'];
const opts = { cwd: tsConfigDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: timeoutMs };
cwd: tsConfigDir is the trick. tsc --noEmit automatically looks for tsconfig.json in the current directory. By setting cwd to the directory where tsconfig.json lives, you get the correct config loaded without specifying a --project flag.
--pretty false is for ease of parsing. Having it output plain text without color codes or terminal escapes ensures the downstream error filtering (line-based matching) works reliably. If you try to search for file paths with includes() while ANSI escapes are mixed in, characters wrapped in escape sequences can break the match.
stdio: ['pipe', 'pipe', 'pipe'] is deliberate too. Piping all of stdin, stdout, and stderr keeps tsc's output from flowing straight to the terminal and puts it into Node's buffer. Only on failure do you join err.stdout and err.stderr for post-processing.
Why the pass-through design
The structure of the run function (L188-195) looks odd at first glance.
function run(rawInput) {
try {
main();
} catch (err) {
process.stderr.write(`[Hook] stop-format-typecheck error: ${err.message}\n`);
}
return rawInput;
}
It doesn't use the result of main(); it returns the rawInput argument as-is. This follows Claude Code's hook spec. A Stop hook is expected to receive Claude's event data on stdin and write the processing result to stdout. This hook doesn't need to transform the data, so it just passes the received JSON straight through.
The important part is that it's wrapped in try-catch. Whatever error occurs inside the hook, the Claude Code session itself isn't broken. In the worst case, formatting and typechecking are both skipped, but Claude's work doesn't stop. It just emits a warning to stderr. This reflects the design philosophy that a hook is an aid and must never obstruct the main work.
MAX_STDIN = 1024 * 1024 (L22) comes from the same thinking. In theory stdin will never get huge, but a 1MB cap prevents reading forever if something goes wrong.
Where I got stuck
The logic looks clean once you organize it after the fact, but it didn't start out in this shape. Here are three things that broke while actually running it.
The session ID contained a path and the tmp file couldn't be created
In the first version, ID sanitization inside getAccumFile was too loose. I wasn't thinking about what characters CLAUDE_SESSION_ID might contain.
Depending on the environment, CLAUDE_SESSION_ID can come in a form containing slashes, like session/abc123 (it depends on version and configuration). Build path.join(os.tmpdir(), 'ecc-edited-session/abc123.txt') from that and appendFileSync crashes with ENOENT, because the os.tmpdir()/ecc-edited-session/ directory doesn't exist.
The symptom is simple: the PostToolUse hook errors every time. But the error only goes to stderr, and Claude's work doesn't stop (thanks to the pass-through design). So my only clue was a vague "I feel like the hook isn't working." I didn't know the cause until I actually checked stderr.
// 修正前
return path.join(os.tmpdir(), `ecc-edited-${raw}.txt`);
// 修正後
const sessionId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
return path.join(os.tmpdir(), `ecc-edited-${sessionId}.txt`);
/[^a-zA-Z0-9_-]/g replaces everything except alphanumerics, hyphens, and underscores with _. Slashes, dots, and whitespace all get flattened. It also slices to 64 characters to control filename length. After the fix, session/abc123 becomes session_abc123 and gets created in tmp without a problem.
The lesson is "don't trust the contents of environment variables." Values that aren't in the spec can show up. Anything coming from outside must be sanitized before you use it on the filesystem.
The Stop hook was called twice and tsc ran twice
At some point during a Claude Code session, I noticed tsc was running twice against the same file. Checking the logs, the Stop event had fired twice.
Investigating, I found that in some operation patterns (specific flows involving tools or subagents), the Stop hook can be triggered multiple times. That's behavior on the Claude Code side, so the hook has to handle it.
The original implementation read the tmp file each time the Stop hook ran. When a second Stop came in, if the tmp file was still there, the same paths got processed again.
The fix is to delete it at the same time you read it.
let raw;
try {
raw = fs.readFileSync(accumFile, 'utf8');
} catch {
return; // ファイルがない = 処理済みか、このターンにJS/TSを触っていない
}
try { fs.unlinkSync(accumFile); } catch { /* best-effort */ }
Right after readFileSync, once the contents are in memory, call unlinkSync. When the second Stop arrives, readFileSync throws ENOENT and the catch block returns. However many Stops pile up, processing happens exactly once.
unlinkSync is wrapped in try-catch so a failed deletion doesn't stop anything. If another process already deleted it, you get ENOENT, and that's not a problem. The pragmatic stance is: "I don't need proof that the delete succeeded — the contents are already in memory."
What I learned from this failure is the principle 'treat external events as idempotent.' Design so the same trigger arriving any number of times produces the same result, and uncertain behavior stops being a problem.
Passing deleted files to tsc caused a cascade of errors
During real Claude work, this pattern comes up: write a temp file named tmp.ts, check its contents, then delete it. Or rename a file from old.ts to new.ts (which is really delete + create).
At that point, the accumulator has tmp.ts or old.ts stacked in it. But by the time the Stop hook runs, those don't exist. What happens if you hand them to tsc without an existence check?
error TS2307: Cannot find module '/path/to/tmp.ts' or its corresponding type declarations.
This error goes out to the Stop hook's stderr. And since the error filter correctly picks it up as "a line related to an edited file," it lands in Claude Code's context. Claude sees the "tmp.ts not found" error on the next turn and starts attempting unnecessary fixes.
The fix is in two places. I added an fs.existsSync check to both the byProjectRoot and byTsConfigDir construction loops.
// byProjectRoot の構築
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) continue; // ← これ
// byTsConfigDir の構築
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) continue; // ← これも
The same check exists on the formatBatch side too (existingFiles at L55). It's spread across three places because grouping and execution happen at different times. Filtering during grouping also saves the cost of searching for tsconfig.json. Filtering again right before execution handles the (very rare) race where a file disappears after grouping.
What I learned from this failure is that 'the accumulator stacks facts, not intentions.' What's stacked isn't "files that were edited" but "paths as of the moment an edit operation happened." The Stop hook takes no responsibility for a path's past; it only looks at whether it exists now.
findTsConfigDir couldn't find anything
findTsConfigDir (L79-88) walks up to 20 levels of parent directories from a file looking for tsconfig.json.
function findTsConfigDir(filePath) {
let dir = path.dirname(filePath);
const fsRoot = path.parse(dir).root;
let depth = 0;
while (dir !== fsRoot && depth < 20) {
if (fs.existsSync(path.join(dir, 'tsconfig.json'))) return dir;
dir = path.dirname(dir);
depth++;
}
return null;
}
At first there was no depth cap, and control was only dir !== fsRoot. That was fine in a normal development environment, but one day when I tested it on a monorepo setup that used symlinks heavily, the loop went around nearly 50 times before returning null. Whether macOS symlink resolution was affecting the fsRoot check, I couldn't fully trace — but it was definitely walking deeper than expected.
After adding the depth < 20 cap, the problem stopped reproducing. The number "20" is a rule of thumb: in a normal development project, it's not realistically possible to walk deeper than that without hitting a tsconfig.json. Even in a typical monorepo structure, it's 6–7 levels from packages/module-name/src/utils/helper.ts to the root. 20 is triple that as a safety margin.
The root of this problem is 'don't have only one termination condition.' With only the natural termination condition of "stop when you reach the root," an unexpected environment produces near-infinite-loop behavior. depth < 20 isn't a logical termination condition — it's an insurance termination condition. It never fires in the normal case, but it always stops things when something is wrong. There's no downside to writing this kind of insurance.
Static budget calculation choked on a large monorepo
The last failure is about the initial budget design. In the first version, I applied a fixed 60-second timeout to tsc.
// 最初の実装(問題あり)
const TYPECHECK_TIMEOUT_MS = 60_000;
for (const [tsDir, batch] of byTsConfigDir) {
typecheckBatch(tsDir, batch, TYPECHECK_TIMEOUT_MS);
}
Running that on a large monorepo with 5 tsconfigs, the total could exceed 300 seconds (5 × 60 seconds = 300 seconds, plus formatting on top). Claude Code's Stop hook times out at 300 seconds, so the last few tsc runs would get cancelled.
The current implementation uses dynamic budget distribution.
const totalBatches = byProjectRoot.size + byTsConfigDir.size;
const perBatchMs = totalBatches > 0
? Math.floor(TOTAL_BUDGET_MS / totalBatches)
: 60_000;
TOTAL_BUDGET_MS = 270_000 (270 seconds) divided by the total batch count. With 5 tsconfigs and 2 project roots (for the formatter), that's 7 batches total, so 270_000 / 7 ≈ 38_571 milliseconds (about 38 seconds) as the cap per batch. 7 batches × 38 seconds = 266 seconds, which fits inside 270. And since 270 seconds is Claude Code's 300-second limit minus 30, it won't exceed 300 seconds even including hook startup overhead.
With only one project, you get the full 270_000 / 1 = 270_000 (270 seconds). The bigger the monorepo gets, the shorter each batch's time, but the total is always within 270 seconds. It's a simple story: the pie is fixed, and more batches means thinner slices.
After this design change, the Stop hook stopped terminating early due to timeouts on projects of any size.
Summing up the things I got stuck on: nearly every sticking point in this implementation is a boundary condition. Values coming from outside for the session ID, Stop arriving multiple times, files disappearing mid-flight, tsconfig not being found, the budget overflowing. These are hard to reproduce in unit tests — the kind of problem you only notice by stepping on it while actually running the thing.
Because a hook is an "auxiliary tool," the reassurance that work doesn't stop when it breaks carries a risk of lax testing. What I consciously did was "go look at stderr precisely because the hook's errors are hard to see." Even when Claude's responses look perfectly natural, the hook can be quietly slacking off.
Gotchas
In addition to the five troubles dug into above (session ID path contamination, double Stop invocation, deleted files, the findTsConfigDir deep dive, and the static budget), here's a comprehensive list of the other gotchas I actually hit.
Around the hook execution environment
nvm's Node.js isn't on PATH and the hook doesn't start
The shell that launches Claude Code hooks doesn't read~/.zshrc. Node.js managed by nvm doesn't get onto PATH automatically, so thenodecommand isn't found. When registering a hook inhooks.json, either write the absolute path to Node.js directly in the command, or put a wrapper shell script with an explicit PATH in between. A#!/usr/bin/env nodeshebang alone isn't enough.No execute permission, and it fails silently
Forgetchmod +xand the hook fails to start withPermission denied. But thanks to the pass-through design, Claude Code's work itself doesn't stop, so you get that vague "I feel like it isn't working" realization. The fastest way to confirm is Claude Code's--debugmode log, or grepping the Stop hook's stderr log after the fact.Debugging with
console.log()pollutes stdout and Claude Code throws a parse error
A hook's stdout is the event data returned to Claude Code. Output text withconsole.log('debug')and the receiving side fails to parse the JSON. Always useprocess.stderr.write()orconsole.error()for debug output. Develop without knowing this and it shows up as "Claude started behaving strangely after I added a hook."An early version with the
hooks.jsonmatcher left at*calling tsc directly inside
When I first tried wiring the hook straight to tsc, I had the matcher set to all edits, so tsc launched on HTML and Markdown edits too. The JS/TS extension filter insidepost-edit-accumulator.js(L38:/\.(ts|tsx|js|jsx)$/) is designed to narrow things down inside the hook, on the assumption that the hook's matcher receives all edits. Misunderstand this division of labor and you keep launching tsc for nothing.
Accumulator (PostToolUse side) gotchas
Not looking at MultiEdit's
editsarray, so some paths never get stacked
The Edit and Write tools have a single path attool_input.file_path. MultiEdit, on the other hand, has afile_pathin each element of thetool_input.editsarray. Handle only one of the two and a MultiEdit that edits several files at once only stacks the first one.post-edit-accumulator.jshandles both explicitly at L56-58.Trying to eliminate duplicates because I thought stacking the same file repeatedly would make the Stop hook heavy
Attempt an implementation on the PostToolUse side that "checks whether it's already stacked before appending," and you get a read → compare → write window that races with concurrent processes and requires mutual exclusion. Duplicates are eliminated in bulk on the Stop hook side with[...new Set()](L37), so the correct answer on the PostToolUse side is to stack withappendFileSyncwithout thinking about it.
Stop hook side gotchas
Omitting
--pretty falselets ANSI escapes destroy the error filter
tsc --noEmitoutputs in color by default. When error lines contain escape sequences like\x1b[91m, the filter that searches for file paths withincludes()(L127) can't match the path string correctly. TypeScript errors are actually occurring, but they never reach Claude's context, and the turn moves on without them being fixed.--pretty false(L94) is not optional.Setting
stdio: 'inherit'sends tsc output straight to the terminal
Omit the stdio option onexecFileSyncand it defaults to'inherit', which sends tsc's output straight into the Stop hook's stdout. The pass-through design breaks, and Claude Code receives event data and tsc output mixed together. Explicitly specifying all three withstdio: ['pipe', 'pipe', 'pipe'](L95) is mandatory.Omitting
cwdmakes tsc read the wrong tsconfig
typecheckBatchruns tsc withcwd: tsConfigDir(L95). Omit that and tsc searches fortsconfig.jsonfrom the current process's directory, leading to a situation where files inpackages/api/get checked withpackages/web/'s configuration. In a monorepo that's fatal. There's also the--projectflag approach, but matchingcwdis simpler.Looking at only stdout or only stderr makes errors disappear
Whethertscemits errors to stdout or stderr changes depending on version and configuration.typecheckBatch(L122) joins both with(stdout + stderr).split('\n')before filtering. Look only atstderrand errors that went to stdout vanish, producing the confusion of "there shouldn't be any errors but it doesn't work."Confusing biome and prettier arguments and crashing
biome ischeck --write; prettier is--write(L58-61). Passcheck --writeto prettier and it tries to write a file namedcheckand fails. Pass only--writeto biome and it errors for lack of a subcommand. The formatter-detection → command-generation branch is hidden away on the lib side, and not calling it directly is the safe move.Writing the formatter assuming a global install, then it didn't work in other environments
BecauseresolveFormatterBinis designed to search the project'snode_modules/.bin/, it doesn't depend on globally installedbiomeorprettier. But run it with nonode_modulespresent and the behavior is "formatter not found → do nothing." That's a safe design in itself, but the gotcha is that it's hard to notice "formatting isn't running."Passing jsx files to tsc
ThebyTsConfigDirconstruction loop (L162) has a!/\.(ts|tsx)$/filter..jsand.jsxare formatter-only targets and aren't passed to tsc. Remove that filter and.jsxfiles become targets of thefindTsConfigDirsearch, producing unexpected errors depending on the tsconfig settings.Using only absolute paths as candidates in the error filter and missing relative-path errors
typecheckBatch's error filter (L124-125) puts both the file's absolute path and its path relative totsConfigDirinto the candidate set. Which form tsc writes in an error line depends on the tsconfigrootDirsetting. Search with only absolute paths, or only relative ones, and some errors slip straight through.
Best practices
Guidelines derived from the implementation and the failures. Ordered by how easily someone building hooks with this structure for the first time will overlook them.
1. PostToolUse only stacks; decisions happen in bulk at Stop
Build PostToolUse to "stack while deduplicating" or "process in parallel too," and you need to manage contention with concurrent processes. Keeping the write simple and concentrating decisions and processing in Stop is the simplest and most robust design.
2. Use appendFileSync for appends to guarantee concurrency safety
PostToolUse hooks can run as multiple processes simultaneously. appendFileSync is an atomic append-to-end at the OS level, so no lock files or mutual exclusion are needed. When you need concurrent writes to a file, appendFileSync is the first option.
3. Reduce environment variables to alphanumerics before using them in filenames
The format of CLAUDE_SESSION_ID varies by version and environment. Replace everything except alphanumerics, hyphens, and underscores with /[^a-zA-Z0-9_-]/g, and limit the length with .slice(0, 64) (L30-31). Trust that a value from outside "will surely come in the documented format" and file creation will fail in rare cases.
4. Achieve idempotency with "read and immediately delete"
Design on the assumption that the Stop hook will be called multiple times. Calling unlinkSync right after readFileSync on the tmp file (L141-146) means the second and later calls catch ENOENT and return immediately. It's more reliable than managing a lock file, and the code is shorter.
5. Put existsSync in two stages: before grouping and before execution
Paths stacked in the accumulator are facts as of that moment, not guarantees at Stop hook execution time. Put an existsSync check in each of the byProjectRoot and byTsConfigDir construction loops (L155, L165), and filter again as existingFiles right before formatBatch executes (L55). Defense in depth completely prevents the "pass a deleted file and crash tsc" problem.
6. Derive timeouts from external constraints and distribute them dynamically
Claude Code's Stop hook has a 300-second limit. TOTAL_BUDGET_MS = 270_000 (L29) is that limit minus 30 seconds of overhead. Dividing it evenly by batch count (L175) keeps the whole thing inside 300 seconds no matter how many tsconfigs a monorepo has. Dynamic distribution — "external constraint ÷ batch count" — is the right answer, not a fixed value.
7. Give loops both a logical termination condition and an insurance one
findTsConfigDir's while loop combines dir !== fsRoot (the logical termination condition) with depth < 20 (the insurance one) (L82-83). It's a cap based on the rule of thumb that "there's no tsconfig deeper than 20 levels," and it stops the loop when symlinks or environment-dependent behavior take it deeper than expected. Always put insurance in loops that traverse an external filesystem.
8. Control tsc with --pretty false and stdio: pipe
tsc --noEmit --pretty false (L94) strips ANSI escapes, and stdio: ['pipe', 'pipe', 'pipe'] (L95) receives output into Node's buffer. Without both together, either the error filtering breaks or Claude's stdout gets polluted. Memorize this as the standard configuration for calling tsc in a subprocess.
9. Narrow tsc errors to "related to edited files, max 10 lines"
Pipe all of tsc's output straight into Claude's context and unrelated errors become noise, leading Claude to attempt wrong fixes. Filter with both absolute and relative paths as candidates (L124-128), and cap it with .slice(0, 10). The principle is that errors a hook outputs should stay at "the minimum information needed for Claude's next action."
10. Wrap the entire hook in try-catch so it doesn't obstruct Claude's work
A hook is an aid. Even with a bug, it shouldn't stop Claude's actual work. Wrap all of run() in try-catch (L188-194) and only write errors to stderr. Even if formatting or typechecking fails, Claude can move on to the next turn. Designing so that breakage isn't a problem takes priority over making the hook work perfectly.
11. stdout is for pass-through only; write debugging to stderr
A hook's stdout is the data returned to Claude Code. Mix in debug strings with console.log() and Claude Code throws a parse error. Use process.stderr.write() or console.error() for all debugging during development. Making that a habit completely prevents the "Claude started behaving strangely after I added a hook" situation.
12. Auto-detect the formatter; skip silently if none is configured
detectFormatter checks biome.json and package.json to pick a formatter, and returns null if it finds none. At null, formatBatch returns immediately. It never "breaks because no formatter is installed," so you can safely bring it into any project. When setting up hooks in a new environment, you're less likely to worry about "why is formatting running?" than to debug "why isn't it formatting?"
13. Give tsc the right context with cwd: tsConfigDir
tsc automatically searches for tsconfig.json in the current directory. Setting cwd to the directory where tsconfig.json lives gets the correct config loaded without a --project flag. In a monorepo, just setting cwd gives you independent checks across multiple packages.
14. Put a cap on reading stdout
MAX_STDIN = 1024 * 1024 (L22) caps stdin reading at 1MB. It's irrelevant for normal hook events, but it prevents the process from eating memory forever if a bug or unexpected state sends an endless stream on stdin. Always cap streams coming from outside.
15. Know that hook errors are "hard to see"
Because of the pass-through design, even a completely non-functional hook doesn't affect Claude Code's visible behavior. Often the only signal is a vague "somehow it isn't being formatted" or "I don't think tsc is running." Checking the stderr log periodically, or adding a lightweight stderr message that indicates the hook ran, speeds up diagnosis considerably.
Wrap-up
Two files and roughly 200 lines of code achieve the goal of "launching tsc once per session." The design axis is simple: stack in PostToolUse, process in bulk at Stop. That separation is what lets Claude move on to the next action without stopping every time it finishes an edit operation.
But inside those 200 lines are a stack of specific judgment calls.
Sanitizing the session ID comes from the premise that "the format of values coming from outside isn't guaranteed." Idempotency via immediate unlink comes from the premise that "assume the same trigger can arrive multiple times." The two-stage existsSync check comes from the premise that "the accumulator is a record of past operations, not a guarantee of current file state." Dynamic budget distribution comes from the premise that "check the external constraint (300 seconds) first, and design backward from it."
All of these came to light after actually running things and breaking them. Neither static analysis nor unit tests would have found them in advance. They only appeared by continuing to use the hooks in production Claude sessions.
When you start using Claude Code, you focus at first on "what code should I have it write." But once you're touching 100 files a day, the questions that create the productivity gap are "is tsc running every single time?", "are errors polluting the context?", and "can I cut the time Claude spends stopped?" During those six months when my revenue was zero, I still think spending the first two days on hooks was the right call. Getting the environment in place first changed the quality of the several hundred hours that followed.
I've put the full picture of the system, the breakdown of the 1.2 million yen a month, and a 30-day walkthrough into a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)