Skip to content

How hooks work

This page explains the hook lifecycle end to end: what events exist, what gets triggered when, how built-in hooks behave, where they write, and how you extend the system with your own.

If you want the why behind the design choices, see ADR-006 — Hooks as subprocess with JSON stdin/stdout and ADR-008 — Compound-loop async worker. This page is the how.

The model

A hook is an executable that:

  1. Is invoked by the agent (not by lazy-harness) when a specific event fires.
  2. Reads a JSON payload describing the event from stdin.
  3. Optionally writes a JSON object with hookSpecificOutput on stdout.
  4. Exits with code 0. Always, with two deliberate exceptions: the PreToolUse hooks that block — pre-tool-use-security and pre-tool-use-git-scope — exit 2 when they decide to block, which is how Claude Code expects a PreToolUse decision to be communicated. Every other built-in is exit-0-always, including on error. Deploy writes those two to Claude Code with "onFailure": "block", so a guard that cannot even start — lh missing from PATH, a crash before the runner, Claude Code's own hook timeout — also blocks instead of passing. Claude Code before 2.1.295 ignores the key and keeps loading the file, so there the old fail-open behaviour remains. Codex has no such key, fails open on the same failures, and refuses only on exit 2 with a reason on stderr, so deploy wraps the two guards there as sh -c '<lh hook …> || { echo "lazy-harness: blocking hook failed to run" >&2; exit 2; }'. That covers a missing lh and a crash; a Codex hook timeout still lets the call through. Codex asks to review both hooks again after the upgrade, because the command is part of its trust hash.

Built-in hooks ship inside the framework as Python scripts under src/lazy_harness/hooks/builtins/. User hooks live under ~/.config/lazy-harness/hooks/<name>.py (or whatever language you prefer as long as the binary is executable and reads stdin).

Declaration happens in config.toml:

[hooks.session_start]
scripts = ["context-inject"]

[hooks.session_stop]
scripts = ["session-export", "compound-loop"]

[hooks.session_end]
scripts = ["session-end"]

[hooks.pre_compact]
scripts = ["pre-compact"]

lh deploy reads this, resolves each name (builtin first, user dir second), and generates the agent's native hook config — settings.json for Claude Code. From then on, the agent itself spawns the hooks.

Event glossary

config.toml event Claude Code event When it fires Built-ins shipped Typical use
session_start SessionStart Right after Claude Code starts a session context-inject, session-start-preflight Inject additional context, report an expired login or a shadowed tool before work starts
session_stop Stop After every LLM turn (not once at shutdown) session-export, compound-loop, engram-persist, stop-context-rotate Export session, queue gated async work, mirror new memory entries, flag a context window worth rotating
session_end SessionEnd Exactly once at real session termination (/exit, /clear, logout) session-end Force final end-of-session work
pre_compact PreCompact Immediately before Claude Code compacts conversation history pre-compact Preserve working state
post_compact PostCompact Immediately after Claude Code compacts conversation history — Available for your own hooks. No built-in ships here: the event's executor returns only a user-facing message, so a hook on it cannot reach the model.
pre_tool_use PreToolUse Before each tool call pre-tool-use-security, pre-tool-use-git-scope, pre-tool-use-memory-size, pre-tool-use-read-size Block destructive / exfiltration commands, block an unsafe git stash on a shared stash stack, warn before MEMORY.md or CLAUDE.md exceeds its own line/byte ceiling, warn before an unbounded read of a large file
post_tool_use PostToolUse After each tool call post-tool-use-format, post-tool-use-sync-system-doc Auto-format edited files, regenerate segmented CLAUDE.md after profile edits
notification Notification Ad-hoc agent notifications — Desktop notifications, integrations
user_prompt_submit UserPromptSubmit When the user submits a prompt user-prompt-goal (opt-in, not in the default set — see below) Goal-declaration sensor, third-party integrations
permission_request PermissionRequest When the agent asks for a permission decision — Third-party integrations, approval routing

The mapping lives in each adapter's hook_events() — other agents expose different event names, and an event an agent does not deliver is simply absent from its map. The config.toml side is stable regardless.

Default hooks

lh deploy ships with an opinionated default set (ADR-031): a fresh profile gets the framework's built-in hooks wired into settings.json automatically. You do not need to declare them in config.toml for them to fire.

Event Default built-ins
session_start context-inject, session-start-preflight
session_stop session-export, compound-loop, engram-persist, stop-context-rotate
session_end session-end
pre_compact pre-compact
post_compact —
pre_tool_use pre-tool-use-security, pre-tool-use-git-scope, pre-tool-use-memory-size, pre-tool-use-read-size
post_tool_use post-tool-use-format, post-tool-use-sync-system-doc

Overriding. Declaring [hooks.<event>] with scripts = [...] in config.toml replaces the default for that event. The smallest override unit is one event — there is no per-script disable. To opt out of a single event entirely, declare it with scripts = []. Events declared in config.toml that the framework does not know about (e.g. notification) pass through verbatim.

Backups when manual entries are removed. settings.json["hooks"] is wholly framework-owned. If lh deploy finds a hook command in the existing block that does not appear in the effective set (typically a hand-edit), it writes settings.json.bak next to settings.json and prints a one-line warning listing the removed command. No chain of backups is kept; version-control ~/.config/lazy-harness/ if you want a longer history.

The literal default set lives in src/lazy_harness/deploy/defaults.py.

The built-ins

context-inject — runs on SessionStart

Source: src/lazy_harness/hooks/builtins/context_inject.py.

Responsibility: pull together everything a new session should know about, wrap it in markdown sections, and print it as hookSpecificOutput.additionalContext so the agent sees it alongside the first user message.

Sections composed, in the order they appear in the body (which is not the order they are dropped in — see below):

  1. ## Git — current branch, last commit, working-tree status (modified / untracked count). Computed with a short-timeout git subprocess; absent if the cwd is not inside a git repo.
  2. ## Repo map — the doc at repo_map_doc, injected only when the session's cwd sits inside repo_map_scope. Off unless that scope is set. Use it for the map of which repo lives where and which remote is authoritative: knowledge an agent needs before it starts guessing, and that a conditional "read this file if…" instruction in CLAUDE.md reliably fails to trigger. Capped at repo_map_max_chars (default 1200), cut on a line boundary, with the remainder announced.
  3. ## Curated memory — the canonical per-project MEMORY.md from the same resolved memory directory as the episodic files. The section names its source (abbreviated when necessary), is absent when the file is missing or empty, and reports unreadable or invalid UTF-8 content without including the underlying exception.
  4. ## LazyNorth — your strategic compass file (universal + per-profile), if [lazynorth] is enabled in config. Truncated to ~20 lines for the universal doc and ~15 for the per-profile one.
  5. ## Last session — the most recent exported session matching this project's name. Displays date, message count, and the first non-empty user message of that session (truncated to 80 chars). Pulled from the knowledge store, so it is scoped by project and spans profiles if you run multiple.
  6. ## Handoff from last session — contents of memory/handoff.md (written by compound-loop) plus memory/pre-compact-summary.md (written by pre-compact) from the project's per-cwd memory dir.
  7. ## Code structure — from graphify-out/graph.json when the graph is fresh: the five most connected code symbols with their file:line, three example graphify commands built from them, and one line on when the graph beats grep (node and edge counts only when the graph holds no code symbols); or a ## Notice pointing at regeneration when the graph is older than the last commit that touched a file graphify treats as code — a docs-only commit does not make it stale. Absent when the repo has no graph.
  8. ## Proposals to review — contents of memory/claude-md.proposal.md when present. The compound-loop worker writes this file when it has surfaced patterns worth promoting into CLAUDE.md (curated semantic layer). Injecting them at session start lets you review and apply them by hand; context-inject never edits CLAUDE.md or MEMORY.md itself.
  9. ## Relevant vault notes — QMD hits for the current branch name, when qmd_suggest_enabled is set.
  10. ## Recent history — the last 3 entries from decisions.jsonl and the last 3 from failures.jsonl, with failures including their prevention field.

Curated memory is read only up to its allocation plus one character. Its rendered section, including source and any truncation notice, gets at most min(12000, max_body_chars / 2) characters. The body is capped at cfg.context_inject.max_body_chars (default 3000): it first drops sections in the order episodic → vault notes → proposals → lazynorth → code structure → repo map → handoff, then clips any remaining overflow with a notice. A compact pending-proposal warning survives when the full notice cannot fit. Curated guidance takes priority when the remaining body also exceeds the cap. A zero or negative max_body_chars suppresses the body; small positive budgets can fit only a truncation notice. A banner is also emitted as a top-level systemMessage — a sibling of hookSpecificOutput, not a key inside it — capped separately at min(200, max_body_chars) characters. The body and banner together are therefore bounded by max_body_chars + min(200, max_body_chars) for positive values.

