# Hooks

A hook is a small program the `ccc-statusd` daemon runs when something happens to one of your agent sessions: a Claude Code event fires, the daemon joins a new session to your sessions root, a token or idle sensor crosses a line, or a question to you has gone unanswered for a while. The hook answers with a reply — text injected into the agent's context, an agent to dispatch, or a verdict that blocks a tool call or a stop.

The programs live in markdown, not in a config file. There is no `$UCC_HOME/hooks.toml`. The file is `HOOKS.md`, and **what is on disk is what runs** — nothing is compiled, cached or armed separately.

```mermaid
flowchart LR
  E[event or sensor] --> M[HOOKS.md mount] --> H[hook block] --> R[reply]
```

## Two halves: transport and program

| Half | Where | Written by | What it decides |
|---|---|---|---|
| Transport | each profile's Claude Code `settings.json` | `ccc-statusd hook install` | which Claude Code events reach the daemon at all |
| Program | `HOOKS.md` on the sessions root, a session dir, or an agent home | you, with a `put` | what happens when one does |

`hook install` registers the 14 events the daemon has a consumer for. The other 17 are opt-in per profile with `hook install --with <Event>` — each one mounted costs a subprocess per occurrence, so they are off until a hook needs them. A `HOOKS.md` hook naming an event the transport does not carry resolves but never fires; `ccc-statusd hooks check` marks it `⚠ transport-not-installed`.

`ccc-statusd doctor --ping` starts every hook command registered in `settings.json` and names the ones that cannot exec. Claude Code swallows a hook command that fails to start, so without this check every file-reading probe reports a healthy plane over a dead one.

## The three mounts

One filename, three places, merged **by hook name** — the deepest wins:

1. `<sessions-root>/HOOKS.md` — the fleet page, every session on this root.
2. `<session-dir>/HOOKS.md` — one session.
3. `<sessions-root>/agents/<short-id>/HOOKS.md` — one agent.

A deeper mount overrides a hook either by declaring a block with the same name or by a frontmatter key `hook.<name>.<suffix>`, with the suffix one of six: `on`, `match`, `model`, `role`, `timeout`, `budget`. `hook.<name>.on: ""` turns a hook off for that scope. A key naming a hook no wider mount declares is reported as `⚠ override-orphan`.

The fleet page is guarded: a put on it needs `force: "<reason>"` in its frontmatter. Session and agent pages are yours to write freely.

## Triggers

A block's `declare(on = "...")` names its trigger:

| Trigger | Fires |
|---|---|
| a Claude Code event, verbatim PascalCase — `UserPromptSubmit`, `PreToolUse`, `Stop`, `SessionStart`, … (31 in all) | when Claude Code emits it; each event has its own input keys, allowed reply keys and timeout |
| `Join` | the daemon's own event: the first user prompt that creates the session directory and agent home (`SessionStart` is too early for card creation). Missing cards are retried at most once per 10 minutes, so a Join hook must be safe to repeat. A fork gets its own Join; a subagent shares its parent's session and gets none |
| `tokens/N` | every N tokens of context |
| `tokens>N` | once, on the upward crossing of N; a rewind or `/compact` re-arms it |
| `idle>D*B` | after D of idle time, then every D, up to B times (default `*3`), then dormant `⏸` |
| `auq>D` | an AskUserQuestion has been pending for D; reply keys only `agent_tool` and `spawn` |
| `""` | disabled |

Grammar: `250k` is 250 000, `1m` is 1 000 000, integer mantissa only; durations carry a unit — `30s`, `10m`, `1h30m`. There is no `stop` sensor: `Stop` is the event.

## The block shape

A hook is one fenced `starlark` block under a heading, closed by a `^name` anchor. The anchor is the hook's address — keep it stable. `declare(...)` at load time carries the trigger; `def run(event)` is the entry and returns the reply.

```starlark
declare(on = "tokens/200k")

def run(event):
    s = event["session"]
    return context('<context-status tokens="%dk" model="%s" />' % (s["tokens"] // 1000, s["model_display"]))
```

