# 52 Days of Silent Zeros: The Stop Hook Payload Has No usage Field

> Source: <https://dev.to/bokuwalily/52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field-1kg8>
> Published: 2026-08-28 05:00:06+00:00

I used to be a college student scraping by on ¥100k a month. Then I was laid off. Six months later, after building an autonomous Claude Code environment, I'm clearing ¥1.2M a month in revenue. What closed that gap wasn't talent or capital — it was **continuously growing an environment that thinks and works in my place**.

The first thing I noticed when trying to grow income through solo development: raising the *quality of the environment* has far better ROI than raising the *amount of work*. Spending a week building a system that runs autonomously and stacks up deliverables while I'm away beats hammering Claude Code for 8 hours a day — at least when you measure revenue three months out. This series is a record of mass-producing that kind of environment.

Today's topic is **cost visibility**. When you live on Claude Code, token consumption happens as naturally as breathing. The problem is that you can't optimize a cost you can't see. "How much did I spend this month?" "Which session was heavy?" "How many dollars a month would I save by shifting a bit more Sonnet work to Haiku?" — without answers to these, gross margin doesn't improve even as revenue grows.

Claude Code has a hook system. Shell scripts or Node.js scripts configured in `~/.claude/settings.json`

run automatically when tied to specific events. The `Stop`

hook is the most important of them, and it **fires every time the assistant completes a turn**. Not just at session end — once per completed turn (see the comment on line 19 of `cost-tracker.js`

: *"Stop fires per assistant response, not per session"*).

The natural idea here is: "If I record token counts in the Stop hook, I get automatic cost tracking." The implementation looks simple. The hook receives a JSON payload on stdin. Read `usage.input_tokens`

and `usage.output_tokens`

from that payload, append to a JSONL file, done — and building it with that assumption is exactly the mistake the first version made.

**The Stop hook payload has no usage field.**

Here's what the payload actually looks like:

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

`session_id`

, `transcript_path`

, `cwd`

, `hook_event_name`

— that's it. No model name, no token counts, no cost. Because this isn't spelled out in the docs, if you write code assuming `usage`

exists, you read a nonexistent field and get `undefined`

, `Number(undefined)`

becomes `NaN`

, you keep adding `NaN`

, and `0`

gets recorded. No errors. Just silent zeros, piling up every turn.

The comment in my `cost-tracker.js`

