📊 📊 Stopフックのpayloadにusageは入らない——52日間ゼロ行を吐き続けたコストトラッカー修正記 — リーダー×
📊

📊 Stopフックのpayloadにusageは入らない——52日間ゼロ行を吐き続けたコストトラッカー修正記

#automation#claudecode#副業2026-08-18 · 約29

月10万の大学生だったわたしが、会社都合の解雇を経て半年でClaude Code自律環境を構築し、今は月商120万を超えています。その差を埋めたのは才能でも資本でもなく、自分の代わりに考えて動き続ける環境を育て続けたことです。

なぜこの仕組みが効くのか

個人開発で稼ぎを伸ばす上でわたしが最初に気づいたのは、「作業量を増やす」より「環境の質を上げる」ほうが圧倒的にROIが高いという事実でした。Claude Codeを1日8時間叩くより、Claude Codeが自律的に動いて、自分の不在中にも成果物を積み上げてくれる仕組みを1週間かけて作るほうが、3ヶ月後の収益には効く。この連載はその「環境構築の量産」を記録したものです。

今回取り上げるのはコスト可視化です。Claude Codeをフル活用する生活では、トークン消費は呼吸のように自然に発生します。問題は、そのコストが見えないと最適化できないことです。「今月いくら使ったか」「どのセッションが重かったか」「Sonnetをもう少しHaikuに寄せれば月何ドル浮くか」——これらが把握できていなければ、稼ぎが増えても粗利は改善しません。

Claude Codeにはhookシステムがあります。~/.claude/settings.json で設定したシェルスクリプトやNode.jsスクリプトを、特定のイベントに紐づけて自動実行できます。Stop フックはその中でもっとも重要で、アシスタントがターンを完了するたびに発火します。セッション終了時だけでなく、1ターン完了するごとにです(cost-tracker.js のコメント19行目: "Stop fires per assistant response, not per session")。

ここで自然に浮かぶ発想が「Stopフックでトークン数を記録すれば自動コストトラッキングができる」です。実装は単純に見えます。フックにはstdinでJSONペイロードが渡される。そのペイロードから usage.input_tokensusage.output_tokens を読んでJSONLに追記すれば終わり——そう思って実装するのが第一版の過ちです。

Stopフックのpayloadには usage フィールドが存在しません。

ペイロードの実体は次の形です。

{
  "session_id": "...",
  "transcript_path": "/path/to/session.jsonl",
  "cwd": "/path/to/workdir",
  "hook_event_name": "Stop"
}

session_idtranscript_pathcwdhook_event_name——これだけです。モデル名も、トークン数も、コストも入っていません。ドキュメントに明記されていないため、usage があると思い込んでコードを書くと、存在しないフィールドを読もうとして undefined を取得し、Number(undefined)NaN になり、NaN を足し続けて 0 が記録されます。エラーは出ません。ただ静かに、毎ターン、ゼロが積み上がります。

わたしの cost-tracker.js のコメントには、その証拠がそのまま残っています(12〜13行目)。

* The Stop payload does NOT include `usage` or `model` directly. The previous
* version of this hook expected those fields and silently produced zero-filled
* rows (verified: 2,340 rows captured with 0.0% non-zero token rate over 52
* days).

52日間、2,340行、非ゼロ率0.0%。トラッカーとしては完全な失敗です。しかもその間、スクリプトは何事もなく動き続け、ログファイルは着実に育ち、cost-summary.sh を実行すれば「$0.00 / 0 sess」が返ってきていました。動いているように見えて、何も記録していなかった。

これは単なる実装バグではなく、Claude CodeのStop hookという仕組みへの誤解から来るアーキテクチャミスです。修正するには、ペイロードに存在しないフィールドを諦め、ペイロードが指し示す別の場所——transcript_path——を読みに行く必要があります。

この切り替えが今回の記事の核心です。

全体の流れ

修正後のアーキテクチャを俯瞰すると、次のようになります。

