🗂 編集のたびにtscを走らせるのをやめた話
大学生のころは月10万円で暮らしていました。掛け持ちで月60万円まで積み上げ、会社都合で解雇されてゼロに戻り、そこから半年でClaude Codeの自律環境を整えて今は月商120万円です。環境さえ整えれば、手を動かす時間より「仕組みが回っている時間」のほうが長くなります。今回はその仕組みの一つを分解します。
なぜこの仕組みが効くのか
Claude Codeで一日に何十ファイルも触る日が続くと、ある日ふと気づきます。「TypeScriptのチェックが毎回走って、応答が止まっている」。
デフォルトのフックを素直に組むと、PostToolUseでファイルを保存するたびにPrettierを、StopでTypeScriptコンパイラを呼ぶ構成になりがちです。一見丁寧に見えますが、実際は無駄の塊です。
tscは起動だけで2〜4秒かかります。
Node.jsプロセスの起動・tsconfig.jsonのパース・型定義ファイルの読み込み、これらはファイルを1件触るたびに毎回繰り返されます。10ファイル編集すれば10回。モノレポなら各パッケージで10回ずつ。Claude Codeのセッション中に100ファイル触る日は珍しくなく、その都度tscを起動すると累積コストは数分単位になります。
しかも最悪なのは、これが「編集の応答」を直接遅らせることです。Claude Codeがファイルを書き終わった瞬間にフックが発火し、tscが終わるまで次の操作がブロックされます。コードを書くAIが書くたびに数秒止まるという状況は、スループットを根本から損ないます。
問題は「チェックをしない」ことではなく、「毎回やる」ことです。
人間がテキストエディタで作業するとき、保存のたびに全体チェックが走るIDEを使っている方は多いですが、それはUIの応答とバックグラウンドプロセスが分離されているからストレスにならないのです。Claude Codeのフックは同期的に呼ばれます。フックが終わるまでClaudeは止まる。この制約のもとで「毎回チェック」を続けることは、壁打ちの相手が返球のたびに5秒立ち止まるようなものです。
解決策は**「チェックは1セッションに1回、まとめてやる」**です。
編集のたびにパスだけを積み上げておき、Claudeがそのターンの全作業を終えたタイミング(Stop)で初めてチェックを走らせる。1回の起動で複数ファイルをまとめて処理すれば、起動コストは1/N になります。セッション中に触ったファイルが50件あっても、tscの起動は(tsconfig単位で)1〜3回です。
「作業より環境を先に整える」のが私の流儀です。
月商がゼロになったとき、最初にやったのは「Claude Codeのフックを読み直す」ことでした。Claudeにファイルを書かせる回数を増やすより、1回の書き込みあたりのロスをゼロにするほうが先だと判断しました。結果、フック整備の2日間が、その後の数百時間の効率を決めました。
フックを正しく組むと「AIが止まらない環境」が手に入ります。これは体感が全然違います。リクエストを投げてから次の結果が返ってくるまでのサイクルが詰まると、それだけ試行回数が増え、一日に出せる成果物の密度が上がります。副業の受注量が増えたのも、コードの品質が上がったのも、半分くらいはこういう環境整備の積み重ねです。
全体の流れ
実装は2ファイルに分かれています。post-edit-accumulator.js(PostToolUseフック)とstop-format-typecheck.js(Stopフック)です。役割は明確に分離されています。前者は「パスを積む」だけ、後者が「まとめて処理する」だけです。
[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 へ
PostToolUseフックの設計
post-edit-accumulator.jsの仕事はシンプルです。Claude Codeがファイルを編集するたびに呼ばれ、そのパスをtmpに1行追記するだけです。
// 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');
}
}
appendFileSyncを使う理由は並行安全性です。Claude Codeは複数のツール呼び出しを並行で発火することがあり、PostToolUseフックが同時に複数プロセス走ることがあります。appendFileSyncはOSレベルで末尾追記の原子操作になるので、プロセスが並走しても互いの書き込みが上書きされません。ロックファイルも排他制御も不要です。
tmpファイルの命名も工夫があります(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`);
}
CLAUDE_SESSION_IDが取れればそれを使い、なければprocess.cwd()のSHA1ハッシュ先頭12文字でセッションを識別します。セッション固有のファイル名にすることで、複数のClaude Codeセッションが同時に動いていても干渉しません。/Users/xxx/project-aと/Users/xxx/project-bを並行セッションで触っていても、それぞれが独立したtmpファイルにパスを積みます。
パスセパレータやトラバーサル文字(..や/等)は正規表現/[^a-zA-Z0-9_-]/gでアンダースコアに置換してから使います。CLAUDE_SESSION_IDの中身がどんな値であっても、ファイル名として安全に埋め込めるようになっています。
ツール種別の吸収も実装されています(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);
}
EditツールもWriteツールもMultiEditツールも、同じtmpに積まれます。Claude CodeがどのツールでファイルをいじってもAccumulatorは気にしません。ツール種別の違いを上位で吸収することで、Stopフック側はパスの配列だけを見ればよくなります。
重複排除をここでやらないのは意図的な設計です。appendFileSyncは追記であり、同じファイルを10回編集すれば同じパスが10行並びます。それでいい。排除は読み込み側(Stopフック)の仕事です。書き込みを単純にしておくことで、PostToolUseフック自体は数ミリ秒で終わります。Claudeの編集操作が止まるのは「パスを1行書く時間」だけです。
Stopフックの設計
Claudeがターンを終えるとstop-format-typecheck.jsが呼ばれます。こちらが実際に処理を行うメインエンジンです。
最初にやることはtmpファイルの読み込みと即時削除です(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);
読んだ直後にunlinkすることで、Stopフックが2回呼ばれた場合でも同じファイルを2重処理しません。読み込み時にファイルがなければ(JS/TSファイルをいじらなかったターン)そのままreturnします。フックが走るコストはゼロです。
重複排除は1行で終わります(L36-38)。
function parseAccumulator(raw) {
return [...new Set(raw.split('\n').map(l => l.trim()).filter(Boolean))];
}
Setに通すだけ。同じパスが何度積まれていても結果は1件です。100回編集されたファイルも、tscには1度だけ渡されます。
バジェット分配がこの設計の肝です(L173-175)。
const totalBatches = byProjectRoot.size + byTsConfigDir.size;
const perBatchMs = totalBatches > 0
? Math.floor(TOTAL_BUDGET_MS / totalBatches)
: 60_000;
TOTAL_BUDGET_MSは270_000(270秒)(L29)。Claude CodeのStopフックには300秒のタイムアウトがあり、90秒をオーバーヘッド用に残して270秒をバッチ処理に割り当てています。プロジェクトが1つだけなら270秒丸ごと使える。モノレポでtsconfigが3つ、プロジェクトルートが2つあれば、5バッチで割って1バッチあたり54秒。バッチ数に応じて自動で時間が分配されます。tsconfigが増えれば1バッチの時間は短くなりますが、トータルで300秒を超えることはありません。
グループ化の仕組みを見てみます(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);
}
findTsConfigDir関数がファイルパスから上位ディレクトリを最大20段たどってtsconfig.jsonを探します(L83-88)。これにより、packages/api/src/handlers/user.tsを編集したとき、packages/api/tsconfig.jsonを起点にしてtsc --noEmitが走ります。packages/web/のtsconfig.jsonは別バッチで独立して処理されます。同じモノレポ内でも、関係ないパッケージのチェックは走りません。
TypeScriptエラーの出力も絞り込みが入っています(L122-133)。tsc --noEmitの全出力をそのまま流すのではなく、編集したファイルのパスが含まれる行だけを最大10行フィルタして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'));
}
1000行のtscエラーをターミナルに流すのではなく、「いま触ったファイルに関係する行だけ」に絞り込みます。Claude Codeが次のターンで読むのはこのフィルタ済みエラーだけになるので、コンテキストが汚れません。
実装の詳細(深掘り)
フォーマッタの自動検出と実行
前半で触れた「プロジェクトルート別グループ化」の後、実際にフォーマッタを呼ぶのがformatBatch関数です(L48-77)。ここが実はかなり複雑で、書くまでに何度もやり直しました。
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はlib側の関数で、biome.jsonやbiome.jsoncが存在すれば'biome'を、package.jsonにprettierの記述があれば'prettier'を返します。フォーマッタが見つからなければnullを返し、formatBatchは即returnします。フォーマッタ未設定のプロジェクトでこのフックを使っても何も起きないという設計です。
重要なのはexistingFilesの生成です(L55)。
const existingFiles = files.filter(f => fs.existsSync(f));
if (existingFiles.length === 0) return;
Claudeが「ファイルを書いた後すぐ削除する」操作(一時ファイルの生成→後始末、リネームによる移動など)をした場合、Accumulatorにはパスが積まれていても、Stopフックが走る時点ではそのファイルが存在しないことがあります。存在チェックなしにフォーマッタに渡すと「file not found」でクラッシュします。existsSyncはここで必ず必要です。この問題は後で詰まった話のところで詳しく書きます。
biomeとprettierでコマンド引数の構造が違う点も見どころです。biomeはcheck --writeでフォーマットと静的解析を同時に行い、prettierは--writeだけで動きます。このフラグの差を三項演算子1行で吸収しています。
Windowsの.cmd問題(L64-76)は、macOSで開発している人には縁が薄いですが、実装として興味深い部分です。
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 });
Windowsではnpx.cmdやbiome.cmdなどの.cmdファイルを実行するとき、shell: trueオプションが必要です。しかしshell: trueにするとパスがシェル経由で解釈され、スペースや&を含むパスが破壊されます。そこでL33のUNSAFE_PATH_CHARSで事前チェックし、危険なパスが含まれていたら処理を丸ごとスキップしてstderrに警告を出す設計にしています。
macOS/LinuxではexecFileSyncを使い、シェルを挟まずに直接バイナリを呼びます。引数は配列として渡すので、パスにスペースが含まれていても安全です。
typecheckBatchのOS分岐
TypeScriptのチェックを担うtypecheckBatch(L91-133)も同様の分岐を持ちます。
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がミソです。tsc --noEmitはカレントディレクトリのtsconfig.jsonを自動的に探します。cwdをtsconfig.jsonが置いてあるディレクトリに設定することで、--projectフラグを明示せずに正しいコンフィグを読ませています。
--pretty falseはparseしやすさのためです。色コードやターミナルエスケープを除いたプレーンテキストで出力させることで、後続のエラーフィルタリング(行ベースのマッチング)が確実に動きます。ANSIエスケープが混じったままincludes()でファイルパスを探そうとすると、エスケープシーケンスに挟まれた文字が壊れてマッチしないことがあります。
stdio: ['pipe', 'pipe', 'pipe']も意図的です。stdin・stdout・stderrを全部パイプにすることで、tscの出力がターミナルに直接流れず、Nodeのバッファに入ります。失敗時だけerr.stdoutとerr.stderrを結合して後処理できます。
pass-through設計の理由
run関数(L188-195)の構造は、初見だと不思議に見えます。
function run(rawInput) {
try {
main();
} catch (err) {
process.stderr.write(`[Hook] stop-format-typecheck error: ${err.message}\n`);
}
return rawInput;
}
main()の結果を使わず、引数のrawInputをそのまま返しています。これはClaude Codeのフック仕様に従っています。StopフックはClaudeのイベントデータをstdinで受け取り、処理結果をstdoutに書き出すことが求められています。このフックはデータを変換する必要がないので、受け取ったJSONをそのまま流す(pass-through)だけです。
重要なのはtry-catchでラップしている点です。フック内でどんなエラーが起きても、Claude Codeのセッション自体を壊さないようになっています。最悪の場合、フォーマットもtypecheckもスキップされますが、Claudeの作業は止まりません。stderrに警告を出すだけです。これはフックは補助であり、メインの作業を妨げてはならないという設計思想です。
MAX_STDIN = 1024 * 1024(L22)も同じ発想です。理論上stdinが巨大になることはないですが、何らかの不具合で無限に読み続けることを防ぐために1MBのキャップを設けています。
私が詰まった話
実装の理屈は後から整理すると綺麗に見えますが、最初からこの形になったわけではありません。実際に動かしながら壊れた話を3つ書きます。
セッションIDがパスになっていてtmpファイルが作れなかった
最初の版では、getAccumFile関数の中のIDのサニタイズが甘かったです。CLAUDE_SESSION_IDの値がどんな文字列を含むか、当初は気にしていませんでした。
環境によってはCLAUDE_SESSION_IDがsession/abc123のようにスラッシュを含む形式になることがあります(バージョンや設定次第です)。そのままpath.join(os.tmpdir(), 'ecc-edited-session/abc123.txt')を構築すると、os.tmpdir()/ecc-edited-session/というディレクトリが存在しないためにappendFileSyncがENOENTでクラッシュします。
症状は単純で、PostToolUseフックが毎回エラーになる。しかしエラーはstderrに出るだけで、Claudeの作業は止まりません(pass-through設計のおかげです)。そのため「なんとなくフックが動いていない気がする」という曖昧な気づき方をしました。実際に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で英数字とハイフン・アンダースコア以外を全部_に置換します。スラッシュもドットも空白も潰れます。さらに64文字でスライスしてファイル名の長さを制御しています。修正後はsession/abc123がsession_abc123になり、問題なくtmpに作成できます。
教訓は「環境変数の中身は信頼するな」です。 仕様書に書いてない値が入ってくることがあります。外から来る値はファイルシステムに使う前に必ずサニタイズします。
Stopフックが2回呼ばれてtscが2重に走った
Claude Codeのセッション中、あるタイミングで「同じファイルに対してtscが2回走っているのに気づいた」ことがあります。ログを見たらStopイベントが2回発火していました。
調査すると、一部の操作パターン(ToolやSubagentが絡む特定のフロー)でStopフックが複数回トリガーされることがあるとわかりました。これはClaude Code側の仕様の挙動で、フック側で対処する必要があります。
当初の実装は、Stopフックが走るたびにtmpファイルを読んでいました。2回目のStopが来たとき、まだtmpファイルが残っていれば同じパスが再度処理されます。
解決策は読み込みと同時に削除することです。
let raw;
try {
raw = fs.readFileSync(accumFile, 'utf8');
} catch {
return; // ファイルがない = 処理済みか、このターンにJS/TSを触っていない
}
try { fs.unlinkSync(accumFile); } catch { /* best-effort */ }
readFileSyncの直後、内容をメモリに取った時点でunlinkSyncします。2回目のStopが来たとき、readFileSyncがENOENTで例外になり、catchブロックでreturnします。これでどれだけStopが重なっても、処理は1回だけです。
unlinkSyncをtry-catchでラップしているのは、削除が失敗しても止まらないためです。すでに別プロセスが削除済みの場合、ENOENTが出ますが、それは問題ではありません。「削除できた確証を得る必要はない、もう内容はメモリにある」という割り切りです。
この失敗から学んだのは「外部イベントは冪等に扱え」という原則です。 同じトリガーが何回来ても同じ結果になる設計にしておけば、挙動が不確定でも問題になりません。
削除済みファイルをtscに渡してエラーが連鎖した
実際のClaudeの作業中、こんなパターンがあります。tmp.tsという名前で一時ファイルを書いて、内容をチェックしてから削除する。あるいはファイルをold.tsからnew.tsにリネームする(実体は削除+新規作成)。
このとき、Accumulatorにはtmp.tsやold.tsのパスが積まれています。しかしStopフックが走る時点でそれらは存在しません。存在チェックなしにtscに渡すとどうなるか。
error TS2307: Cannot find module '/path/to/tmp.ts' or its corresponding type declarations.
このエラーがStopフックのstderrに出力されます。しかもエラーフィルタリングで「編集したファイルに関係する行」として正しく拾われるので、Claude Codeのコンテキストに入ります。Claudeが次のターンで「tmp.tsが見つからない」エラーを見て、不要な修正を試みるという事態になりました。
修正は2箇所です。byProjectRootとbyTsConfigDirの構築ループ、どちらにもfs.existsSyncチェックを追加しました。
// byProjectRoot の構築
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) continue; // ← これ
// byTsConfigDir の構築
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) continue; // ← これも
formatBatch側にも同じチェックが入っています(L55のexistingFiles)。3箇所に分散しているのは、グループ化のタイミングと実行のタイミングが別だからです。グループ化で弾いておけばtsconfig.jsonの探索コストも省けます。実行直前にも弾けば、グループ化後にファイルが消えた(ごく稀なレースコンディション)ケースにも対処できます。
この失敗から学んだのは「Accumulatorは意図ではなく事実を積む」ということです。 「編集したファイル」ではなく「編集操作があった時点でのパス」が積まれています。Stopフック側はパスの過去に責任を持たず、「今存在するか」だけを見ます。
findTsConfigDirが見つけられなかった
findTsConfigDir(L79-88)はファイルから親ディレクトリを最大20段さかのぼって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;
}
最初このdepthの上限がなく、dir !== fsRootだけで制御していました。通常の開発環境では問題ありませんでしたが、ある日シンボリックリンクを多用したモノレポ構成でテストしたとき、ループが50回近く回ってからnullを返す事象が起きました。macOSのシンボリックリンク解決がfsRoot判定に影響していたのか、詳細は追いきれませんでしたが、想定より深くたどっていたことは確かです。
depth < 20という上限を加えてから問題は再現しなくなりました。「20段」という数字は、通常の開発プロジェクトでtsconfig.jsonが存在しない深さまでたどることは現実的にあり得ないという経験則です。モノレポの典型的な構造でも、packages/module-name/src/utils/helper.tsからルートまで6〜7段です。20はその3倍の安全マージンです。
この問題の根本は「終了条件を1つだけにするな」という話です。 「ルートに達したら止まる」という自然な終了条件だけでは、想定外の環境で無限ループに近い挙動が起きます。depth < 20は論理的な終了条件ではなく、保険の終了条件です。正常ケースでは絶対に発動しないが、異常時に必ず止める。こういう保険は書いておいて損がありません。
バジェット計算が静的で大きいモノレポが詰まった
最後の失敗は初期のバジェット設計についてです。最初の版では、tscに固定の60秒タイムアウトをかけていました。
// 最初の実装(問題あり)
const TYPECHECK_TIMEOUT_MS = 60_000;
for (const [tsDir, batch] of byTsConfigDir) {
typecheckBatch(tsDir, batch, TYPECHECK_TIMEOUT_MS);
}
これをtsconfigが5つある大きなモノレポで動かしたとき、合計で300秒を超える可能性がありました(5 × 60秒 = 300秒、さらにフォーマット分が乗る)。Claude CodeのStopフックは300秒でタイムアウトするので、最後のいくつかのtscがキャンセルされてしまいます。
現在の実装は動的バジェット分配です。
const totalBatches = byProjectRoot.size + byTsConfigDir.size;
const perBatchMs = totalBatches > 0
? Math.floor(TOTAL_BUDGET_MS / totalBatches)
: 60_000;
TOTAL_BUDGET_MS = 270_000(270秒)を総バッチ数で割ります。tsconfigが5つ、プロジェクトルートが2つ(フォーマッタ用)なら、合計7バッチで270_000 / 7 ≈ 38_571ミリ秒(約38秒)が各バッチの上限になります。7バッチ × 38秒 = 266秒で、270秒に収まります。さらに270秒はClaude Codeの300秒上限から30秒引いた値なので、フックの起動オーバーヘッドを含めても300秒を超えることはありません。
プロジェクト数が1つだけなら270_000 / 1 = 270_000(270秒)丸ごと使えます。モノレポが大きくなればなるほど1バッチの時間は短くなりますが、トータルは常に270秒以内です。全体のパイが固定で、バッチが増えればスライスが細くなる、という単純な話です。
この設計変更後、どのサイズのプロジェクトでもStopフックがタイムアウトで途中終了することはなくなりました。
詰まった話をまとめると、この実装で詰まるポイントはほぼ全て「境界条件」です。セッションIDの外から来る値、Stopが複数回来る、ファイルが途中で消える、tsconfigが見つからない、バジェットが溢れる。これらは単体テストでは再現しにくく、実際に動かしながら踏まないと気づけない類の問題でした。
フックは「補助ツール」なので、壊れても作業は止まらないという安心感がテストを甘くする危険があります。私が意識的にやったのは「フックのエラーが見えにくい環境でこそ積極的にstderrを見に行く」ことです。Claudeの返答が自然に見えていても、フックが静かにサボっていることがあります。
つまずきポイント
前段で詳しく掘り下げた5つのトラブル(セッションIDのパス汚染・Stopの二重呼び出し・削除済みファイル・findTsConfigDirの深掘り・静的バジェット)に加えて、実際に踏んだその他のつまずきを網羅しておきます。
フック実行環境まわり
-
nvmのNode.jsがPATHに乗っていなくてフックが起動しない Claude Codeのフックが起動するシェルは
~/.zshrcを読みません。nvmで管理しているNode.jsは自動でPATHに入らず、nodeコマンドが見つからない状態になります。hooks.jsonにフックを登録するとき、コマンドにNode.jsの絶対パスを直接書くか、PATHを明示したラッパーシェルスクリプトを挟みます。#!/usr/bin/env nodeというシェバンだけでは足りません。 -
実行権限がなくて静かに失敗する
chmod +xを忘れるとPermission deniedでフックが起動しません。しかしpass-through設計のおかげでClaude Codeの作業自体は止まらず、「なんとなく動いていない気がする」という気づき方をします。Claude Codeの--debugモードのログか、StopフックのstderrログをあとからGrepするのが最短の確認手段です。 -
console.log()でデバッグしてstdoutを汚すとClaude Codeがパースエラーを起こす フックのstdoutはClaude Codeに返すイベントデータです。console.log('debug')でテキストを出力すると、受け取り側がJSONのパースに失敗します。デバッグ出力は必ずprocess.stderr.write()かconsole.error()を使います。これを知らずに開発すると、「フックを入れたらClaudeの挙動がおかしくなった」という現象で表れます。 -
hooks.jsonのmatcherが*のまま内部でtscを直接呼んでいた初期版 フックとtscを直結する構成を最初に試したとき、matcherを全編集に設定していたためHTML・Markdownの編集でもtscが起動しました。post-edit-accumulator.js内のJSTS拡張子フィルタ(L38:/\.(ts|tsx|js|jsx)$/)はフック内部で絞り込む設計であり、hookのmatcherは全編集を受け取る前提です。この役割分担を誤解すると無駄なtsc起動が続きます。
Accumulator(PostToolUse側)のつまずき
-
MultiEditの
edits配列を見ていなくて一部のパスが積まれない EditツールとWriteツールはtool_input.file_pathに単一のパスを持ちます。一方、MultiEditはtool_input.editsという配列の各要素にfile_pathがあります。どちらか一方しか処理していないと、MultiEditで複数ファイルを一括編集したとき、最初の1件しかAccumulatorに積まれません。post-edit-accumulator.jsはL56-58で両方を明示的に処理しています。 -
同じファイルを何度も積んでStopフックが重くなると思って排除しようとした PostToolUse側で「すでに積まれているか確認してから追記」する実装を試みると、読み→比較→書きの間で並行プロセスと競合し、排他制御が必要になります。重複はStopフック側の
[...new Set()](L37)で一括排除する設計なので、PostToolUse側は何も考えずappendFileSyncで積むだけが正解です。
Stopフック側のつまずき
-
--pretty falseを省いてANSIエスケープがエラーフィルタを破壊するtsc --noEmitはデフォルトでカラー出力します。エラー行に\x1b[91m等のエスケープシーケンスが含まれると、ファイルパスをincludes()で探すフィルタ(L127)がパス文字列を正しくマッチできなくなります。実際にTypeScriptエラーが出ているのにClaudeのコンテキストにエラーが届かず、修正されないまま次のターンに進みます。--pretty false(L94)は省略不可です。 -
stdio: 'inherit'にしてtsc出力がターミナルに直接流れるexecFileSyncのstdioオプションを省略するとデフォルトの'inherit'になり、tscの出力がそのままStopフックのstdoutに流れます。pass-through設計が壊れ、Claude Codeがイベントデータとtscの出力を混在して受け取ります。stdio: ['pipe', 'pipe', 'pipe'](L95)で三本とも明示するのが必須です。 -
cwdを省略してtscが間違ったtsconfigを読むtypecheckBatchはcwd: tsConfigDir(L95)でtscを実行します。これを省くとtscがカレントプロセスのディレクトリからtsconfig.jsonを探し、packages/api/のファイルをpackages/web/の設定でチェックするという事態になります。モノレポでは致命的です。--projectフラグを使う方法もありますが、cwdを合わせる方がシンプルです。 -
stdout・stderrどちらか一方しか見ないとエラーが消える
tscがエラーをstdoutに出すかstderrに出すかはバージョンや設定次第で変わります。typecheckBatch(L122)では(stdout + stderr).split('\n')で両方を結合してからフィルタします。stderrだけ見ているとstdoutに出たエラーが消え、「エラーがないはずなのに動かない」という混乱になります。 -
biomeとprettierの引数を混同してクラッシュする biomeは
check --write、prettierは--writeです(L58-61)。prettierにcheck --writeを渡すとcheckというファイルを書こうとして失敗します。biomeに--writeだけ渡すとサブコマンドがなくてエラーになります。フォーマッタ検出→コマンド生成の分岐はLib側に隠蔽されており、直接呼ばないのが安全です。 -
フォーマッタをグローバルインストール前提で書いたら他環境で動かなかった
resolveFormatterBinがプロジェクトのnode_modules/.bin/から検索する設計になっているため、グローバルインストールのbiomeやprettierには依存しません。ただしnode_modulesが存在しない状態で動かすと「フォーマッタが見つからない→何もしない」という挙動になります。これ自体は安全な設計ですが、「フォーマットが走っていない」と気づきにくいのがつまずき原因です。 -
jsxファイルをtscに渡してしまう
byTsConfigDirの構築ループ(L162)には!/\.(ts|tsx)$/のフィルタが入っています。.js・.jsxはフォーマッタのみの対象で、tscには渡しません。このフィルタを外すと.jsxファイルがfindTsConfigDirの探索対象になり、tsconfig設定次第で予期しないエラーが出ます。 -
エラーフィルタで絶対パスだけを候補にして相対パスエラーを見逃す
typecheckBatchのエラーフィルタ(L124-125)はファイルの絶対パスとtsConfigDirからの相対パスの両方を候補セットに入れています。tscがエラー行にどちらの形式でパスを書くかはtsconfigのrootDir設定次第です。絶対パスだけ、あるいは相対パスだけで検索すると一部のエラーが素通りします。
ベストプラクティス
実装と失敗から導いた指針です。この構成で初めてフックを組む方が見落としやすい順に並べています。
1. PostToolUseは積むだけ・判断はStopで一括する PostToolUseで「重複を排除しながら積む」「処理も並行してやる」という構成にすると、並行プロセスとの競合管理が必要になります。書き込みを単純に保ち、判断と処理はStopに集約する設計が最もシンプルで堅牢です。
2. 追記にはappendFileSyncで並行安全を確保する
PostToolUseフックは同時に複数プロセス走ることがあります。appendFileSyncはOSレベルで末尾追記の原子操作になるので、ロックファイルも排他制御も不要です。ファイルへの並行書き込みが必要な場面ではappendFileSyncが最初の選択肢です。
3. 環境変数はファイル名に使う前に英数字のみに絞る
CLAUDE_SESSION_IDの値の形式はバージョンや環境で変わります。/[^a-zA-Z0-9_-]/gで英数字・ハイフン・アンダースコア以外を置換し、.slice(0, 64)で長さを制限します(L30-31)。外から来る値を「仕様通りの形式が来るはず」と信頼すると、稀なケースでファイル作成が失敗します。
4. 冪等性は「読んで即削除」で実現する
Stopフックが複数回呼ばれることを前提に設計します。tmpファイルをreadFileSyncした直後にunlinkSyncすることで(L141-146)、2回目以降の呼び出しはENOENTでキャッチして即returnします。ロックファイルを管理するより確実でコードも短くなります。
5. existsSyncはグループ化前と実行前の2段階で入れる
Accumulatorに積まれたパスはその時点での事実であり、Stopフック実行時の保証ではありません。byProjectRoot・byTsConfigDirの構築ループでそれぞれexistsSyncチェックを入れ(L155・L165)、さらにformatBatchの実行直前でもexistingFilesとして弾きます(L55)。多段防御が「削除済みファイルを渡してtscがクラッシュする」問題を完全に防ぎます。
6. タイムアウトは外部制約から逆算して動的に分配する
Claude CodeのStopフックには300秒の上限があります。TOTAL_BUDGET_MS = 270_000(L29)はその制限から30秒のオーバーヘッドを引いた値です。これをバッチ数で均等割り(L175)することで、モノレポのtsconfigが何個になっても全体が300秒に収まります。固定値でなく「外部制約 ÷ バッチ数」という動的分配が正解です。
7. ループには論理的な終了条件と保険の終了条件の両方を持たせる
findTsConfigDirのwhileループはdir !== fsRoot(論理的な終了条件)とdepth < 20(保険の終了条件)を組み合わせています(L82-83)。「20段より深いtsconfigはない」という経験則による上限で、シンボリックリンクや環境依存の挙動でループが想定より深くなる場合に止まります。外部ファイルシステムを走査するループには必ず保険を入れます。
8. tscは--pretty falseとstdio: pipeで制御する
tsc --noEmit --pretty false(L94)でANSIエスケープを除去し、stdio: ['pipe', 'pipe', 'pipe'](L95)でNodeのバッファに受け取ります。この2つをセットにしないと、エラーのフィルタリングが壊れるかClaudeのstdoutが汚染されます。tscをサブプロセスで呼ぶときの定型設定として覚えます。
9. tscエラーは「編集ファイル関連・最大10行」に絞る
tscの全出力をそのままClaudeのコンテキストに流すと、無関係なエラーがノイズになりClaudeが誤った修正を試みます。絶対パス・相対パスの両方を候補にしてフィルタし(L124-128)、.slice(0, 10)で上限を設けます。フックが出力するエラーは「Claudeの次のアクションに必要な最小の情報量」にとどめる原則です。
10. フック全体をtry-catchでラップしてClaudeの作業を妨げない
フックは補助です。バグがあってもClaudeの本作業を止めるべきではありません。run()全体をtry-catchでラップし(L188-194)、エラーはstderrに書くだけにします。フォーマットやtypecheckが失敗しても、Claudeは次のターンに進めます。フックを完璧に動かすことより、壊れても問題にならない設計にすることの優先度が高いです。
11. stdoutはpass-through専用・デバッグはstderrに書く
フックのstdoutはClaude Codeへの返却データです。console.log()でデバッグ文字列を混入させると、Claude Codeがパースエラーを起こします。開発中のデバッグは全てprocess.stderr.write()かconsole.error()を使います。これを習慣化することで、「フックを追加したらClaudeの挙動がおかしくなった」という状況を完全に防げます。
12. フォーマッタは自動検出・未設定なら無音でスキップする
detectFormatterがbiome.jsonとpackage.jsonを確認してフォーマッタを選択し、見つからなければnullを返します。nullの時点でformatBatchは即returnします。「フォーマッタが入っていない状態でフックが壊れる」ことなく、どんなプロジェクトにも安全に持ち込めます。新環境でフックを設定したとき「なぜフォーマットされないのか」とデバッグするより、「なぜかかかっているのか」を調べる心配のほうが少ないです。
13. cwd: tsConfigDirでtscに正しいコンテキストを渡す
tscはカレントディレクトリのtsconfig.jsonを自動検索します。cwdをtsconfig.jsonが置かれているディレクトリに設定することで--projectフラグなしに正しいコンフィグが読まれます。モノレポではcwdの設定だけで複数パッケージの独立したチェックが実現します。
14. stdoutの読み込みには上限を設ける
MAX_STDIN = 1024 * 1024(L22)でstdinの読み込みを1MB上限にしています。通常のフックイベントでは関係ない値ですが、バグや予期しない状態でstdinが無限に流れてきたとき、プロセスがメモリを食い続けるのを防ぎます。外部から来るストリームには必ず上限を設けます。
15. フックのエラーが「見えにくい」ことを知っておく pass-through設計により、フックが完全に機能していなくてもClaude Codeの表の動作には影響が出ません。「なんとなくフォーマットされていない」「tscが走っていない気がする」という曖昧な気づき方をすることが多いです。定期的にstderrのログを確認するか、フックが動いたことを示す軽量なstderrメッセージを追加しておくと診断が速くなります。
まとめ
2ファイル・200行ほどのコードが「tscの起動を1セッション1回にする」という目標を実現しています。設計の軸はシンプルです。PostToolUseで積み、Stopでまとめて処理する。この分離があるから、Claudeが編集操作を終えるたびに止まることなく次のアクションに進めます。
ただ、その200行の内側には具体的な判断が積み重なっています。
セッションIDのサニタイズは「外から来る値の形式は保証されない」という前提から来ています。即unlinkによる冪等性は「同じトリガーが複数回来ることを想定に含める」という前提から来ています。existsSyncの2段チェックは「Accumulatorは過去の操作の記録であり、現在のファイル状態の保証ではない」という前提から来ています。動的バジェット分配は「外部制約(300秒)をまず確認し、そこから逆算して設計する」という前提から来ています。
これらは全て「実際に動かして壊れた後」に判明したことです。静的解析でも単体テストでも事前に発見できなかった。フックを本番環境のClaudeセッションで使い続けることで初めて表れた問題でした。
Claude Codeを使い始めると、最初は「どんなコードを書かせるか」に集中します。でも毎日100ファイル触るようになると、「tscが毎回走っていないか」「エラーがコンテキストを汚染していないか」「Claudeが止まっている時間を削れないか」という問いのほうが生産性の差を生みます。月商がゼロになったあの半年間、最初の2日間をフック整備に使ったのは今も正解だったと思っています。環境を先に整えることで、その後の数百時間の質が変わりました。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!