preserves the evidence verbatim (lines 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 days, 2,340 rows, 0.0% non-zero rate. As a tracker, a total failure. And the whole time, the script kept running without complaint, the log file grew steadily, and running `cost-summary.sh`

returned "$0.00 / 0 sess". **It looked like it was working while recording nothing.**

This isn't merely an implementation bug — it's an architectural mistake stemming from a misunderstanding of what Claude Code's Stop hook is. To fix it, you have to give up on a field that doesn't exist in the payload and go read the place the payload *points to* — `transcript_path`

.

That switch is the core of this article.

Here's the corrected architecture at a glance:

```
┌─────────────────────────────────────────────────────┐
│  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 に追記     │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘
```

The key is the shift in understanding: "the Stop hook is not a courier delivering cost information." The hook is strictly an **event notifier**, and all the notification contains is an address (`transcript_path`

) telling you which session transcript to read. The cost information lives inside the transcript.

Claude Code writes every turn of a session into a single JSONL file. Each line corresponds to one message, and the `type`

field distinguishes the kind.

What you need for cost calculation are the `type: "assistant"`

lines. Their structure is as follows:

```
{
  "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
    }
  }
}
```

Summing the four kinds — `input_tokens`

, `output_tokens`

, `cache_creation_input_tokens`

, `cache_read_input_tokens`

— across all assistant turns gives you the session's total token consumption. Multiply by the billing rates and you have cost.

Lines 57–90 of `cost-tracker.js`

implement this JSONL accumulation logic.

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

Three design decisions are worth noting.

**Swallow parse errors and continue.** Even if a line partway through the JSONL is corrupted, `try { entry = JSON.parse(line); } catch { continue; }`

skips it. The Stop hook must be non-blocking. Having Claude Code's session termination fail because the cost log couldn't be captured would be completely backwards.

**Prevent NaN with toNumber().** The reason the old implementation kept writing

`0`

was NaN propagating from trying to convert a nonexistent field into a number. In the new implementation, a `toNumber()`

helper checks with `Number.isFinite()`

and returns `0`

if the value isn't a finite number (lines 47–49).**Use the last model name found.** Assuming the model can switch mid-session, it keeps overwriting as long as `msg.model`

isn't `'unknown'`

. The model from the later part of the session becomes the representative value, but the cost error is generally within acceptable range.

Per-model billing rates are hardcoded on lines 34–38.

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

Units are US dollars per 1M tokens. The `getRates()`

function (lines 40–45) determines `haiku`

, `opus`

, or other (default `sonnet`

) from the model name string.

The cost calculation is contained in lines 128–133.

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

The `/ 1e6 * 1e6`

round trip is there to round away floating-point error. Rounding at the micro-dollar level keeps errors below `0.000001`

out of the result.

The line ultimately appended to `~/.claude/metrics/costs.jsonl`

looks like this:

```
{
  "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
}
```

Everything up to here is recorded automatically on each completed turn. Every time the Stop hook fires, the session's "cumulative cost up to that point" is appended. If you want the final cost for one session, you take the last line with the same `session_id`

.

In the next part, I'll verify the specific failure patterns of the old implementation that spat out zeros for 52 days, and how the output of `cost-summary.sh`

changed before and after the fix.

Input to the Stop hook arrives on stdin. Since Node.js stdin is a stream, data may arrive split across multiple chunks. `cost-tracker.js`

handles this on lines 92–98 as follows:

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

The 64KB (65,536 byte) cap is a defensive design: the Stop hook payload is a 4-field JSON of a few hundred bytes in practice, but the cap prevents the process from hanging on a future Claude Code spec change or an unexpectedly large payload. The double guard of `raw.length < MAX_STDIN`

and `chunk.substring(0, MAX_STDIN - raw.length)`

ensures the read buffer stops at the cap no matter what input arrives.

If the cap is exceeded and the JSON is cut off partway, `JSON.parse(raw)`

on line 100 throws. But the outer `try { ... } catch { }`

catches it and the hook terminates without incident. What matters is the structure on lines 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)`

is *outside* the `try`

. Even if a parse failure blows away the cost record, the stdin contents always flow to stdout. This is a requirement of the ECC hook convention, and an expression of the design philosophy that **delivering input to downstream links in the hook chain takes top priority**. Losing one line of cost log versus crashing the entire Stop hook — the magnitudes of impact are literally incomparable.

Lines 104–106 hold the logic for obtaining `transcript_path`

.

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

The first candidate is the payload's `input.transcript_path`

. It's adopted only after confirming it's a string type and non-empty. The `typeof`

check is there to prevent an exception from touching `undefined`

when the payload falls back to an empty object `{}`

(e.g. stdin was empty).

The second candidate is the environment variable `CLAUDE_TRANSCRIPT_PATH`

. This is for testing and debugging. It's unnecessary via the actual Stop hook, but when you want to run the script standalone by hand and check the log, you can set the environment variable manually and run it.

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

The old implementation, which tried to read directly from the payload, had none of this design — it didn't even have a variable called transcript_path.

Session ID retrieval is a three-stage process on lines 108–112.

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

`sanitizeSessionId`

is a utility that validates and sanitizes UUID format. Getting `session_id`

from the payload is ideal, but in an ECC session-management environment there may be `ECC_SESSION_ID`

, and in plain Claude Code there may be `CLAUDE_SESSION_ID`

as environment variables. If none can be obtained, it falls through to `'default'`

. Rows with `'default'`

effectively serve as a signal you can check later that "something is completely broken."

Repeating the cost calculation from lines 128–133:

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

The round trip of `* 1e6`

, then `Math.round`

, then `/ 1e6`

exists to erase floating-point error below the micro-dollar level. It prevents JavaScript's famous `0.1 + 0.2 === 0.30000000000000004`

trap from leaving damage in the decimals of cost aggregation. You could argue there's no real harm if $0.051234 gets recorded in the JSONL as $0.051234000000000003, but the numbers look ugly when you later aggregate with `cost-summary.sh`

. It's a single line of processing, but it shows that the quality of the record is being taken seriously.

The fix wasn't finished by simply rewriting the old zero-writing implementation to read the transcript. Even after rewriting to v2, two separate problems stacked up and turned verification into a maze.

The morning after implementing and deploying `sumUsageFromTranscript`

, I ran `cost-summary.sh`

.

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

Thinking "zero again," I first looked directly at `~/.claude/metrics/costs.jsonl`

.

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

The file existed, with 3 rows recorded for that day. The `estimated_cost_usd`

values were `0.048291`

, `0.031457`

, `0.072139`

. v2 was working correctly.

The cause was what `cost-summary.sh`

was reading from. Look at line 10 of the script:

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

That's `~/.claude/logs/cost-log.jsonl`

. Meanwhile, what `cost-tracker.js`

writes to is `~/.claude/metrics/costs.jsonl`

. **Different directories. logs vs metrics.**

The field names didn't match either. The aggregation logic in `cost-summary.sh`

(lines 39–47) looks like this:

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

It reads `cost_usd`

and `ts`

. But the fields `cost-tracker.js`

writes are `estimated_cost_usd`

and `timestamp`

.

In other words, at the point I fixed things to v2, **two mismatches existed simultaneously: a file path mismatch and a field name mismatch**. If it were only one of them, `cost-summary.sh`

would read an empty file and return "0 sess". With both at once the symptom is unchanged, so I nearly misdiagnosed it as "v2 still isn't working either."

What was actually working was the v2 hook. What wasn't working was where the summary script was reading from.

The debugging lesson was "look at the terminal record file directly with `tail -f`

." Rather than trusting the summary script's output and concluding "it's not working," check the raw JSONL with your own eyes. Without the habit of isolating which stage of the pipeline is broken one step at a time, this double mismatch would have stayed unsolved.

Let me trace a bit more deeply how the v1 code kept writing 2,340 zero rows.

The old implementation looked (in pseudocode) like this:

``` js
// 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)`