Claude Code auto memory is on by default and loads the first 200 lines or 25KB of its native projects/<project>/memory/MEMORY.md. autoMemoryDirectory, autoMemoryEnabled, and CLAUDE_CODE_DISABLE_AUTO_MEMORY can change delivery. After migration, the knowledge-store file and native file may differ; the hook does not infer equivalence from the adapter name. A linked or redirected native file can cause duplicate guidance until its identity and actual delivery are verified.

Why the repo map is capped rather than left to the drop order. It is the only section whose source is a hand-written document of unbounded length, and it sits second-to-last in the drop order — so an uncapped map survives while every other section is dropped to make room for it, and then gets dropped itself, leaving a body with nothing in it. repo_map_max_chars bounds the section before the budget is ever consulted. Raise it if your map is genuinely worth the room; the cap is in characters because that is the unit max_body_chars spends.

Why code structure outranks vault notes. Both are discovery aids, but the graph summary is a compact map of the repo the session is about to edit, while vault notes are speculative matches on a branch name. Under a tight budget the map is worth more, and it is the only reason to generate the graph at all. If both keep getting dropped, raise max_body_chars — the default is deliberately conservative and costs well under a thousand tokens even when doubled.

Where it writes: nowhere on disk. It only prints to stdout. Its job is read-only composition.

session-start-preflight — runs on SessionStart

Source: src/lazy_harness/hooks/builtins/session_start_preflight.py.

Responsibility: report the things that strand a session partway through, before the work is staged rather than after. It reports and never blocks — SessionStart has no blocking semantics — so every path exits 0 and the worst outcome is a line saying a check could not tell.

Check Reports
auth Remaining life of the refresh token: fail once expired, warn under 12 hours, unknown for a shape it does not recognise, n/a for an agent whose credentials it cannot read at all
git The origin remote and the user.email commits would carry
path Tools resolving to more than one copy on PATH

Which profile's login it checks. The one the hook was invoked under: the credentials file is read from the agent runtime directory that --profile resolves to, following the same resolution order as the hook log. Before that it resolved CLAUDE_CONFIG_DIR on its own and fell back to ~/.claude, so on a machine with several profiles the preflight could report a healthy login for a profile the session was not running under — the one failure the check exists to catch.

Which file it reads, and whether that file is the store. The filename comes from the agent adapter's credentials_file(), not from the hook. An adapter that answers None — because its agent keeps credentials somewhere with no file to read, or in a format this check has not been taught to parse — gets n/a, naming the agent. That is deliberately not unknown: "this check could not tell" and "this check does not apply to your agent" are different findings, and only the first is worth acting on. The Codex adapter answers None today; its credentials file is known by path and unprobed by shape.

On macOS the live Claude Code credential is in the keychain and the file beside it is a mirror nothing re-synchronises. Measured on a profile logged in that same morning: the keychain entry was hours old, the file eight days old with an expired refresh token, and the check reported fail against a login that was working. So where the file is a mirror, a fail or a warn derived from it degrades to unknown and says why; a pass survives, because a mirror is written from the store and never ahead of it. The keychain itself is not read — see the paragraph below for why nothing here spawns a credential helper.

The auth check reads a file and spawns nothing. Running an auth CLI from inside a hook is exactly what an operator rule elsewhere forbids: a credential helper that cannot reach the keychain — as happens in launchd's Background domain — has deleted a credential store before now. A read cannot do that.

It reads refreshTokenExpiresAt, not expiresAt. The access token expires constantly and is refreshed transparently: measured on a live profile, expiresAt sat 61 hours in the past while the session worked perfectly, because the refresh token still had 84 hours left. Reading the wrong field makes the check cry wolf every session, and a preflight nobody reads is worse than no preflight.

An unrecognised credentials shape reports unknown, never fail, for the same reason: one false "your login is dead" teaches the reader to skip the whole block.

Output stays quiet when clean. All-clear collapses to a single line; only non-passing checks get a line of their own. Median cost measured at 24 ms.

pre-compact — runs on PreCompact

Source: src/lazy_harness/hooks/builtins/pre_compact.py.

Responsibility: rescue working state before Claude Code compacts the conversation.

Steps when the event fires:

  1. Take the transcript path from the normalised event (HookEvent.transcript_path), and use it only when a file is actually there — at compaction time it always is, but the same helper serves SessionStart, where the agent names a transcript it has not written yet.
  2. Copy the raw transcript to <agent runtime dir>/compact-backups/<timestamp>-<project>.jsonl. This is the forensic backup; nothing in the framework relies on it, but lh users can re-run compound loop against it if needed.
  3. Parse role and content from each JSONL record's message. Extract the last up to 5 non-trivial human messages (length ≥ 15 chars, truncated to 200 chars each) from either string content or text blocks; skip tool_result content. Collect file paths from assistant tool_use blocks' input.file_path or input.path (sorted, deduplicated, last 10 kept), then render them as ## Tasks in progress and ## Files worked on.

  4. Append the last 3 summary fields of decisions.jsonl and of failures.jsonl from the memory directory, as ## Recent decisions and ## Recent failures. These read the memory store rather than the transcript.

  5. If the summary came out non-empty, write it to <memory_dir>/pre-compact-summary.md with a generation timestamp in an HTML comment — the same resolved directory every other memory writer uses (see compound-loop below).
  6. Return it as plain text, preceded by a line asking the summariser to preserve it. The adapter puts HookDecision.additional_context on stdout unchanged for this event alone. Claude Code's PreCompact executor collects each successful hook's raw stdout and hands the joined text to the compaction summariser as newCustomInstructions. There is no hookSpecificOutput variant for this event — a JSON payload fails schema validation, which marks the hook failed and discards its output.

Where it writes: - <agent runtime dir>/compact-backups/<ts>-<project>.jsonl — raw transcript backup. - <memory_dir>/pre-compact-summary.md — distilled summary for the next session start.

session-export — runs on Stop

Source: src/lazy_harness/hooks/builtins/session_export.py, backed by the pure export_session in src/lazy_harness/knowledge/session_export.py.

Responsibility: convert the just-ended session's JSONL into a clean markdown file in the knowledge store, then re-index with QMD if available.

Flow:

  1. Take the transcript the agent declared on the event when it is already on disk; otherwise locate the latest *.jsonl under <agent runtime dir>/projects/<encoded-cwd>/, where the runtime dir is the one belonging to the profile the hook was invoked with.
  2. Skip it as evaluator when its first user message is the compound-loop evaluator prompt (is_evaluator_session). This gate runs before every other one and outranks force: the evaluator is a headless claude -p run, and exporting it would file its prompt as a session note.
  3. Parse it into metadata + messages. A session is interactive when a permission-mode or last-prompt record appears anywhere in it (for Codex, a user response_item); otherwise it is skipped as non-interactive.
  4. Filter sessions under min_messages (default 4) — scratch prompts never make it into the knowledge tree.
  5. Classify the session by cwd heuristics (see ADR-011) to compute profile and session_type.
  6. Write to <store>/sessions/YYYY-MM/YYYY-MM-DD-<short-id>.md with frontmatter (session_id, cwd, project, profile, session_type, branch, claude_version, messages) and a body of ## User / ## Claude sections.
  7. Atomic write via tempfile + os.replace (iCloud / Dropbox safe).
  8. If qmd is on PATH, run qmd update to re-index.

