The runner went quiet
The autonomous runner had been silent for nineteen hours. The Stop hook had fired fifty-something times — I could see the entries piling up in the projects directory, the log file growing. The drafts CMS hadn’t seen a single new post.
That’s an interesting failure mode. The hook is firing, the runner is starting, but nothing is being produced.
I tailed the skip log:
triage_parse_error: missing_reasoning
triage_parse_error: missing_reasoning
triage_parse_error: missing_reasoning
...
Every session. Same reason. The triage step expects a JSON object back from claude -p with a reasoning field; if it’s missing, the runner bails so I’ll notice and tune the rubric. The whole point of that error is to be loud. It was loud — just only in the log, which I hadn’t read in nineteen hours.
The runner log was more revealing:
2026-04-27T... triage missing reasoning:
Followed by nothing. Empty string. Whatever claude -p was returning, the parser saw zero characters of it.
That ruled out “the model is producing bad JSON.” There was no JSON to be bad. The runner was getting an empty triageText and dutifully reporting “no reasoning.”
I ran the CLI directly:
$ echo "Reply with: hello" | claude -p --max-turns 1 --output-format json \
| jq '. | length, [.[] | .type]'
4
[
"system",
"assistant",
"rate_limit_event",
"result"
]
The output is an array now. Four event objects: a system init, the assistant message, a rate-limit event, and a final result. The runner code was assuming a single object and reading .result off the array — undefined, which the ?? '' coalesced to an empty string. The triage prompt got sent, the model answered, the answer was right there in parsed[3].result, and we threw it away every time.
// before
const parsed = JSON.parse(out);
return parsed.result ?? '';
// after
const parsed = JSON.parse(out);
if (Array.isArray(parsed)) {
const result = parsed.find(e => e?.type === 'result');
return typeof result?.result === 'string' ? result.result : '';
}
return parsed.result ?? '';
I left the single-object branch in place. If a future CLI release goes back to the old shape, I’d rather not learn about it from another nineteen-hour silence.
Two things worth writing down.
Empty-string parse failures don’t look like parse failures. The skip log said missing_reasoning, which sounds like the model’s fault. The truth was upstream: there was nothing for the model to be missing. I went looking for rubric tuning when I should have been looking at I/O. Next time a downstream parse step fires “the input was bad,” my first question is going to be “did we even get an input.” Print the length, not the content.
A CLI is an interface, and version bumps can change it silently. I’d updated claude at some point in the last few weeks. The exit code was still zero. The bytes were still JSON. The shape changed. My smoke test was “the runner doesn’t crash” — and the runner didn’t crash, it just ate the output. A one-line contract test that runs claude -p ... | jq -e '.[] | select(.type=="result") | .result' and exits non-zero on miss would have screamed on the upgrade. I don’t have one yet. That’s the next commit.
The fix is in. The next session-end hook should produce a real triage decision instead of feeding the rubric a void.