returns `NaN`

. Every arithmetic operation using `NaN`

returns `NaN`

. That much is predictable. The problem is the next line.

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

** JSON.stringify converts NaN to null.** That's the JavaScript spec. Not an error — it quietly becomes

`null`

. The lines written to the JSONL aren't malformed JSON; they're perfectly well-formed lines. It's just that all the values are `null`

.On the `cost-summary.sh`

side, the Python code (lines 40–49) is written as `r.get("cost_usd", 0)`

, so when the key exists but the value is `null`

, it returns `None`

. In Python, `total += None`

throws a `TypeError`

, but `except Exception: continue`

on line 49 skips all exceptions, so that session's row is ignored and `total`

doesn't move.

No errors. No exceptions. Just silently skipped from aggregation, over and over. The structure was: **it wasn't that zeros were being recorded — the recorded rows were being continuously excluded from aggregation.** Writing `null`

is the JSON spec; skipping `null`

is Python's try-except. Each is correct behavior on its own, but combined they created a 52-day blind spot.

The new implementation's `toNumber()`

helper (lines 47–49) cuts this propagation off at the source.

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

`Number.isFinite(NaN)`

returns `false`

. `NaN`

, `Infinity`

, `null`

, and `undefined`

all get converted to `0`

here. The path by which `null`