Where it writes: <store>/sessions/YYYY-MM/*.md, where <store> is the knowledge store root and sessions is the name its knowledge.toml marker declares. Idempotent — re-running on the same session only writes if the new message count exceeds the existing export's.

compound-loop — runs on Stop

Source: src/lazy_harness/hooks/builtins/compound_loop.py (the producer) + src/lazy_harness/knowledge/compound_loop_worker.py (the consumer) + src/lazy_harness/knowledge/compound_loop.py (the pure functions).

Responsibility: distill the session into structured decisions, failures, learnings, and handoff items — asynchronously, so session close stays instant.

This is the hook that does the heaviest lifting. It is split into two pieces deliberately (ADR-008):

Producer (in-hook, fast): 1. Check compound_loop.enabled in config, bail if disabled. 2. Find the latest session JSONL for the current cwd. 3. Apply debounce (debounce_seconds, default 60) — if a task for this session was queued within the window, skip. 4. Apply the growth gate (reprocess_min_growth_seconds, default 1800) — re-queue only if the JSONL grew past the threshold since the last done/ task. 5. Check queue/done/ for the same short session id — if already processed, skip. 6. Drop a task file (<unix-ts>-<short-id>.task) into the agent's queue/ directory with key=value metadata (cwd, session_jsonl, session_id, memory_dir, timestamp). 7. subprocess.Popen the worker as a detached process. Return immediately.

Consumer (worker, slow, async): 1. Acquire fcntl.flock on queue/.worker.lock. If another worker is running, exit 0.

Three distinct objects live under the agent runtime directory, none of them at a fixed path: the queue is its own subdirectory, the lock is a file inside that queue, and the worker's log sits in `logs/` alongside `hooks.log`. On a single-profile Claude Code install with no environment override they are `~/.claude/queue/`, `~/.claude/queue/.worker.lock` and `~/.claude/logs/compound-loop.log`. The worker resolves that directory from the profile the producer names when it spawns it, and from the global `[agent].type` when nobody names one — the producer's own fallback, so the pair always agrees. [Observability](#observability) lists which hooks resolve per profile today.
  1. Drain *.task files in FIFO order.
  2. For each task: parse metadata → skip evaluator sessions (is_evaluator_session) and non-interactive ones (is_interactive_session) → filter trivial sessions (min_user_chars, min_messages; a session blocked here, or by a backend failure, gets the deterministic slim handoff when slim_handoff_enabled) → collect existing decisions/failures/learnings for deduplication → build prompt with build_prompt (ported verbatim from the predecessor; the wording is calibration, not code) → call run_inference with the distill role, routed through [llm.roles], with a configurable timeout → parse the JSON response with parse_response (handles bare JSON, fenced JSON, and prose-preamble JSON) → persist with persist_results → record the session's goal_declared/goal_absent verdict (see below).
  3. Move the task to queue/done/ regardless of outcome — failures never block the queue.

Where it writes (via persist_results + persist_insights): - <memory_dir>/decisions.jsonl — appended structured decisions. - <memory_dir>/failures.jsonl — appended preventable errors. - <memory_dir>/grades.jsonl — appended self-graded distillation quality (ADR-021). - <memory_dir>/claude-md.proposal.md — appended proposed additions to MEMORY.md / CLAUDE.md for the human to review. Surfaced by context-inject on the next session start. - <memory_dir>/insights/YYYY-MM/*.md — verbatim ★ Insight ─ blocks captured deterministically (regex, not LLM) before the headless extractor runs. Survives LLM timeouts and bypasses the min_user_chars / min_messages gates. Detailed mechanics in the compound-loop how page. - <memory_dir>/insights/.cursor.json — per-session last-processed message index, so subsequent Stop hooks scan only the delta. - <learnings_dir>/YYYY-MM/YYYY-MM-DD-<slug>-<host>.md — one markdown file per learning, atomic write. - <memory_dir>/handoff.md — overwritten with the current pending items, or deleted if empty, unless a more recent session wrote it. - loop_events table in metrics.db — one goal_declared or goal_absent row per graded session, recording whether the grading JSON's goal_declared field was true. A missing or non-boolean field records nothing. Idempotent across reprocessing: MetricsDB.clear_goal_verdict deletes any prior verdict for the session before inserting the new one, so a session that grows and gets re-evaluated still contributes exactly one row — the most recent processing's verdict is the one that counts. Surfaced by lh metrics loops.

<memory_dir> is <store root>/memory/<host>/<owner>/<repo>/ — the knowledge store root, joined with the memory area its knowledge.toml marker declares, joined with the project's identity derived from its git remote. It falls back to <CLAUDE_CONFIG_DIR>/projects/<encoded-cwd>/memory/ when there is no usable store, or when the checkout has no remote to be keyed on. core/memory_store.py:memory_dir_for is the only place that builds it and every hook reaches it through hooks/builtins/_shared.py:memory_dir. <learnings_dir> is that same store root joined with the learnings subdirectory the marker declares. See the memory model.

session-end — runs on SessionEnd

Source: src/lazy_harness/hooks/builtins/session_end.py.

Responsibility: force one final compound-loop evaluation when the session actually closes (/exit, /clear, logout, prompt_input_exit). SessionEnd fires exactly once at real shutdown — unlike Stop, which fires after every LLM turn.

compound-loop on Stop is gated by debounce_seconds and reprocess_min_growth_seconds to bound worker cost. Those gates are correct during a session and wrong at its end: the last minutes of work can fall into a window where neither the gate nor the classifier in context-inject flags the handoff as stale, and the next session reads an out-of-date snapshot. The session-end hook is the fix.

What it does:

  1. Record a session_closed row in loop_events — unless the transcript's first user message is the compound-loop evaluator prompt, since the evaluator's own headless sessions close too.
  2. Check compound_loop.enabled; bail if disabled.
  3. Find the latest session JSONL for the current cwd.
  4. Call should_queue_task(..., force=True) — bypasses debounce and the growth gate; the helper still respects the force flag as the one intersection point between the two producers.
  5. Drop the task file and spawn the worker exactly like the Stop-hook producer.

The worker is the same, the prompt is the same, the persistence layer is the same. Only the gating differs. See ADR-019 for the trade-off analysis.

Opting in — users wire the hook into their settings.json the same way as the others, which is the form lh deploy writes: the bare launcher name, resolved against PATH, and the profile the hook runs under. Example:

{
  "hooks": {
    "SessionEnd": [
      {"matcher": "", "hooks": [{"type": "command", "command": "lh hook session-end --profile <profile>"}]}
    ]
  }
}

A harness-agnostic fallback is also available as a CLI command: lh knowledge handoff-now runs the same force path on demand. Useful before /compact, before closing a terminal without /exit, or on Claude Code builds that predate the SessionEnd event.

pre-tool-use-security — runs on PreToolUse

Source: src/lazy_harness/hooks/builtins/pre_tool_use_security.py.

Responsibility: stop high-blast-radius shell commands before the agent runs them. Like the git-scope guard, it returns a denial — Claude Code interprets exit code 2 from a PreToolUse hook as a block decision and surfaces the hook's stderr message back into the agent's turn so it can adapt.

Scope: two shapes of tool call are inspected, and every other tool name (Grep, MCP tools, …) is a fast exit 0.

  • Bash — the hook checks command positions and arguments, then an ordered list of regex rules grouped by category. A denial is never rescued by incidental allowed text.
  • Read, Edit, Write, NotebookEdit — the hook reads tool_input.file_path (or notebook_path) and matches it against SECRET_PATH_GLOBS, the secret-path list described below.

The subscription has to be as wide as that scope: the hook is registered with matcher = "Bash|Read|Edit|Write|NotebookEdit", overriding the PreToolUse default of Bash. A narrower matcher does not weaken the guard, it removes it — the agent never calls the hook for the tools left out, and nothing reports the gap. tests/unit/test_hook_matcher_coverage.py holds the matcher every builtin is deployed with against the INSPECTED_TOOLS its module publishes.

Categories shipped:

Category Examples blocked
filesystem rm asked to recurse, in any spelling — a short cluster containing r or R (-r, -R, -rf, -fr, -rv, -rfv) or --recursive; find -delete, and a find -exec/-execdir/-ok action that would itself block. Recursion alone is the trigger: force is not required and is not matched at all, so force without recursion (rm -f, rm -fv, rm --force) stays allowed, as does rm <file>. truncate <file>
git git push --force or -f in any argument position, forced +refspec (a lease alone stays allowed), git reset --hard — also through any long-option abbreviation git accepts (--har, --forc), and after a global option the hook does not recognise, which fails closed instead of abstaining — git add -f .env/*.pem/id_rsa/credentials
sql DROP TABLE, DROP DATABASE, TRUNCATE TABLE
terraform terraform destroy, terraform apply -auto-approve, terraform apply -replace=…, terraform state rm/push
credentials reads of .env (excluding .env.example / .sample / .template), .ssh/id_* private keys (excluding *.pub), .aws/credentials & .aws/config, any .pem / .key / .p12
credentials (path guard) any file tool reaching a SECRET_PATH_GLOBS entry — see below

Every rule above except sql only fires when its token appears in a command position — at the start of a line, after a ;, &&, ||, | or (, or after a wrapper that execs its argument such as sudo, xargs, eval or sh -c. A command that merely mentions the string is not an invocation and is not blocked: grep -rn "rm -rf" src is a search, git commit -m "docs: ban terraform destroy" is a commit message, and a heredoc body written into a file is prose. A command's arguments also end at the line break, so a cat opening a heredoc cannot reach a secrets filename named in the body.

Quoted text the shell never runs. A shell operator or backtick inside quoted text does not open a command position. Text counts as inert when the shell does not interpret it — single quotes, double quotes holding no $ or backtick, the body of a heredoc whose delimiter is quoted (<<'EOF'), and that body in the "$(cat <<'EOF' … EOF\n)" shape used for PR bodies and commit messages — and every command in the string treats its arguments as text: echo, printf (without -v), grep, cat, tee, head, tail, wc, sort, uniq, cut, tr, less, more, cd, git add/commit, gh pr review and gh pr/gh issue create, edit or comment. So a markdown backtick in a PR body, a | in a grep alternation or a ; in a commit message no longer reads as a command. The exemption fails closed: an unterminated quote, command or process substitution, ANSI-C quoting, a heredoc without its terminator, or any other command in the string — a shell, eval, an interpreter, a pipe into one — leaves the whole command judged as before. The body of a heredoc whose delimiter is unquoted is never exempted and is judged as written; quoted text elsewhere in the same command keeps its exemption. A file written alongside git also forfeits it, since a commit can run a hook the same command just wrote. A rule whose own command is in plain view still fires on a quoted operand (grep x '.env' reads the file).

sql is the exception to command position, and deliberately: DROP TABLE is never the command being run, it is the argument of one (psql -c "DROP TABLE users"), so it is matched anywhere in the string. Prose naming DROP TABLE is blocked as a result unless it sits in inert quoted text as described above; otherwise use a file editing tool when writing such documentation.

Secret-path guard on the file tools. The regex rules above only see shell commands; a Read of the same file is a different tool call, so the hook matches file-tool paths against a separate glob list:

**/.env             **/secrets/**            **/.npmrc
**/.env.*           **/credentials/**        **/.pypirc
**/.dev.vars        **/.aws/**               **/.netrc
**/.dev.vars.*      **/.ssh/**               **/config/database.yml
**/*.pem            **/.gnupg/**             **/config/credentials.json
**/*.key            **/id_rsa*
                    **/id_ed25519*