┌─────────────────────────────────────────────────────┐
│  Claude Code セッション                              │
│                                                     │
│  アシスタントターン完了                              │
│       │                                             │
│       ▼                                             │
│  Stop フック発火                                    │
│       │                                             │
│       ▼ stdin (JSON)                                │
│  { session_id, transcript_path, cwd, ... }          │
│       │                                             │
│       ▼                                             │
│  cost-tracker.js                                    │
│  ┌──────────────────────────────────────────────┐   │
│  │  1. transcript_path を取得                   │   │
│  │  2. JSONL を読み込み                         │   │
│  │  3. type="assistant" の行だけフィルタ        │   │
│  │  4. message.usage を積算                     │   │
│  │  5. モデル名からレートを引いてコスト計算     │   │
│  │  6. ~/.claude/metrics/costs.jsonl に追記     │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

ポイントは「Stopフックはコスト情報の配達員ではない」という認識の転換です。フックはあくまでイベントの通知者であり、通知の中に含まれるのは「どのセッションのトランスクリプトを読めばいいか」という住所(transcript_path)だけです。コスト情報はトランスクリプトの中にあります。

transcript_path が指すJSONLの構造

Claude Codeはセッション中の全ターンを1本のJSONLファイルに書き出しています。各行が1つのメッセージに対応し、type フィールドで種別が分かれます。

コスト計算に必要なのは type: "assistant" の行です。その行の構造は次のとおりです。

{
  "type": "assistant",
  "message": {
    "model": "claude-sonnet-4-6",
    "usage": {
      "input_tokens": 12483,
      "output_tokens": 847,
      "cache_creation_input_tokens": 8192,
      "cache_read_input_tokens": 3200
    }
  }
}

input_tokensoutput_tokenscache_creation_input_tokenscache_read_input_tokens の4種類を全assistantターン分積算すれば、セッション全体のトークン消費量が得られます。これに課金レートを掛ければコストになります。

sumUsageFromTranscript 関数の実装

cost-tracker.js の57〜90行目に、このJSONL積算ロジックが実装されています。

function sumUsageFromTranscript(transcriptPath) {
  let content;
  try {
    content = fs.readFileSync(transcriptPath, 'utf8');
  } catch {
    return null;
  }

  let inputTokens = 0;
  let outputTokens = 0;
  let cacheWriteTokens = 0;
  let cacheReadTokens = 0;
  let model = 'unknown';

  for (const line of content.split('\n')) {
    if (!line.trim()) continue;
    let entry;
    try { entry = JSON.parse(line); } catch { continue; }

    if (entry.type !== 'assistant') continue;
    const msg = entry.message;
    if (!msg || !msg.usage) continue;

    const u = msg.usage;
    inputTokens      += toNumber(u.input_tokens);
    outputTokens     += toNumber(u.output_tokens);
    cacheWriteTokens += toNumber(u.cache_creation_input_tokens);
    cacheReadTokens  += toNumber(u.cache_read_input_tokens);

    if (msg.model && msg.model !== 'unknown') model = msg.model;
  }

  return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
}

注目すべき設計判断が3つあります。

パースエラーを握りつぶして続行する。 JSONLの途中の行が壊れていても、try { entry = JSON.parse(line); } catch { continue; } でスキップします。Stopフックは非ブロッキングでなければなりません。コストログが取れなかったせいでClaude Codeのセッション終了が失敗するのは本末転倒です。

toNumber() でNaNを防ぐ。 旧実装が 0 を書き続けた原因は、存在しないフィールドを数値に変換しようとしてNaNが伝播したことです。新実装では toNumber() ヘルパーが Number.isFinite() で検査し、有限数でなければ 0 を返します(47〜49行目)。

モデル名は最後に見つかったものを使う。 1セッション中にモデルが切り替わるケースを想定し、msg.model'unknown' でない限り上書きし続けます。セッション後半のモデルが代表値になりますが、コスト誤差は概ね許容範囲内です。

レートテーブルとコスト計算

モデルごとの課金レートは34〜38行目にハードコードされています。

const RATE_TABLE = {
  haiku:  { in: 0.80,  out: 4.0,  cacheWrite: 1.00,  cacheRead: 0.08 },
  sonnet: { in: 3.00,  out: 15.0, cacheWrite: 3.75,  cacheRead: 0.30 },
  opus:   { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 }
};

