# Claude CLI runner status contract and regression pattern

Use this when modifying or debugging `claude_cli_runner.sh`, especially for cron/subagent automation.

## Durable lessons

- The runner stdout is a status envelope, not the Claude task result. It must be emitted on success and on execution failures so automation can make decisions without scraping stderr.
- Avoid command substitution for the Claude invocation (`RAW_RESULT="$(...)"`) because it loses/stashes useful stderr on nonzero exits and can prevent raw artifacts from being written.
- Capture Claude stdout and stderr to files first, then emit a bounded status JSON envelope.
- In jq, `field: ($value | select(. != ""))` inside an object can suppress the whole object when the value is empty. Use `null` or conditional object merging for optional fields.
- For `--format json`, do not trust process exit alone. Parse the raw artifact. If `.is_error == true` or `.subtype` starts with `error_`, report `status: "claude_error"` or another automation-visible failure status and exit nonzero unless an explicit caller mode says otherwise.
- Some Claude CLI failures may produce useful JSON on stdout and no stderr. Preserve and parse that JSON even when the process exits nonzero; `stderr_bytes == 0` is not itself a failure of diagnostics if `response_bytes > 0` and the status envelope points to both artifacts.
- The runner launches `claude` in the background and `wait`s on it under a `trap` so a SIGTERM/SIGINT delivered to the runner is forwarded to the actual `claude` process (no orphan) and the runner still falls through to emit a status envelope. A terminated worker surfaces as a non-zero `exit_code` (e.g. `143` = SIGTERM, `124` = `timeout(1)` deadline), `status: "error"`, wrapper exit `3` — never a silent hang.
- `--timeout <secs>` wraps `claude` in coreutils `timeout`/`gtimeout` when available, giving a hard self-termination backstop that survives the parent process dying. Where that binary is absent (stock macOS) it logs a note to stderr and is a no-op; the caller's own watchdog remains the guarantee.

## Minimum expected envelope fields

```json
{
  "status": "ok|error|claude_error",
  "subtype": "success|error_*|n/a",
  "output_file": "/path/to/raw.json-or-text",
  "structured_output_file": null,
  "stderr_file": "/path/to/raw.json.stderr",
  "response_bytes": 123,
  "stderr_bytes": 0,
  "exit_code": 0,
  "num_turns": "1",
  "total_cost_usd": "0.01",
  "session_id": "...",
  "is_error": false,
  "terminal_reason": "completed|max_turns|n/a",
  "stop_reason": "end_turn|n/a",
  "result_text": "bounded final result text, or null",
  "errors": []
}
```

## Stream mode (`--stream`)

- `--stream` runs claude with `--output-format stream-json --verbose --include-partial-messages` and writes the JSONL event log to the output file **as events arrive**, so the file grows live. This is the activity signal a watching caller polls to detect "no output for N seconds"; plain `--format json` is silent until the end and cannot support that.
- The status envelope is built from the **final `result` event** (extracted with `jq -c 'select(.type=="result")' | tail -n1`), which carries the same summary fields as a `--format json` blob, plus a bounded `result_text`. An empty extraction (no result event) is reported as `status: "error"`, wrapper exit `4` — never a silent success.
- `--stream` is mutually exclusive with `-s/--schema` (structured output uses `--format json`).
- mktemp templates must keep the `X` placeholders trailing (e.g. `result.XXXXXX`, not `result.XXXXXX.json`); BSD `mktemp` on macOS does not randomize `X`s that are followed by a suffix.

## Verification approach

1. Use a fake `claude` binary in a temporary `PATH` to test wrapper logic cheaply and deterministically.
2. Verify normal JSON success with no schema emits non-empty parseable status JSON and writes the raw artifact.
3. Verify nonzero CLI failure exits nonzero and still emits a diagnostic envelope with `output_file` and `stderr_file`.
4. Verify JSON error subtypes / `.is_error == true` do not report plain `status: "ok"`.
5. Then run at least one live smoke success and one live failure-ish invocation if available.
6. Keep the stable alias and skill copy identical, or intentionally replace one with a symlink.