Public keys (*.pub) and dotenv samples (.env.example, .env.sample, .env.template) are exempt. The path is made absolute before matching, so a relative file_path is still caught.

These globs are deliberately enforced here rather than as Read(...) entries in permissions.deny. A single Read() deny rule makes the agent's auto-approval classifier escalate any compound command that combines a cd with a relative file read — it cannot resolve the path statically, so it refuses to auto-approve and prompts instead. Enforcing the same globs from the hook keeps the coverage, drops the prompts, and extends the protection to Bash, which a Read() deny rule never reached.

recursive_delete_roots does not apply to file-tool paths or credential reads. The only path exemptions are the public-key and sample-file globs above. Legacy allow_patterns no longer exempts commands either.

The .env rule follows the same principle: it matches the dotenv file, not any identifier that happens to end in .env. Searching source for the Node or Vite environment APIs — grep -rn "process\.env" src/, rg 'import.meta.env' app/ — reads code, not credentials, and is not blocked.

When a rule matches, Claude Code receives this message on stderr with exit 2; Codex receives the adapter's native deny decision:

Blocked by lazy-harness PreToolUse: <reason> (<category>).
Matched: <truncated command>
Review [hooks.pre_tool_use] in config.toml. Only recursive_delete_roots can exempt a literal cleanup; legacy allow_patterns no longer bypass security rules.

Scoped cleanup and environment policy. Options live in the existing event table in the shared harness config.toml. They apply to every profile reading that file; they are not implicitly scoped to the invoking profile.

[hooks.pre_tool_use]
recursive_delete_roots = ["/absolute/project/.worktrees", "/absolute/scratch"]
denied_commands = ["example-cli"]
  • Both lists default to empty. Roots must be absolute paths below the filesystem root; command names must be literal executable basenames, not regexes or paths.
  • A cleanup exception covers only one simple rm invocation with a recursive flag. Every operand must resolve strictly below a declared root. The root itself, sibling paths and symlinks resolving outside it are refused. Relative operands use the tool event's working directory. Root symlinks are resolved too, so platform aliases such as /tmp retain their filesystem meaning.
  • Quoted spaces, reordered flags, -- and multiple in-scope operands work. Shell operators, pipelines, redirections, wrappers, .., expansions, globs and unknown flags never qualify for cleanup exceptions, even if their text appears safe. Submit a separate simple cleanup command instead.
  • denied_commands blocks the named executable in recognized command positions, including absolute executable paths, assignments and common wrappers (env, sudo, command, xargs, shell -c, eval). A plain argument such as echo example-cli is not an invocation. No executable is prohibited by default.
  • Missing configuration gives no cleanup exceptions and no opt-in bans. Unreadable/malformed configuration, invalid policy values and unknown keys in this event table refuse command execution with a diagnostic naming the problem.

Migration from allow_patterns. Security no longer evaluates arbitrary allow regexes. A path mention could previously exempt an unrelated reset, a second deletion destination or another operation in a pipeline. Existing regex entries may remain in the config for migration, but grant no exemptions. Replace \.worktrees/ or rm -rf /tmp/ with reviewed absolute cleanup roots. Other regex exemptions (including Terraform and SQL) have no automatic replacement; perform those operations outside the agent hook only after explicit review. The separate git-scope hook's allowlist is unchanged.

Limits. This is a static heuristic guard, not a shell interpreter or an OS execution boundary. It does not trace aliases, shell functions, dynamically constructed executable names, command substitutions inside quoted arguments, arbitrary wrapper options (such as env -S) or commands executed inside another program. Filesystem checks are subject to changes between the hook and execution; roots must be trusted directories. Secrets in shell reads still use the existing regex rules. Environment-specific bans require configuring denied_commands in every applicable config and verifying the deployed hook; this feature does not change a live profile automatically.

Where it writes: blocks are logged to logs/hooks.log inside the runtime directory of the profile the hook was invoked with; see Observability.

The full rule list and the rationale behind each category live in specs/designs/2026-04-17-security-hooks-cluster-design.md.

pre-tool-use-git-scope — runs on PreToolUse

Source: src/lazy_harness/hooks/builtins/pre_tool_use_git_scope.py.

Responsibility: stop a git stash that reaches blindly into a shared stash stack. Like pre-tool-use-security it exits 2 to block, and Claude Code surfaces its stderr back into the agent's turn.

The stash stack belongs to the repository, not to the checkout. Every linked worktree and every concurrent agent session pushes onto the same stack, so git stash pop takes whatever happens to be on top — possibly another session's work — and git stash clear destroys all of it. Four such incidents were recorded before this hook existed.

Scope: only Bash. Every other tool name is a fast exit 0, and the rule fires only where the stack is actually shared — from a linked worktree, or from the main checkout of a repository that has one. A repository with no worktrees keeps its stack private and is left alone.

The safe list decides. Anything touching the stash that is not recognised as safe blocks, rather than the reverse — a denylist ends in a silent pass for everything nobody thought of, and (git stash pop), git stash drop -q and git stash > /dev/null all walked through an earlier denylist version.

Allowed Blocked
git stash push -m "<tag>" (any form naming the entry) git stash / git stash push / git stash save without -m
git stash apply <sha>, git stash drop stash@{1} git stash apply / drop with no ref, or with only flags (-q)
git stash list, git stash show git stash pop (removes the entry even when named), git stash clear
git stash --help git stash branch / store / create, and any unrecognised subcommand