単位は1Mトークンあたり米ドルです。getRates() 関数(40〜45行目)がモデル名の文字列から haikuopus、その他(デフォルト sonnet)を判定します。

コスト計算式は128〜133行目で完結しています。

const estimatedCostUsd = Math.round((
  (inputTokens      / 1e6) * rates.in +
  (outputTokens     / 1e6) * rates.out +
  (cacheWriteTokens / 1e6) * rates.cacheWrite +
  (cacheReadTokens  / 1e6) * rates.cacheRead
) * 1e6) / 1e6;

/ 1e6 * 1e6 の往復は浮動小数点誤差を丸めるためです。マイクロドル単位での丸めにより、0.000001 未満の誤差が結果に残りません。

最終的に ~/.claude/metrics/costs.jsonl に追記される1行は次の形になります。

{
  "timestamp": "2026-08-18T08:45:22.000Z",
  "session_id": "abc123",
  "transcript_path": "~/.claude/transcripts/abc123.jsonl",
  "model": "claude-sonnet-4-6",
  "input_tokens": 12483,
  "output_tokens": 847,
  "cache_write_tokens": 8192,
  "cache_read_tokens": 3200,
  "estimated_cost_usd": 0.051234
}

ここまでが1ターン完了ごとに自動で記録されます。Stopフックが発火するたびに、セッションの「その時点までの累積コスト」が追記されます。1セッションあたりの最終コストを知りたければ、同じ session_id の最後の行を取ればよいことになります。

次のパートでは、52日間ゼロを吐き続けた旧実装の具体的な失敗パターンと、修正前後で cost-summary.sh の出力がどう変わったかを検証します。

実装の詳細

stdinの受け取り設計と64KB上限

Stopフックへの入力はstdinで届きます。Node.jsのstdinはストリームなので、データが複数チャンクに分割されて届くことがあります。cost-tracker.js は92〜98行目でこれを次のように扱っています。

const MAX_STDIN = 64 * 1024;
let raw = '';

process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
  if (raw.length < MAX_STDIN) raw += chunk.substring(0, MAX_STDIN - raw.length);
});

上限を64KB(65,536バイト)に設けているのは、StopフックのペイロードはJSON4フィールドで実質数百バイトしかありませんが、将来のClaude Codeの仕様変更や、予期しない大きなペイロードでプロセスがハングするのを防ぐための防衛的設計です。raw.length < MAX_STDINchunk.substring(0, MAX_STDIN - raw.length) の二重のガードにより、どんな入力が来ても読み込みバッファは上限で確実に止まります。

もし上限を超えてJSONが途中で切れた場合、100行目の JSON.parse(raw) が例外を投げます。しかしそれは外側の try { ... } catch { } が受け止め、フックは何事もなく終了します。重要なのは151〜156行目の構造です。

  } catch {
    // Non-blocking — never fail the Stop hook.
  }

  // Pass stdin through (required by ECC hook convention).
  process.stdout.write(raw);
});

process.stdout.write(raw)try の外にあります。パース失敗でコスト記録が飛んでも、stdinの内容は必ずstdoutへ流れます。これはECCフック規約の要件で、フック連鎖の後続に入力を届けることを最優先する設計思想の現れです。コストログが1行欠けることと、Stopフック全体がクラッシュすることは、影響の大きさが文字どおり比べものになりません。

transcript_pathの3段フォールバック

104〜106行目に、transcript_path の取得ロジックがあります。

const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path)
  ? input.transcript_path
  : process.env.CLAUDE_TRANSCRIPT_PATH || null;

第一候補はペイロードの input.transcript_path。文字列型であり、かつ空でないことを確認してから採用します。typeof チェックが入っているのは、ペイロードが空オブジェクト {} にフォールバックした場合(stdinが空だった等)に undefined を触って例外が出るのを防ぐためです。

第二候補は環境変数 CLAUDE_TRANSCRIPT_PATH。これはテスト・デバッグ用です。実際のStopフック経由では不要ですが、スクリプトを単体で手動実行してログを確認したい時に、環境変数を手でセットして動かせます。

CLAUDE_TRANSCRIPT_PATH=~/.claude/transcripts/abc.jsonl \
  echo '{}' | node cost-tracker.js