could contaminate a record row is sealed at the very top of the conversion chain.

`model: unknown`

" rows are handled
`sumUsageFromTranscript`

scans the assistant turns of a session JSONL and adopts the last `msg.model`

found as the representative model (line 86: `if (msg.model && msg.model !== 'unknown') model = msg.model`

).

But the Stop hook fires "on each completed assistant turn" (comment line 19: *"Stop fires per assistant response, not per session"*). When the hook runs right after the session's first turn finishes, the transcript has only one assistant line, and there's no problem as long as the model name was recorded correctly on that turn.

However, in rare cases the assistant message early in a session has no `model`

field (streaming interruption, tool-use-only turns, etc.). In that case `model`

stays `'unknown'`

and gets passed to `getRates('unknown')`

.

``` js
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'`

matches none of the conditions, so the `sonnet`

rate is applied. Early-session turns from a session that was using a Haiku model get calculated at the `sonnet`

rate, and the cost is recorded inflated by 3.75× (`in: 0.80`

vs `in: 3.00`

).

I noticed this discrepancy when I was looking directly at `costs.jsonl`

and saw multiple rows with `model: unknown`

.

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

Calculating 4,821 input tokens at the sonnet rate gives about $0.0145. At the haiku rate it would be about $0.0039. Nearly a 4× difference.

The root fix would be either "defer cost calculation for rows where the model couldn't be obtained" or "retroactively correct once the model becomes known in a later turn," but implementation complexity jumps sharply. In the current implementation, the policy is to **approximate unknown rows at the sonnet rate and judge the monthly error to be within acceptable range**. What I want from a cost tracker is precision at a granularity usable for business decisions, not matching the Anthropic console down to the yen.

Whether you make that trade-off consciously is, I think, the difference in attitude toward a tool's reliability. Rather than settling for "that's just how it is," it's only once you've traced "the conditions under which this row becomes unknown," "the upper bound of the cost error at that point," and "the impact that has on monthly aggregation" that the numbers become something you can use with confidence.

In the next part, I'll cover how `cost-summary.sh`

started returning non-zero values after the fix, and the surprising consumption patterns that emerged from the daily cost graph.

The previous two parts dug into four points: "the Stop hook payload has no usage," "the NaN→null JSON.stringify trap," "path/field name mismatches," and "model:unknown cost overstatement." Here I'll cover the actual sticking points that fell outside those. Every one of them was a wall I thought was impossible.

**Because the Stop hook fires per turn, dozens of rows accumulate under the same session_id.**

If there are 30 assistant turns in one session, 30 rows get written to `costs.jsonl`

. It's stated clearly in comment line 19 of `cost-tracker.js`

(*"Stop fires per assistant response, not per session"*), but if the aggregation script doesn't know this, it sums all rows and produces a value dozens of times the real cost. I also made the error in the opposite direction: I thought "I spent $90 this month" when it was actually $4. To get per-session cost, you must aggregate only the last row for each `session_id`

(= the maximum cumulative value). The current aggregation logic in `cost-summary.sh`

(lines 37–49) doesn't account for this cumulative structure and adds every row with `for line in open(log):`

. Fixing the hook to v2 isn't enough — the summary script needs fixing too.

**Passing a transcript_path with a literal ~ to fs.readFileSync crashes immediately.**

`fs.readFileSync('~/.claude/...')`

doesn't do shell expansion. `~`

is treated as a plain string and it throws because the file doesn't exist. After receiving the path on lines 104–106 of `cost-tracker.js`

, if there's no processing to expand it with `os.homedir()`

or `path.resolve()`

, then depending on the Claude Code execution environment, `transcript_path`

arrives with a tilde. I actually hit this. You should run `transcriptPath.replace(/^~/, os.homedir())`

before using `input.transcript_path`

.**Writing the hook command in settings.json as just a path means node isn't found.**