A git stash is recognised in command position — at the start of a line, after a ;, &&, |, (, { or newline, after a shell keyword such as then or do, through wrappers like sudo, env and command, and past git's own global flags. git -C <path> stash counts: retargeting another checkout makes it more dangerous, not less. A command that merely mentions the string, like echo git stash pop, is not a stash and is not blocked.

Every git stash in a compound command is judged, not only the first: git stash list; git stash pop blocks on account of its second invocation.

Shared-stack detection reads the checkout's .git. A linked worktree's is a file pointing into <main>/.git/worktrees/<name>; the main checkout has a directory, and its .git/worktrees/ holds one entry per linked worktree — empty or absent means nobody else is on the stack. A submodule's .git file points into .git/modules/ and is out of scope. Reading the filesystem directly is what keeps this cheap enough to run ahead of every Bash call; a git rev-parse subprocess would not be.

Escape hatch. [hooks.pre_tool_use_git_scope] allow_patterns in the profile config takes regexes matched against the whole command. This is independent of the security hook: scoped cleanup roots never exempt stash operations, and legacy security regexes no longer grant exceptions.

A payload it cannot parse is a refusal, not a pass. This hook blocks, and exit 0 with no output is how a hook says "no objection" — so a guard that degraded a malformed payload into silence would report consent it never formed. The runner refuses before the hook is reached, with the reason on stderr and exit 2. Every non-blocking built-in takes the other branch and still exits 0, because refusing a tool call it was never meant to judge is the worse failure there.

Known limits, measured rather than assumed. The rule recognises a stash in command position, and an invocation the shell only reaches indirectly is outside that: sh -c "…", eval "…", a command assembled in a variable, a backslash-escaped or absolutely-pathed git, a quoted subcommand, and a redirect written before the command all pass. None is closed on purpose — each is a deliberate act, this hook guards a scope mistake rather than an adversary, and widening the anchor would cost false positives on ordinary scripts. The full list, with what was attacked and what held, is on _STASH_CALL in the source.

A stash named inside inert quoted text — a commit message, a PR body, a heredoc of prose — is not an invocation. The rule for what counts as inert is the security hook's, shared by both guards; see Quoted text the shell never runs above.

Kill criteria. This hook blocks, so its cost is false positives rather than non-adoption. If clearing them needs more than three allow_patterns entries in the first month of use, the rule is too broad and comes out rather than growing an allowlist.

pre-tool-use-memory-size — runs on PreToolUse

Source: src/lazy_harness/hooks/builtins/pre_tool_use_memory_size.py.

Responsibility: warn — never block — when an Edit or Write would push an always-loaded context file past a line or a byte ceiling (ADR-030 G2). Two kinds of file qualify, and each carries its own threshold pair:

File Lines Bytes Where the numbers come from
per-project MEMORY.md 200 12KB MAX_LINES / MAX_BYTES, fixed in the module
any CLAUDE.md or AGENTS.md 200 12KB CLAUDE_MD_MAX_LINES / CLAUDE_MD_MAX_BYTES, overridable in config.toml

The defaults coincide; the pairs do not. They are kept apart because the two files have different jobs — MEMORY.md is a curated index, a contract (CLAUDE.md, or AGENTS.md under ADR-060) loads on every session — so the ceiling for one has to be movable without moving the other. Both are soft contracts. The line ceiling exists because Claude Code truncates MEMORY.md if it overflows; the byte ceiling exists because the file is re-sent on every session start, so its size is a recurring cost regardless of how few lines carry it.

The byte ceiling is not redundant with the line one. An index written as one long line per note — the shape these files naturally take — stays far under 200 lines while dominating the prompt prefix: a real 67-line index measured 20KB and cost roughly 3.6k tokens on every session start, invisible to a line-count check.

Mechanics:

  1. Scope check — the hook only acts on the edit tools (Edit, Write, and Codex's apply_patch), and only on two path shapes: a path ending in /memory/MEMORY.md, or one that is exactly CLAUDE.md / AGENTS.md or ends in /CLAUDE.md / /AGENTS.md. A near miss such as NOTCLAUDE.md is out of scope. Every other tool / path: instant exit 0. The tool gate is the native tool name rather than the normalised modify-file operation, because that operation also covers NotebookEdit and the filename match would not narrow it back out.
  2. Project the post-operation content from the tool input: Write uses content directly, apply_patch applies its hunks; Edit reads the current file and applies old_string → new_string (honouring replace_all) in memory.
  3. Measure both the line count and the UTF-8 byte size of that projection against the pair belonging to that kind of file.
  4. If either ceiling is breached, emit a top-level systemMessage naming the breach — both, when both apply — with the remedy for that kind of file: for MEMORY.md, lh memory consolidate or moving detail out of the index into the linked note; for a contract, a pass over whether each line is a fact the agent needs or a procedure it would already follow, with lh memory rightsize to show every contract the harness can reach — profile system docs and repository CLAUDE.md / AGENTS.md alike — and which ceiling it breaches. The hooks.log line is a second, shorter spelling of the same breach and deliberately carries no remedy.
  5. Always exit 0. This is a warning hook, not a guard.

Moving the CLAUDE.md pair. [hooks.pre_tool_use] in the harness config.toml — the one config_dir() resolves, shared by every profile — takes claude_md_max_lines and claude_md_max_bytes:

[hooks.pre_tool_use]
claude_md_max_lines = 250
claude_md_max_bytes = 16000

The loader is fail-soft on the same contract as the security hook's allowlist: a missing config file, malformed TOML, a missing section, or a value that is not a positive integer falls back to 200 / 12KB rather than raising or blocking the write. lh memory rightsize reads the same loader, so the hook and the audit command cannot silently disagree about where the ceiling is. MEMORY.md's pair has no such keys.

Bypass for tooling that legitimately rewrites MEMORY.md (the consolidator pathway): set LH_MEMORY_SIZE_BYPASS=1 in the subprocess environment.

pre-tool-use-read-size — runs on PreToolUse

Source: src/lazy_harness/hooks/builtins/pre_tool_use_read_size.py.

Responsibility: warn — never block — when a Read without offset or limit targets a file large enough that pulling it in whole is the session's biggest single context expense. Reading a 3000-line file costs more context than hundreds of shell commands, and unlike shell output there is no way to trim it after the fact.

Mechanics:

  1. Scope check — only Read tool calls. Every other tool: instant exit 0.
  2. If the call already carries a numeric offset or limit, the caller has bounded the read; exit 0. A value that is not an integer is not a bound — the normalised ToolCall carries None for it — so such a call is reported as unbounded.
  3. Size the target file. Missing, unreadable, or empty files exit 0 — this hook never speaks about a read that is going to fail anyway.
  4. Above 500 lines, emit a top-level systemMessage reporting the line count and an estimated token cost, and suggesting offset/limit or Grep to locate the region first.
  5. Always exit 0. This is a warning hook, not a guard.

Every warning is also appended to hooks.log, so the rate of unbounded large reads is measurable over time rather than a matter of impression.

Bypass: set LH_READ_SIZE_BYPASS=1 in the subprocess environment.

Where it writes: nowhere on disk. Only stdout (the warning) and the standard hook log.

pre-tool-use-graph-assist — runs on PreToolUse

Source: src/lazy_harness/hooks/builtins/pre_tool_use_graph_assist.py.

Responsibility: when an agent searches a repository for a code identifier, put what graphify's graph knows about it — definitions, callers, callees, documents naming it — beside the search result. Information, never an instruction and never a verdict. Opt-in: add it to [hooks.pre_tool_use].scripts. Declared for Claude Code only; lh deploy omits it from other agents' profiles and says so.

Mechanics:

  1. Scope check — only Grep, and Bash commands that run grep, rg, ugrep or egrep. Anything else exits 0 without a trace.
  2. The main checkout behind the cwd (resolved through git --git-common-dir, so a worktree uses its main checkout's graph) must hold graphify-out/graph.json, with an mtime at or after the last commit on that checkout's HEAD that touched a file graphify treats as code — a docs-only commit does not make the graph stale. Line numbers can drift for files a worktree's branch has changed.
  3. The search must target the checkout the agent is in — no pipe feeding it, every path inside it, nothing the shell would expand (~, $, subshells) — and its pattern must be identifier-shaped (name, mod.func, Class.method, optionally (), optionally after def/class/function). Regexes and phrases stay silent.
  4. Look the identifier up in graphify-out/cache/lh-graph-assist.json, built from graph.json by lh knowledge graph update or lazily here. A lazy build that passes 1.5 s is abandoned for this call.
  5. On a match, emit up to three definitions as additionalContext, about 600 tokens at most. Homonyms are all counted; three are shown and the rest summarised as (+N more).
  6. Always exit 0, including on any error.

Where it writes: the index above, and one JSON line per evaluated search to <agent dir>/logs/graph_assist_metrics.jsonl (outcome, reason, latency). lh knowledge graph-assist report reads it.

post-tool-use-format — runs on PostToolUse

Source: src/lazy_harness/hooks/builtins/post_tool_use_format.py.

Responsibility: keep edited Python files formatted without forcing a separate workflow step. Fires after every successful tool call; matches Edit or Write against any path ending in .py and runs ruff format <path> with a 10-second timeout.

Behaviour:

  • Non-Python files: no decision, nothing run.
  • Tools other than Edit / Write: no decision, nothing run. NotebookEdit is deliberately among them even though it carries the same modify_file operation — nothing guarantees a notebook's path ends in .ipynb, so the suffix check alone would let Ruff loose on a file that was never Python source.
  • ruff not on PATH, or ruff format times out, or the file does not exist: no decision, and a line in hooks.log under the invoking profile saying which file was left unformatted (the hook never fails the agent's turn).

This is the simplest built-in and the easiest extension point — a project that prefers black over ruff format, or that wants to format .go files with gofmt, can copy this file into ~/.config/lazy-harness/hooks/ under a different name and register it in [hooks.post_tool_use] instead.

Where it writes: the file the agent just edited (in place, via ruff format). Nothing else.

post-tool-use-ansible-lint — runs on PostToolUse

Runs ansible-lint after any Edit or Write to a .yml or .yaml file that sits inside a repository containing an ansible.cfg, and is either directly in that Ansible root or under a roles/ or playbooks/ directory. This excludes YAML that merely lives alongside Ansible content — a Traefik or Homepage config committed in the same repo, for example — as well as any file whose first line starts with $ANSIBLE_VAULT, since encrypted vars are ciphertext, not lintable YAML. ansible-lint runs with the Ansible root as its working directory, so it resolves .ansible-lint and role paths the same way a manual run from that root would.

Findings are returned to the agent as additional context rather than written to a log, so a lint failure is visible in the session that caused it. Clean runs emit nothing.

The hook is fail-soft: a missing or non-executable ansible-lint binary and a timeout both exit 0 and leave the file unchecked. Malformed input never reaches the hook at all — the runner refuses a payload it cannot parse, still exiting 0 because this hook does not block, but naming the refusal on stderr instead of running the checks against nothing. When the binary is unavailable, that fact is surfaced to the agent as additional context — not just logged — because a hook whose entire premise is "the model shouldn't have to remember to lint" must not report its own absence somewhere the model won't see. A note is also written to logs/hooks.log for every unavailable run, so the rate of skipped lints is measurable over time.

post-tool-use-sync-system-doc — runs on PostToolUse

Source: src/lazy_harness/hooks/builtins/post_tool_use_sync_system_doc.py.

Responsibility: keep a profile's composed system doc in sync with its role-named sources. The framework splits agent-facing instructions across head.md, tail.md, common.md (shared via _common/), and _common/<agent>.md; on every edit to one of those segments the composed document would drift unless something re-stitched it.

Mechanics:

  1. Scope check — the hook only acts on file-modification operations whose edited basename is head.md, tail.md, common.md, or a registered <agent>.md. Every other operation or path makes it abstain.
  2. Walk parent dirs of the edited file to find the enclosing profiles/ root.
  3. Call sync_profiles(<profiles_dir>, <adapter>) to regenerate every profile's composed system doc from its segments.
  4. Fail-soft: any exception is swallowed and the hook still abstains. A sync failure must never block the agent's turn.

Where it writes: each profile's system doc under <profiles_dir>/<name>/<doc> (in place). The segment files themselves are not touched. Note the scope of step 3: the tree is chosen by the edited path, and every profile under it is regenerated — not only the one whose segment was touched.

Which destinations it writes. Those of the agent the hook was invoked under: the adapter is resolved from --profile, and system_docs() is read off it (CLAUDE.md for Claude Code, AGENTS.md for Codex). An adapter may name more than one destination, and each receives the identical rendered bytes. Before that it read the global [agent].type, so on a machine whose profiles run different agents a segment edit regenerated the wrong agent's contract file and left the right one stale.

The segment filenames it watches are named by role — head.md, tail.md, common.md, <agent>.md — and the trigger set is derived from those roles rather than listed in the hook, so adding an adapter cannot leave its segment unwatched. Legacy destination-keyed spellings are not inputs and do not trigger a resync.

Tool scope, deliberately narrower than the event. PostToolUse delivers NotebookEdit as a file modification alongside Edit and Write, and this hook matches on the filename rather than an extension — so a notebook named head.md would clear the second gate. This is an accepted synthetic collision; real notebook edits carry .ipynb paths.

engram-persist — runs on Stop

Source: src/lazy_harness/hooks/builtins/engram_persist.py (the builtin, which takes a HookEvent and returns an empty HookDecision) + src/lazy_harness/knowledge/engram_persist.py (the EngramPersister class).

Responsibility: deterministically mirror new entries from decisions.jsonl and failures.jsonl into Engram so the same observation is queryable both via grep over the JSONL and via mem_search from any future session. Runs after compound-loop writes its new entries on the same Stop event.

Mechanism: per-file byte cursors in engram_cursor.json, one per project per machine. When memory lives in the knowledge store the cursor sits in <lh data dir>/engram-cursors/<memory dir relative to the store>/ — shared by every profile, because they append to the same JSONL and save into the same Engram database, and outside the store because the store syncs between machines and the database does not. Memory outside the store is per profile, and its cursor stays in <agent dir>/engram-cursors/<host>/<owner>/<name>/. A shared cursor's first run adopts the furthest offset any profile's old cursor reached, so the move does not re-upload history.

{
  "version": 1,
  "decisions_offset": 18234,
  "failures_offset": 5120,
  "updated_at": "2026-04-13T18:33:01Z"
}

For each kind (decision, failure):

  1. Open the matching JSONL, seek to the stored byte offset.
  2. Read line by line. Partial lines at EOF are deferred to the next run (the producer might still be flushing).
  3. Decode each line as JSON. Malformed lines are counted (skipped_malformed) and the cursor advances past them — they are not retried.
  4. For each well-formed entry, invoke engram save --type <kind> --project <key> --scope project -- <title> <json>. The title is the entry's summary field truncated to 200 chars; the body is the canonical JSON of the entry. Flags go first and -- ends them, so a summary starting with -- stays a title instead of failing as an unknown flag; engram ignores a -- placed before its flags.
  5. On success, advance the cursor to the new file position and persist engram_cursor.json atomically (tempfile + os.replace). A run attempts at most 25 saves across both files (MAX_SAVES_PER_RUN), so a large backlog drains over several Stop events instead of blocking one.
  6. On failure, leave the cursor untouched and stop processing this kind for this run. The next run retries the same offset → at-least-once delivery. A failure is logged as one line carrying both of engram's output streams, so an update banner printed ahead of the error does not hide it.
  7. On the fifth failed exit for the same entry (MAX_ATTEMPTS_PER_ENTRY), the entry moves to engram_quarantine.jsonl beside the cursor and the cursor advances past it, so one entry that always fails cannot block every later entry in its file. Attempts are counted per run in the cursor file (retries, keyed by kind and offset) and reset when a save succeeds. A timeout or a binary that never started uses no attempt: it says nothing about the entry. Quarantine gives up ordering for that one entry; its raw line, offset, exit code and last error stay in the sidecar for a manual replay.

Project key resolution: git rev-parse --git-common-dir so worktrees collapse onto the main repo's basename. This prevents lazy-harness and .worktrees/feat-foo from showing up as two separate Engram projects.

If engram is not on PATH, the run logs engram binary not on PATH; skipping run (no-op) and exits 0. That run also records a skip event in engram_persist_metrics.jsonl, which lh doctor reports as a warning (N skipped) in the "Engram persist" section, since a skip saves nothing.

Where it writes:

  • engram_cursor.json — the per-kind byte cursors, plus the failed attempts at the entry each file is stuck on, in the location described above.
  • engram_quarantine.jsonl — beside the cursor: one record per entry that ran out of attempts.
  • <agent runtime dir>/logs/engram_persist.log — append-only error log (subprocess failures, missing binary). The directory is the agent runtime directory of the profile the hook was invoked with, not a fixed path.
  • <agent runtime dir>/logs/engram_persist_metrics.jsonl — one JSONL record per run (run summary) plus one record per slow engram save (≥ 500 ms). The lh doctor "Engram persist" section reads this file, for the active profile or the [profiles] default one when run outside an agent session, via monitoring/engram_persist_health.py to classify state as ok / warn / fail based on last-run age, recent failure rate, cursor lag, and quarantined entries — each project's latest quarantined_total, a warn until its sidecar is emptied. A large lag is warn with catching up only when the run hit its save cap, the cursor file advanced, and no save failed in that run; otherwise it is fail.

herdr-context-gauge — runs on Stop, SessionStart, SessionEnd, PostToolUse

Publishes the session's live context size onto its Herdr pane, so an orchestrator driving that pane can see how large the window has grown.

The problem it solves is specific to multiplexed work. When one agent is kept alive and fed task after task through herdr agent prompt, its window grows monotonically, and every subsequent turn re-reads the whole thing. Herdr's own agent surface reports lifecycle state — idle, working, blocked — but never context size, so the orchestrator has no signal that the worker is due for a reset and keeps prompting it.

After each turn the hook reads the transcript through the reader the profile's agent declares and takes the last token-usage record, summing every input channel: fresh input, cache reads and cache writes. In a Claude Code transcript those are input_tokens, cache_read_input_tokens and cache_creation_input_tokens on the last assistant entry; in a Codex rollout, the last token_usage_record, whose input_tokens already include the cache. That is the live window, not the session's cumulative spend — summing every turn instead would report a number orders of magnitude too large. An agent whose transcript lh cannot read gets no gauge, and the pane's label is cleared. The result becomes a traffic light published as pane metadata:

Context Label
below 200k 🟢 94k
200k–400k 🟡 340k
400k and above 🔴 673k rotar

The action rides inline on the red label rather than living in a separate rule, because the orchestrator already reads this field in herdr agent list output when it harvests a worker. A rule in a prompt has to be recalled; a verb attached to the datum does not.

Publishing uses a dedicated lh:ctx metadata source, so it never collides with the herdr:claude integration that owns lifecycle reporting.

Pane metadata is persistent and panes outlive the sessions inside them, so publishing alone is not enough: a dead session's window would stay on display until something else overwrote it, and the next session started in that pane would inherit a number that describes its predecessor. The hook therefore retracts as well as publishes:

Event Behaviour
Stop publish the current window
SessionStart publish if the transcript already holds a window (a resume), otherwise clear
SessionEnd clear, whatever the transcript says
PostToolUse publish, at most once per THROTTLE_SECS (60s)

Retraction issues herdr pane report-metadata <pane> --source lh:ctx --clear-display-agent, naming the same source that published. A pane killed outright never fires SessionEnd, so its label survives until the next session starts there and clears it.

Stop alone gives the gauge a resolution of one turn, which is the wrong resolution for what it measures: a turn that spends half an hour on hundreds of tool calls grows the window the whole time and reports nothing until it ends. A worker can cross the rotate threshold mid-turn and the orchestrator only learns once the cost is already paid.

PostToolUse closes that gap by sampling inside the turn. Because it fires on every tool call, two things keep it cheap:

  • A time throttle. At most one publish per THROTTLE_SECS (60s) per pane, recorded in a stamp file under the system temp dir keyed by pane id. Panes throttle independently. A stamp that is unreadable, corrupt, or dated in the future fails open — publishing once too often costs a subprocess, while failing closed would freeze the pane for the rest of the session.
  • The throttle precedes the transcript read. The skipped path does no I/O beyond stat-ing the stamp, which matters when the alternative is parsing a multi-megabyte JSONL on every tool call.

Stop, SessionStart and SessionEnd ignore the throttle: end-of-turn is the authoritative reading and the lifecycle events must never be suppressed by a mid-turn sample. Each of them also refreshes the stamp, so a publish never immediately follows another.

The hook is registered with a per-event matcher — * on PostToolUse, the event default elsewhere. The Edit|Write default that PostToolUse applies to other built-ins would be wrong here: the tools that actually grow a context window are Read, Bash, Grep and Task.

The hook is fail-soft and a no-op outside Herdr: a missing HERDR_ENV=1, an absent HERDR_PANE_ID, a herdr binary that is not installed, or a publish that times out all exit 0. An unreadable transcript is not an error — it is the ordinary state at startup, and it clears the gauge.

Because it branches on four events, it declares no default event of its own: which one arrives is decided by where you wire it, and a payload always names it. That has one consequence when invoking the hook by hand. A pane whose gauge is stranded — the session was killed outright, so SessionEnd never fired — is cleared by naming the event:

echo '{"hook_event_name": "SessionEnd"}' | lh hook herdr-context-gauge --profile <p>

Running lh hook herdr-context-gauge with empty stdin does not do this. With no event to resolve, the hook does not run; it writes a line to stderr and exits 0.

user-prompt-goal — runs on UserPromptSubmit

Source: src/lazy_harness/hooks/builtins/user_prompt_goal.py.

Responsibility: record events for prompts that look like work. Ships as a sensor collecting baseline data; injection of goal prompts back to the agent is gated behind [loops] inject_goal_prompt, which defaults to off until a baseline exists.

The hook classifies prompts into trivial and non-trivial. For non-trivial ones it records an event with kind="nontrivial_prompt" to the metrics store — one row per qualifying prompt, not per session — so that later analysis can measure how often such prompts occur. This hook records only the denominator: UserPromptSubmit fires before the assistant has produced any text, so it can classify the incoming request but cannot judge whether a goal actually got declared. The numerator — goal_declared/goal_absent — is recorded by the compound-loop worker instead, which grades the session from its first 8 and last 20 messages — the head is there because a success criterion is stated before the work.

Classification logic:

  1. A prompt is non-trivial if it contains a file reference (regex match for common extensions: .py, .md, .toml, .yaml, .yml, .json, .sh, .lock) OR if it is at least 25 characters long and contains at least one action verb.
  2. A prompt is trivial if it is empty, whitespace-only, or does not meet the criteria above.

The action verbs monitored (English + Spanish): add, agregá, agrega, arreglá, arregla, build, cableá, cablea, cambiá, cambia, create, escribí, escribe, fix, hacé, hace, implement, implementá, implementa, migrate, migrá, move, refactor, refactorizá, remove, rename, sacá, saca, wire.

Mechanics:

  1. Read the user's submitted prompt off the hook event (event.prompt); the runner parses the payload and the agent adapter normalises it, so the hook never touches stdin.
  2. Call is_non_trivial(prompt) to classify it.
  3. If the prompt is the compound-loop evaluator's own (it opens with EVALUATOR_PROMPT_HEAD), record nothing and inject nothing: the evaluator runs as a headless session, and counting it made about half of the nontrivial_prompt rows the grader's own runs.
  4. Otherwise, if the prompt is non-trivial, insert a row into the loop_events table of metrics.db with kind="nontrivial_prompt", session=<from payload>, project=<cwd from payload>, and a server-generated timestamp.
  5. If the prompt is trivial, record nothing.
  6. Always exit 0, even on malformed input or database write failures. This is a fail-soft sensor.

Output: none. The hook only records to the store, it does not emit hookSpecificOutput.

Where it writes: the loop_events table in metrics.db, resolved the same way every lh metrics/lh status reader resolves it — [monitoring] db from config.toml if set, otherwise the path returned by data_dir() (which resolves the LH_DATA_DIR environment variable, falls back to $XDG_DATA_HOME/lazy-harness, and defaults to ~/.local/share/lazy-harness). Nothing is written for trivial prompts.

Enabling it: the hook is registered but not part of the default hook set — opt in explicitly in config.toml:

[hooks.user_prompt_submit]
scripts = ["user-prompt-goal"]

stop-context-rotate — runs on Stop

Source: src/lazy_harness/hooks/builtins/stop_context_rotate.py.

Responsibility: say once, per session, that the context window has grown past the point where rotating it is cheaper than carrying it. herdr-context-gauge already computes this number and paints the pane red at ROTATE_TOKENS, but a colour on a pane is a display, not a prompt. This hook turns the same number into a systemMessage and changes nothing else.

Why a second hook rather than a branch in the gauge. The gauge's responsibility is publishing pane metadata for an orchestrator; emitting operator-facing text is a different job with a different failure mode. The threshold itself is imported from the gauge rather than restated, so the message and the pane colour cannot drift apart — a test asserts they are the same object.

Mechanics:

  1. Take the transcript path from the normalised event (HookEvent.transcript_path) and use it only when a file is actually there — a payload that named none, or named something that was not a non-empty string, arrives as None.
  2. Compute the session's context with the gauge's context_tokens() — the same reader, so the same answer.
  3. Return an empty HookDecision when the total is below ROTATE_TOKENS (400k).
  4. Return an empty HookDecision when this session already published its notice, detected by a stamp file keyed on session_id. Stop fires every turn; without this the notice repeats for the rest of the session, which is how a warning gets tuned out.
  5. Otherwise return HookDecision(system_message=…) and write the stamp.
  6. It never sets a verdict and never sets stop, so it cannot block a Stop, and the adapter's exit code is always 0.

Output: {"systemMessage": "<notice>"} on the first qualifying Stop of a session; nothing on every other path.

systemMessage goes at the top level, not inside hookSpecificOutput. Claude Code's hook schema (verified against the 2.1.269 binary) lists it among the common fields — "Display a message to the user (all hooks)" — while hookSpecificOutput accepts exactly four keys: additionalContext, permissionDecision, permissionDecisionReason and updatedInput. A nested systemMessage parses without error and is then discarded, producing a hook that runs, stamps, logs and displays nothing. The placement is the adapter's since the migration; the hook returns a system_message and never serialises anything itself.

Kill criteria. Declared before deployment, as behavioural automation requires:

  • Baseline (2026-09-12, 72h, both profiles), measured with context_tokens on the closing turn — the same reader the hook fires on, not the session's peak. A criterion read against a different number than the trigger uses cannot judge the trigger. 9 of 387 sessions closed at or above 400k (5 lazy, 4 flex); 0 compactions across 52 interactive sessions; highest close 824k.
  • Horizon: 15 days, re-measured the same way.
  • Remove it if sessions closing above 400k have not fallen below 6 per 72h, or if compactions are still zero. A notice nobody acts on is worse than silence, because it trains the reader to skip the next one.

stop-verify-guard — runs on Stop

Source: src/lazy_harness/hooks/builtins/stop_verify_guard.py.

Responsibility: soft-enforce verification before a session that declared a goal closes. Once per session: if the session declared a goal and no verify_ran event exists for it, the first Stop attempt blocks with a reminder; the second closes regardless. The event is recorded either way. Gated behind the same [loops] inject_goal_prompt flag as user-prompt-goal — when it is off, the hook does nothing at all (no evaluation, no event, no block).

How "declared a goal" is detected. Not via the compound-loop worker's goal_declared/goal_absent verdict — that is an LLM classification of the transcript made after the session ends and is structurally unavailable at Stop time. Instead, the hook asks the profile's agent adapter for a TranscriptReader and looks for a single Signal.GOAL_STATUS event in the session's transcript. What that marker looks like on disk is the reader's business, not the hook's: for Claude Code it is the entry its native /goal <condition> command writes synchronously, the moment the command runs. This is a narrower signal than the compound-loop verdict: it only catches sessions where the user ran /goal, not ones where the assistant stated a prose criterion without it.

Agents whose transcript cannot be read. The reader is resolved per profile, from the [profiles.<name>].agent setting that decides whose wire format a session speaks. An adapter that does not implement TranscriptReader resolves to no reader at all, and the hook then reports no goal and never blocks. An adapter whose reader delivers messages and tokens but does not declare GOAL_STATUS lands in the same place by a different route — which is the point of naming the signal rather than carrying a "needs the transcript" boolean: such a reader must not silently re-enable a guard that would then pass every session. ClaudeCodeAdapter declares all four signals. CodexAdapter also implements TranscriptReader (ADR-048), but declares three of the four — MESSAGES, TOOL_CALLS, TOKEN_USAGE — and rejects GOAL_STATUS, because nothing in a codex-cli 0.154.0 rollout marks an explicit goal; that is the exact case the previous sentence describes, and it is why a Codex profile's deploy leaves stop-verify-guard out. Keeping such a hook out of a profile in the first place is a separate mechanism, and it ships: lh deploy resolves gaps_for_profile before it writes a profile's config, leaves out every hook whose declared signals that profile's agent cannot deliver, and names each omission on stdout — · stop-verify-guard omitted in 'throwaway': agent 'codex' does not deliver goal_status. It is not a deploy failure; the exit code is unchanged and the other profiles are untouched — see Hooks the deploy leaves out. lh doctor's Hook signals section reads the same resolver, so what doctor names is what deploy omits.

The run-time path above is still the one that matters where the filter cannot reach. Only a built-in declares signals — builtin_signals answers the empty set for anything outside the registry — so a hook of your own that reads the transcript installs on every profile regardless, and there the guard can only decline to block.

Mechanics:

  1. If [loops] inject_goal_prompt is off, exit 0 immediately.
  2. Read session_id from stdin JSON; exit 0 if missing or not a string.
  3. Take the transcript path from the parsed event — the adapter reads the agent's transcript_path field and nothing else — and ask for the profile's TranscriptReader. Exit 0 if no transcript is resolvable, if the adapter supplies no reader, or if the transcript carries no GOAL_STATUS event. The read stops at the first marker rather than parsing a transcript that can run to hundreds of megabytes by the time a Stop hook sees it.
  4. If a verify_ran event already exists for this session in loop_events, exit 0 — nothing to enforce.
  5. If a verify_block event already exists for this session (this is at least the second Stop attempt), record verify_skipped and exit 0 without blocking.
  6. Otherwise, record verify_block and print {"decision": "block", "reason": "..."} to stdout.
  7. Always exit 0, even on malformed input, an unreadable transcript, or database write failures.

Output: {"decision": "block", "reason": "<reminder>"} on the first qualifying Stop; nothing on every other path.

Where it writes: the loop_events table in metrics.db, resolved via resolve_db_path() — the same [monitoring] db-then-data_dir() resolution every reader and writer of this table uses.

Its producer: lh metrics record-verify writes the verify_ran event this hook reads. It stamps the same session, project and profile the guard reads back, and resolves the database through the same resolve_db_path(). The session id comes from --session, or from CLAUDE_CODE_SESSION_ID when the flag is absent — the variable Claude Code sets in the environment of every command it runs, carrying the same value the hook receives as session_id in its payload.

lh metrics record-verify            # session id from the environment
lh metrics record-verify --session <id>

Invoke it as the final step of a verification procedure, never on its own: recording that verification happened without having verified is worse than recording nothing, because it silences the guard.

Wire the guard only once the producer is deployed and invoked. The command shipping in this repository is not enough — the procedure that calls it lives in the profile, outside this repo. A guard whose producer nobody invokes blocks once on every goal-declaring session and passes on the second attempt regardless, which is a nag rather than enforcement.

Enabling it: the hook is registered but deliberately not part of the default hook set. Opt in explicitly, after confirming the deployed lh carries record-verify and the verification procedure invokes it:

[hooks.session_stop]
scripts = ["session-export", "compound-loop", "engram-persist", "stop-verify-guard"]

How the hooks complement each other

The magic is the composition, not any single hook. A full session lifecycle:

┌──────────────────────┐      ┌──────────────────────┐
│  SessionStart         │──►   │ context-inject       │
│                       │      │ reads: Git, memory,  │
│                       │      │   sessions/, jsonl,  │
│                       │      │   lazynorth          │
│                       │      │ writes: stdout       │
└──────────────────────┘      └──────────────────────┘
              │
              │  user ↔ agent conversation
              │  (every Bash / Read / Edit / Write tool call → PreToolUse → pre-tool-use-security)
              │  (every Edit/Write tool call → PostToolUse → post-tool-use-format)
              ▼
┌──────────────────────┐      ┌──────────────────────┐
│  PreCompact           │──►   │ pre-compact          │
│  (when context fills) │      │ reads: transcript    │
│                       │      │ writes: compact-     │
│                       │      │   backups/,          │
│                       │      │   memory/pre-compact │
│                       │      │   -summary.md        │
└──────────────────────┘      └──────────────────────┘
              │
              │  session continues
              ▼
┌──────────────────────┐      ┌──────────────────────┐
│  Stop                 │──►   │ session-export       │
│                       │      │ writes:              │
│                       │      │   <knowledge>/       │
│                       │      │   sessions/          │
│                       │      │                      │
│                       │──►   │ compound-loop        │
│                       │      │ (queues task,        │
│                       │      │  spawns worker)      │
│                       │      │ writes:              │
│                       │      │   decisions.jsonl,   │
│                       │      │   failures.jsonl,    │
│                       │      │   handoff.md,        │
│                       │      │   learnings/*.md     │
│                       │                              │
│                       │──►   │ engram-persist       │
│                       │      │ mirrors new JSONL    │
│                       │      │ entries → Engram     │
│                       │      │ via byte cursor      │
│                       │      │ writes:              │
│                       │      │   engram_cursor.json,│
│                       │      │   engram_persist     │
│                       │      │   _metrics.jsonl     │
└──────────────────────┘      └──────────────────────┘
              │
              │  user types /exit or /clear
              ▼
┌──────────────────────┐      ┌──────────────────────┐
│  SessionEnd           │──►   │ session-end          │
│                       │      │ forces compound-loop │
│                       │      │ without gates        │
│                       │      │ so handoff.md        │
│                       │      │ reflects the         │
│                       │      │ session's final state│
└──────────────────────┘      └──────────────────────┘

The next SessionStart then reads handoff.md, pre-compact-summary.md, the last session export, and the tail of decisions.jsonl / failures.jsonl. That is the loop: every session produces memory that the next session consumes.

Writing your own hook

Minimum viable user hook — ~/.config/lazy-harness/hooks/my_hook.py:

#!/usr/bin/env python3
"""My custom SessionStart hook."""
import json
import sys

def main() -> None:
    try:
        payload = json.load(sys.stdin)
    except (json.JSONDecodeError, EOFError, ValueError):
        payload = {}

    # Do your work. Touch the filesystem, call an API, whatever.
    # But: always exit 0, and keep it fast.

    output = {
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": "Hello from my_hook",
        }
    }
    print(json.dumps(output))

if __name__ == "__main__":
    main()

Register it in config.toml:

[hooks.session_start]
scripts = ["context-inject", "my_hook"]

Then:

lh deploy         # regenerates the agent's hook config
lh hooks list     # confirms your hook resolved
lh hooks run session_start    # runs the event's hooks, my_hook included

Hooks are ordered within an event. They run sequentially in the order you declared. A hook cannot cancel the ones after it — there is no hook-abort semantic, by design.

Observability

Most built-in hooks append a line to logs/hooks.log with their name, the cwd, and what they did (or why they skipped); the compound-loop worker logs to compound-loop.log beside it. Both files rotate in place once they exceed 100 KB, keeping the last 500 lines. Not every built-in writes there — stop-verify-guard records to the metrics database instead.

Neither file sits at a fixed path. Both live in the agent runtime directory, which resolves in this order: the agent's own environment variable (CLAUDE_CONFIG_DIR and its equivalents), then the profile's config_dir, then the adapter's default_home() (~/.claude for Claude Code, ~/.codex for Codex). On a single-profile Claude Code install that lands in ~/.claude/logs/; with profiles declared, the launcher exports the environment variable, so it lands in the profile's own directory — see profiles and deploy.

The second step, the profile's config_dir, is what a hook falls back on when the environment variable is absent, and ten hooks read it today: context-inject, session-export, session-end, compound-loop, pre-compact, post-tool-use-format, post-tool-use-ansible-lint, pre-tool-use-read-size, pre-tool-use-memory-size and the block log of pre-tool-use-security resolve the directory from the profile they were invoked with — and so does the compound-loop worker, which inherits the profile the producer spawns it with. No hook that writes this file resolves it globally any more: step 5 closed the last one.

lh status hooks surfaces a summary view over hooks.log so you do not have to tail it by hand.