ペイロードから直接読み込もうとした旧実装はこの設計が存在せず、transcript_pathという変数さえありませんでした。

sessionId解決チェーン

セッションIDの取得は108〜112行目で3段階になっています。

const sessionId =
  sanitizeSessionId(input.session_id) ||
  sanitizeSessionId(process.env.ECC_SESSION_ID) ||
  sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||
  'default';

sanitizeSessionId はUUID形式の検証と無害化を行うユーティリティです。ペイロードの session_id が取れれば理想的ですが、ECCのセッション管理環境では ECC_SESSION_ID、素のClaude Codeでは CLAUDE_SESSION_ID を環境変数として持つことがあります。どれも取れなければ 'default' に落ちます。'default' の行は実質的に「何かが完全に壊れている」シグナルとして後から確認できます。

コスト計算の浮動小数点対策

128〜133行目のコスト計算式を再掲します。

const estimatedCostUsd = Math.round((
  (inputTokens      / 1e6) * rates.in +
  (outputTokens     / 1e6) * rates.out +
  (cacheWriteTokens / 1e6) * rates.cacheWrite +
  (cacheReadTokens  / 1e6) * rates.cacheRead
) * 1e6) / 1e6;

* 1e6 してから Math.round して / 1e6 する往復は、マイクロドル以下の浮動小数点誤差を消すためです。0.1 + 0.2 === 0.30000000000000004 という JavaScript の有名な罠が、コスト集計の小数点以下に悪影響を残すのを防いでいます。$0.051234 が $0.051234000000000003 としてJSONLに記録されても実害はないと言えばそうですが、後から cost-summary.sh で集計する時の数値の見た目が汚くなります。たった1行の処理ですが、記録の品質に対して真剣に考えていることが分かります。


私が詰まった話

修正作業は「ゼロを書き続ける旧実装をtranscript読み込みに書き直す」だけで終わりませんでした。v2に書き直した後も、別の問題が2つ重なって、検証作業が迷路になりました。

詰まり①:v2に直したのにsummaryはまだ「$0.00 / 0 sess」

sumUsageFromTranscript を実装してデプロイした翌朝、cost-summary.sh を実行しました。

=== cost summary (last 7d) ===
  sessions: 0
  total:    $0.00

「また0か」と思いながら、まず ~/.claude/metrics/costs.jsonl を直接覗きました。

tail -3 ~/.claude/metrics/costs.jsonl

ファイルは存在し、当日分の行が3件記録されていました。estimated_cost_usd の値は 0.0482910.0314570.072139。v2は正しく動いていました。

原因は cost-summary.sh の読み込み先でした。スクリプト10行目を見てください。

LOG="$HOME/.claude/logs/cost-log.jsonl"

~/.claude/logs/cost-log.jsonl です。一方、cost-tracker.js が書き出す先は ~/.claude/metrics/costs.jsonlディレクトリが違います。logsmetrics

さらにフィールド名も合っていませんでした。cost-summary.sh の集計ロジック(39〜47行目)はこうなっています。

total += r.get("cost_usd", 0)
t = datetime.datetime.fromisoformat(r["ts"])

cost_usdts を読んでいます。しかし cost-tracker.js が書くフィールドは estimated_cost_usdtimestamp です。

つまりv2に直した時点で、ファイルパスのズレとフィールド名のズレという2つの不一致が同時に存在していました。どちらか一方なら cost-summary.sh が空ファイルを読んで「0 sess」を返す。両方同時だと症状が変わらないため、「v2もまだ動いていない」と誤判断しかけました。

実際に動いていたのはv2のhookでした。動いていなかったのはsummaryスクリプトの読み込み先でした。

デバッグのポイントは「末端の記録ファイルを直接 tail -f で見る」ことでした。サマリスクリプトの出力を信じて「動いていない」と判断するのではなく、生のJSONLを自分の目で確認する。パイプラインのどの段階が壊れているかを一段ずつ切り分ける習慣がなければ、この2重不一致は解けませんでした。

詰まり②:NaNが伝播しても行が壊れて見えない理由