`#!/usr/bin/env node`

shebang — there are cases where the `node`

that `/usr/bin/env`

finds points to an old system Node at `/usr/local/bin/node`

(v16, etc.). The reliable approach is to specify node's absolute path explicitly in settings.json's command.

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

Adjust the path to your own setup. When a hook "doesn't feel like it's firing," this is the first thing I suspect.

**Debugging with console.log breaks the hook chain.**

`cost-tracker.js`

line 156: `process.stdout.write(raw);`

— downstream scripts in the hook chain receive their input from this stdout. Insert a single `console.log('debug:', something)`

and an arbitrary string contaminates stdout, breaking the downstream hook when it tries to parse the JSON. Always write debug output to `process.stderr.write(...)`

or a dedicated log file.**Not counting cache_creation_input_tokens makes costs come out low.**

`cache_creation_input_tokens`

accounts for a non-negligible share. Sonnet's cache write rate is $3.75/1M tokens (1.25× normal input) and the read rate is $0.30/1M tokens (0.1×) (see the RATE table on lines 34–38 of cost-tracker.js). If a session's input is 50,000 tokens with 30,000 of those being cache writes, the write portion alone is about $0.11 — 1.5× the cost of normal input. Leaving `cache_creation_input_tokens`

out of aggregation makes 20–40% of your real cost invisible.**Calling process.exit() inside the Stop hook cuts stdin short and loses data.**

`process.stdin.on('data', ...)`

callback and call `process.exit(1)`

, the process ends before the remaining chunks arrive, and the payload gets cut off. Design hooks so all processing completes inside the `process.stdin.on('end', () => { ... })`

callback, with no early exit.**Even when transcript_path arrives, the file may not exist.**

`cost-tracker.js`

— `if (transcriptPath && fs.existsSync(transcriptPath))`

— is for. Omit it and call `readFileSync`

directly, and occasionally you'll throw on "file doesn't exist" rather than "the path exists but can't be read." The outer try-catch will catch the error, but that session's cost is lost entirely.**Trying to match the Anthropic console's numbers is a swamp.**

The cost `cost-tracker.js`

calculates is strictly an estimate. Actual billing is based on the token counts Anthropic measures, plus various factors like batch discounts, promotions, and taxes. Start chasing "why is it $2 off from the console" and it will consume infinite time. All I want from this tracker is "monthly trends and identification of high-cost sessions." I decided within ±15% of the Anthropic console is my acceptable range and stopped pursuing precision beyond that.

**Verifying behavior using only the summary script's output.**

I touched on this in p2, but I'll restate it as a universal anti-pattern. `cost-summary.sh`

returns correct numbers only when the read path, field names, and aggregation logic are all correct. If even one of them doesn't match, you get `$0.00 / 0 sess`

. Whether the hook is working correctly can only be determined by checking `~/.claude/metrics/costs.jsonl`

directly with `tail -5`

, not by the summary output. When you think "it's not working," your first command should be checking the raw file, not re-running the summary script.

Here are 10+ design principles for tracking cost with the Stop hook, solidified through implementation.

**① Wrap the entire hook in try-catch and make it absolutely non-blocking.**

`cost-tracker.js`

are the core of this design.

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

Losing the cost log is far more acceptable than crashing the hook.

**② Put the stdin pass-through outside the try block.**

`process.stdout.write(raw)`

inside the `try`

, input stops reaching downstream scripts in the hook chain when parsing fails. The placement on line 156 (after the try-catch) is deliberate design. The cost log can be lost, but the hook chain stays alive — that's the priority order.**③ Kill NaN at the source with a toNumber() helper.**

`Number(undefined) === NaN`

, and `JSON.stringify({v: NaN})`

becomes `{"v":null}`

. Cutting off this propagation with `toNumber()`

on lines 47–49 seals the path by which `null`

contaminates a record row, at the source.

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

**④ Take the write path and the read path from the same constant.**