`event` is one dict: `name`, `id` (the idempotency anchor), `input` (the official hook input verbatim), `session` (`id`, `short`, `dir`, `agent_file`, `role`, `model`, `tokens`, `window`, `percent`, `cwd`, `host`, `herdr`, …), `sensor` (`threshold`, `tokens`, `fire_seq`, `budget`, `idle`, `pending`), `facts`, `time`. `bash(block = "name")` runs a named bash block from the same page. Which reply keys an event accepts: `ccc-statusd hooks show --contract`.

## What your root ships with

The fleet page arrives with the sessions-root template, never from the daemon binary. Its hooks:

| Hook | Trigger | What it does |
|---|---|---|
| `born-card` | `Join` | creates your agent card `agents/<short-id>/<short-id>.md` |
| `agent-brief` | `Join` | injects the `<session-brief>` block: session dir, agent home, work folder, worktree instruction, herdr pane |
| `auto-compound` | `UserPromptSubmit` | injects the chain-stage instructions (ideate → explore → plan → work → review → compound); its presence is the switch |
| `context-status` | `tokens/200k` | one `<context-status …/>` line every 200k tokens |
| `continuity-knee` | `tokens>360k,tokens>420k` | dispatches the `ccc-continuity` agent — 420k for the 1M-window model class, 360k as the unknown-model floor |
| `idle-default` | `idle>42m*3` | an idle nudge; an `-adhoc` session gets one reminder, a named session is told to continue or report and `killme` |
| `stop-check-probe`, `ups-context-probe` | `""` | off; diagnostic probes you can enable per session with `hook.<name>.on: "<Event>"` |

The live list, with the mount each hook comes from, its block revision, when it last fired and its state, is one command — run it on your machine rather than trusting this table:

```
ccc-statusd hooks show
```

A healthy fresh root prints one `ok` row per hook above, an `off` row naming the two probes, and `⚠ none`.

## Customizing

A change is a `put` on the right mount: a session-wide tweak on `<session-dir>/HOOKS.md`, a per-seat tweak on `agents/<short-id>/HOOKS.md` — a new block, or just an override key. Then `ccc-statusd hooks check` to validate, `ccc-statusd hooks fire <page> <hook>` to run one hook dry (the program runs, `bash()` is stubbed, effects are listed and applied nowhere), and `hooks show` to see it resolve.

## The verbs

`ccc-statusd hooks --help` — the five:

- `show [session-id]` — resolves the plane as a real fire would; `--verbose` adds mounts, overrides and envelope previews; `--contract` prints the reply-key table per event.
- `check [page.md] [--file <draft>] [--session S]` — validates the plane and prints what it checked. Exit 0 no finding, 1 findings, 2 the plane could not be read; the last line reads `verdict: no finding on the HOOKS.md plane (<n> warning(s)).` when clean, and when stdout is a pipe a final `ccc-statusd hooks check: VERDICT=<n> …` line is added, because a pipe masks the exit code.
- `fire <page> <hook> [--event-json F] [--live]` — dry by default.
- `init [--root PATH] [--write-mount]` — the precondition ladder: `sessions-mount` → `engine-binary` → `fleet-page` → `hooks-check` → `hooks-show`, each refusal printing the exact missing command. Exit 0 ready, 1 ran but not ready, 2 a precondition failed.
- `plane` — the operator kill switch: stop or resume all hook firing on this host.

## Where the detail lives

The installed `ccc-base` skill, at `$UCC_HOME/profiles/<profile>/skills/ccc-base/`:

- `hooks-reference.md` — the three mounts, override keys, the event envelope, sensors, markers, validation.
- `hooks-events.md` — one entry per Claude Code event: input keys, exit-2 rule, reply keys, timeout.

`ccc-statusd hook --help` documents the transport side, including the env flags (`CCC_TOOL_USE_ALLOW_ALL`, `CCC_USER_PROMPT_SUBMIT_FREQUENCY`, `CCC_STOP_IGNORE_SYMBOL`, `CCC_HOOK_LOG_FILE`).