v1のコードが2,340行ゼロを書き続けた仕組みを、もう少し深く追います。

旧実装は(擬似的に書くと)次のような形でした。

// v1 (旧実装の想定コード)
const payload = JSON.parse(raw);
const inputTokens  = Number(payload.usage?.input_tokens);   // undefined → NaN
const outputTokens = Number(payload.usage?.output_tokens);  // undefined → NaN
const cost = (inputTokens / 1e6) * rates.in + ...;          // NaN

Number(undefined)NaN を返します。NaN を使った四則演算は全て NaN を返します。ここまでは予測できます。問題は次の行です。

const row = { estimated_cost_usd: NaN, input_tokens: NaN, ... };
JSON.stringify(row);
// → '{"estimated_cost_usd":null,"input_tokens":null,...}'

JSON.stringifyNaNnull に変換します。 これはJavaScriptの仕様です。エラーではなく、静かに null になります。JSONLに書かれた行は壊れたJSON形式ではなく、完全に正常な形式の行です。ただ値が全て null なだけです。

cost-summary.sh 側のPythonコード(40〜49行目)では r.get("cost_usd", 0) という書き方をしているため、キーが存在するが値が null の場合は None が返ります。Pythonで total += NoneTypeError を投げますが、49行目の except Exception: continue が全例外をスキップするため、そのセッションの行は無視されて total は動きません。

エラーなし。例外なし。ただ静かに集計対象からスキップされ続ける。ゼロが記録されているのではなく、記録された行が集計から除外され続けていたという構造でした。null を書いたのはJSONの仕様、null をスキップしたのはPythonのtry-except。どちらも個別には正しい振る舞いですが、組み合わさって52日間の盲点を作りました。

新実装の toNumber() ヘルパー(47〜49行目)はこの伝播を源流で断ちます。

function toNumber(v) {
  const n = Number(v);
  return Number.isFinite(n) ? n : 0;
}

Number.isFinite(NaN)false を返します。NaNInfinitynullundefined も、ここで 0 に変換されます。記録行に null が混入する経路を、変換の最上流で封じています。

詰まり③:「model: unknown」行の扱いで思わぬコスト過大計上

sumUsageFromTranscript はセッションJSONLのassistantターンを走査し、最後に見つかった msg.model を代表モデルとして採用します(86行目: if (msg.model && msg.model !== 'unknown') model = msg.model)。

ところがStopフックは「1アシスタントターン完了ごとに」発火します(コメント19行目: "Stop fires per assistant response, not per session")。セッションの最初のターンが終わった直後にフックが動いた時、トランスクリプトにはassistantターンが1行しかなく、そのターンでモデル名が正しく記録されていれば問題ありません。

しかし、まれにセッション初期のassistantメッセージに model フィールドが存在しない場合があります(ストリーミング中断・ツール使用のみのターン等)。その場合、model'unknown' のまま getRates('unknown') に渡されます。

function getRates(model) {
  const m = String(model || '').toLowerCase();
  if (m.includes('haiku')) return RATE_TABLE.haiku;
  if (m.includes('opus'))  return RATE_TABLE.opus;
  return RATE_TABLE.sonnet;   // ← fallthrough
}

'unknown' はどの条件にもマッチしないので sonnet レートが適用されます。Haikuモデルを使っていたセッション初期のターンが sonnet レートで計算され、コストが3.75倍( in: 0.80 vs in: 3.00)に膨らんで記録されます。

このズレに気づいたのは、costs.jsonl を直接覗いていて model: unknown の行が複数あることに気づいた時でした。

{"model":"unknown","input_tokens":4821,"estimated_cost_usd":0.014463}

4,821 inputトークンをsonnetレートで計算すると約 $0.0145。haikuレートなら約 $0.0039。4倍近い差です。

根本解決は「モデルが取れなかった行のコスト計算を保留する」か「後続ターンでモデルが判明した時に遡って補正する」ことですが、実装複雑度が跳ね上がります。現時点の実装では unknown 行はsonnetレートで近似計上し、月次の誤差は許容範囲と判断する 方針にしています。コストトラッカーに求めるのは経営判断に使える粒度の精度であり、Anthropicコンソールと1円単位で一致させることではありません。