The `logs/cost-log.jsonl`

vs `metrics/costs.jsonl`

mismatch I wrote about in p2 was born from two scripts writing the path separately. Ideally you place a constant like `COSTS_PATH`

in a shared module and import it from both the hook script and the summary script. Between two JavaScript files you can share it with `require('../lib/paths')`

.

**⑤ Manage field names with schema constants too.**

Name mismatches like `ts`

vs `timestamp`

and `cost_usd`

vs `estimated_cost_usd`

also don't happen if the schema is defined in one place. It structurally guarantees that JSONL writing and reading reference the same keys.

**⑥ Verify the terminal file directly with tail.**

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

If `estimated_cost_usd`

has a non-zero value, the hook is working.

**⑦ Prevent hangs with a stdin cap (64KB).**

`cost-tracker.js`

line 92: `const MAX_STDIN = 64 * 1024;`

. The current Stop hook payload is a few hundred bytes, but this is a defensive design preventing the process from hanging on a future spec change or unexpectedly large input.

**⑧ Check that transcript_path exists before reading.**

`fs.existsSync(transcriptPath)`

check (line 115) and a read will run during the race window when the file doesn't exist, losing that session's cost row entirely.**⑨ Always count cache_creation_input_tokens and cache_read_input_tokens.**

**⑩ Use only the last row of each session_id as the per-session cost.**

**⑪ Make model: unknown rows identifiable to make the location of cost error explicit.**

`model`

field is `'unknown'`

are approximated at the sonnet rate (the getRates function's fallthrough). When there are many such rows, the divergence from real cost widens. Displaying the count and computed cost of `unknown`

rows separately during aggregation makes the scale of the error visible.**⑫ Send debug output to stderr or a log file. Don't touch stdout.**

A hook's stdout is exclusively for pass-through. `console.log`

is forbidden; use only `console.error`

or appending to a dedicated log file.

**⑬ Specify node's absolute path in the hook command.**

Specifying `/path/to/.nvm/versions/node/vX.Y.Z/bin/node`

directly ensures the hook launches regardless of whether PATH is expanded. It's mandatory in execution environments where `nvm use`

doesn't take effect.

**⑭ State your acceptable error range and stop chasing precision.**

What you want from a cost tracker is precision sufficient to "identify high-cost sessions and grasp monthly trends," not matching the Anthropic console to the yen. Deciding on an error you can accept (e.g. ±15%) saves you from spending time on pointless precision improvements.

52 days, 2,340 rows, 0.0% non-zero rate — the fact that this confession still lives in the code shows how quietly, and for how long, a misunderstanding of the Stop hook can keep doing real damage.

The root cause was simple: **the assumption that "the Stop hook payload has a usage field."** Trying to read a field that doesn't exist in the payload produced `NaN`

, which `JSON.stringify`

converted to `null`

, which was then silently skipped during aggregation. No errors, no warnings. Just zeros quietly stacking up.

The essence of the fix comes down to one point. **The cost information isn't in the payload — it's in the JSONL that transcript_path points to.** The Stop hook is "a notifier that tells you which JSONL to read," not "a courier that delivers cost information." That shift in understanding is the entirety of the v1-to-v2 rewrite.

Even if v2 is written correctly, the output stays zero unless `cost-summary.sh`

's read target and field names match. Until you look at the terminal file directly with `tail -5`

, you can't tell which of the two is broken. The principle of pipeline debugging is not "trust the summary output" but "work backwards from the most terminal record."

Once cost becomes visible as numbers, the resolution of your business decisions changes. "How much am I spending on Sonnet per month?" "Which work can be replaced by shifting to Haiku?" "Is the overnight autonomous loop more cost-effective than a morning manual session?" — you can judge these with numbers instead of intuition. Being able to keep optimizing the breakdown of ¥1.2M in monthly revenue is possible because there's a system making gross margin visible.

I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.

📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