この割り切りを意識的にしているかどうかが、ツールの信頼性に対する態度の差だとわたしは思っています。「そういう仕様」で済ませるのではなく、「この行が unknown になる条件」「その時のコスト誤差の上限」「それが月次集計に与えるインパクト」まで追って初めて、安心して使える数字になります。


次のパートでは、修正後の cost-summary.sh が実際に非ゼロを返すようになった様子と、日次コストグラフで見えてきた意外な消費パターンを取り上げます。

つまずきポイント

前2パートでは「Stopフックのpayloadにusageは無い」「NaN→nullのJSON.stringify罠」「path/field名ズレ」「model:unknown過大計上」の4点を掘り下げました。ここでは、そこから漏れた実際の詰まりを網羅します。どれも「ありえない」と思っていた壁です。


  • Stopフックはターンごとに発火するため、同一session_idの行が何十行も積まれる。 1セッション中にアシスタントターンが30回あれば costs.jsonl に30行が書かれます。cost-tracker.js のコメント19行目("Stop fires per assistant response, not per session")に明記されていますが、集計スクリプト側がこれを知らないと、全行を合計して実コストの何十倍もの値を出します。わたしは「月$90使った」と思っていたら実際は$4だった、という逆方向の誤りもやりました。per-sessionコストを取るには、同一 session_id の最後の行(= 累積の最大値)だけを集計しなければなりません。cost-summary.sh 現状の集計ロジック(37〜49行目)はこの累積構造を考慮せず for line in open(log): で全行を加算しています。hookをv2に直しただけでは不十分で、summaryスクリプトも直す必要があります。

  • ~ のままの transcript_pathfs.readFileSync に渡すと即クラッシュする。 Node.jsの fs.readFileSync('~/.claude/...') はシェル展開をしません。~ はただの文字列として扱われ、ファイルが存在しないとして例外を投げます。cost-tracker.js の104〜106行目でパスを受け取った後、os.homedir()path.resolve() で展開する処理が入っていない場合、Claude Codeの実行環境によっては transcript_path がチルダ付きで渡されます。実際に遭遇しました。input.transcript_path を使う前に transcriptPath.replace(/^~/, os.homedir()) を噛ませておくべきです。

  • settings.json のhookコマンドをパスだけで書くと node が見つからない。 nvm管理のNodeを使っている場合、hookがシェル越しに起動された時点でPATHにnvmのbinディレクトリが入っていないことがあります。#!/usr/bin/env node のshebangも同様で、/usr/bin/env が探す node/usr/local/bin/node の古いシステムNode(v16等)を指すケースがあります。確実なのは settings.json のcommandに node の絶対パスを明示することです。

    {
      "hooks": {
        "Stop": [{
          "command": "/Users/<you>/.nvm/versions/node/v24.13.0/bin/node ~/.claude/scripts/hooks/cost-tracker.js"
        }]
      }
    }
    

    パスはセットアップ環境に合わせて読み替えてください。hookが「発火している気がしない」時はまずこれを疑います。

  • console.log でデバッグするとフック連鎖が壊れる。 Stopフックのstdoutはpayloadのパススルー専用です。cost-tracker.js 156行目: process.stdout.write(raw);——フック連鎖の後続スクリプトはこのstdoutから入力を受け取ります。console.log('debug:', something) を1行入れると、stdoutに任意の文字列が混入し、後続のフックがJSONをパースしようとして壊れます。デバッグ出力は必ず process.stderr.write(...) か、専用のログファイルに書いてください。

  • cache_creation_input_tokens を計上しないとコストが低めに出る。 Claude Codeはプロンプトキャッシュを多用するため、cache_creation_input_tokens の課金が見逃せない割合を占めます。sonnetのキャッシュ書き込みレートは $3.75/1Mトークン(通常inputの1.25倍)、読み込みは $0.30/1Mトークン(0.1倍)です(cost-tracker.js 34〜38行目のRATEテーブル)。1セッションのinputが50,000トークン、そのうちキャッシュ書き込みが30,000トークンなら、書き込み分だけで約$0.11——通常inputの1.5倍のコストが乗ります。cache_creation_input_tokens を集計から外すと、実コストの20〜40%が不可視になります。

  • Stopフック内で process.exit() を呼ぶと stdin が読み切れずにデータが欠ける。 Node.jsのstdinはストリームです。process.stdin.on('data', ...) のコールバック内でエラー判定して process.exit(1) を呼んだ場合、残りのchunkが届く前にプロセスが終了し、payloadが途中で切れます。フックは常に process.stdin.on('end', () => { ... }) のコールバック内で処理を完結させ、途中でexitしない設計にします。

  • transcript_path が届いてもファイルが存在しない場合がある。 Stopフックはセッション完了と同時に発火しますが、Claude Codeのトランスクリプト書き込みとフック起動の間にごく短い競合窓があります。cost-tracker.js 115行目: if (transcriptPath && fs.existsSync(transcriptPath)) の存在確認はこのためです。ここを省略して直接 readFileSync を呼ぶと、稀に「パスが存在するが読めない」ではなく「ファイルが無い」で例外が飛びます。エラーは外側のtry-catchが受け止めてくれますが、そのセッションのコストが丸ごと欠損します。

  • Anthropicコンソールの数字と一致させようとすると沼にはまる。 cost-tracker.js が計算するコストはあくまで推定値です。実際の課金はAnthropicが計測したトークン数に基づき、バッチ割引・プロモーション・税など様々な要素が加わります。「コンソール比でなぜ$2ずれているのか」を追い始めると無限に時間を取られます。わたしはこのトラッカーに「月次の傾向と高コストセッションの特定」しか求めていません。Anthropicコンソール比±15%以内を許容範囲と決めて、それより細かい精度追求は止めました。

  • 動作検証をサマリスクリプトの出力だけで行う。 これはp2でも触れましたが普遍的なアンチパターンとして改めて書きます。cost-summary.sh は読み込み先、フィールド名、集計ロジックが正しくて初めて正しい数字を返します。どこか1点でも合っていなければ $0.00 / 0 sess が返ります。hookが正しく動いているかどうかは、サマリ出力ではなく ~/.claude/metrics/costs.jsonl を直接 tail -5 で確認することでしか判断できません。「動いていない」と思った時の最初のコマンドはサマリスクリプトの再実行ではなく、生ファイルの確認です。


ベストプラクティス

実装を通じて固まった、Stopフックでコストを追跡する上での設計原則を10以上まとめます。

① フック全体を try-catch で囲み、絶対にノンブロッキングにする。 コストログが取れなかったとしても、Claude Codeのセッション終了を妨げてはなりません。cost-tracker.js 151〜153行目がこの設計の核です。

} catch {
  // Non-blocking — never fail the Stop hook.
}

フックのクラッシュよりコストログの欠損のほうが断然許容できます。

② stdinパススルーは try ブロックの外に置く。 process.stdout.write(raw)try の中に入れると、パース失敗時にフック連鎖の後続スクリプトへ入力が届かなくなります。156行目の配置(try-catchの後)は意図的な設計です。コストログが飛んでもフック連鎖は生きる——これが優先順位です。

toNumber() ヘルパーで源流のNaNを潰す。 Number(undefined) === NaNJSON.stringify({v: NaN}){"v":null} になります。この伝播を47〜49行目の toNumber() で断ち切ることで、記録行に null が混入する経路を源流で封じています。

function toNumber(v) {
  const n = Number(v);
  return Number.isFinite(n) ? n : 0;
}

④ 書き出しパスと読み取りパスを同一定数から取る。 p2で書いた「logs/cost-log.jsonl vs metrics/costs.jsonl」のズレは、2つのスクリプトが別々にパスを書いていたことで生まれました。理想的には COSTS_PATH のような定数を共有モジュールに置き、hookスクリプトとsummaryスクリプトの両方からimportする設計にします。JavaScript同士なら require('../lib/paths') で共有できます。

⑤ フィールド名もスキーマ定数で管理する。 ts vs timestampcost_usd vs estimated_cost_usd という名前のズレも、スキーマを1か所で定義していれば起きません。JSONLの書き込みと読み取りで同じキーを参照していることを構造的に保証します。

⑥ 末端ファイルを直接 tail で検証する。 サマリスクリプトの出力は真実ではなく「サマリスクリプトが見ている世界」です。hookが正常に動作しているかを確認する最も確実な方法は次の1コマンドです。

tail -5 ~/.claude/metrics/costs.jsonl | python3 -m json.tool

estimated_cost_usd に非ゼロの値が入っていれば、hookは動いています。

⑦ stdin上限(64KB)でハング防止。 cost-tracker.js 92行目: const MAX_STDIN = 64 * 1024;。現在のStopフックpayloadは数百バイトですが、将来の仕様変更や予期しない大入力でプロセスがハングするのを防ぐ防衛設計です。

transcript_path の存在確認をしてからreadする。 fs.existsSync(transcriptPath) のチェック(115行目)を忘れると、競合窓でファイルが無い状態でreadが走り、そのセッションのコスト行が丸ごと欠けます。

cache_creation_input_tokenscache_read_input_tokens を必ず計上する。 この2フィールドを抜かすと、キャッシュヘビーなセッションで実コストの20〜40%が不可視になります。Claude Codeは長文プロンプトを自動でキャッシュするため、使い込むほどキャッシュ比率が高くなります。

⑩ 同一 session_id の最終行だけを per-session コストとして使う。 累積設計を知らずに全行合計すると実コストの数十倍の値が出ます。集計ロジックは必ず「session_idでグループ化し最後のtimestampの行を取る」形にします。

model: unknown 行を識別可能にしてコスト誤差の所在を明示する。 model フィールドが 'unknown' の行はsonnetレートで近似計上されます(getRates関数の fallthrough)。この行が多い場合、実コストとの乖離が広がります。集計時に unknown 行の件数と計算コストを別途表示することで、誤差の規模を可視化できます。

⑫ デバッグ出力はstderrかログファイルへ。stdoutは触らない。 hookのstdoutはパススルー専用です。console.log は禁止、console.error か専用ログファイルへの追記のみを使います。

⑬ hookコマンドにはnodeの絶対パスを明示する。 /path/to/.nvm/versions/node/vX.Y.Z/bin/node で直接指定することで、PATHの展開有無に関わらずhookが確実に起動します。nvm use が効かない実行環境では必須です。

⑭ 許容誤差範囲を明示して精度追求を止める。 コストトラッカーに求めるのは「高コストセッションを特定し、月次の傾向を掴む」精度であり、Anthropicコンソールとの1円一致ではありません。自分が許容できる誤差(例: ±15%)を決めておくと、無駄な精度改善への時間を使わずに済みます。


まとめ

52日間・2,340行・非ゼロ率0.0%——この自己告白がコードに残り続けている事実は、Stopフックの誤解がどれだけ静かに、そして長期にわたって実害を与え続けるかを示しています。

根本原因は単純でした。「Stopフックのpayloadにusageフィールドがある」という思い込みです。ペイロードに存在しないフィールドを読もうとした結果、NaN が発生し、JSON.stringify によって null に変換され、集計からサイレントにスキップされ続けました。エラーも警告も出ません。ただ静かにゼロが積まれます。

修正の本質は1点に尽きます。コスト情報はpayloadにはなく、transcript_path が指すJSONLの中にある。 Stopフックは「どのJSONLを読めばいいかを教えてくれる通知者」であって、「コスト情報を配達してくれる配達員」ではありません。この認識の転換が、v1からv2への書き換えのすべてです。

v2が正しく書けていても、cost-summary.sh の読み取り先とフィールド名が合っていなければ出力はゼロのままです。末端ファイルを直接 tail -5 で覗くまで、どちらが壊れているかは分かりません。パイプラインのデバッグは「サマリ出力を信じる」のではなく「最も末端の記録から逆算する」ことが原則です。

コストが数字として見えるようになると、経営の解像度が変わります。「Sonnetを月いくら使っているか」「Haiku寄せでどの作業が代替できるか」「深夜の自律ループが朝の手動セッションよりコスパが高いか」——これらを直感ではなく数字で判断できるようになります。月商120万の内訳を最適化し続けられているのも、粗利を可視化する仕組みがあるからです。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!