Built-in Hooks Guide¶
When to Use This Guide¶
Read this when you want to understand what little-loops is doing in the background, tune a specific automatic behavior, or disable something you don't need. If hooks are working fine and you just want to get things done, you don't need this guide.
What Is a Hook?¶
A hook is a lifecycle callback — a script that fires automatically at a specific moment in your Claude Code session. Hooks can load config, inject context, record events, or block tool calls. They run silently: you usually only notice them when they inject a message or deny an action.
All built-in little-loops hooks are declared in hooks/hooks.json. They fire through a thin adapter layer and route to Python handlers under scripts/little_loops/hooks/. You never need to invoke them directly. (Developers may also register their own local-only hooks in .claude/settings.local.json — gitignored, fire only in their own checkout.)
When you install the little-loops plugin, it registers a set of Claude Code lifecycle hooks. These fire silently as you work: loading config when a session starts, recording what ran, watching context usage, guarding issue-file integrity, and preserving state before compaction. This guide documents every one of them so nothing about your session is a mystery — and so you can disable anything you don't want.
Table of Contents¶
- How Hooks Work
- The Lifecycle at a Glance
- Safe by Default
- SessionStart
- UserPromptSubmit
- PreToolUse
- PostToolUse
- Stop
- PreCompact
- SubagentStart / SubagentStop
- Turning Hooks Off
- Configuration Reference
- See Also
How Hooks Work¶
All hooks are declared in hooks/hooks.json and dispatched through a thin translation layer. Each shell adapter under hooks/adapters/claude-code/ reads the host's native hook payload from stdin, pipes the JSON through a Python dispatcher (python -m little_loops.hooks <intent>), which routes to a host-agnostic handler module under scripts/little_loops/hooks/ (e.g. session_start.handle), then translates the result back into Claude Code's hook contract (exit code + stderr feedback). A few lightweight hooks are pure bash with no Python handler.
This adapter→handler split is why the same hook logic runs across Claude Code, Codex, and other hosts — only the thin adapter changes per host. See Write a Hook for authoring details.
Two outcomes a hook can produce:
- Passive — records data, injects context, or writes a state file. You don't notice it.
- Blocking / feedback — denies a tool call, or returns exit 2 to surface a message the model must act on (e.g. "context is full, run
/ll:handoff"). Only a handful of hooks do this, and they're called out explicitly below.
The Lifecycle at a Glance¶
| Event | Hook | What it does | Can block? | Default |
|---|---|---|---|---|
| SessionStart | session-start | Loads config + local overrides, injects a 7-day project digest, starts history backfill | — | on |
| SessionStart | sweep-stale-refs | Finds/fixes prose calling a done issue still "open" |
— | on (report) |
| SessionStart | drift-check | Surfaces throttled mention/route doc-drift findings (hooks.doc_drift_throttle_days, default 7 days); opt out with LL_DOC_DRIFT_DISABLE |
— | on |
| SessionStart | scratch-cleanup | Prunes dead-PID scratch files from .loops/tmp/scratch |
— | on |
| UserPromptSubmit | user-prompt-check | Optimizes vague prompts; records corrections & skill calls | — | on (opt-in for recording) |
| PreToolUse | check-duplicate-issue-id | Blocks creating an issue file whose ID collides cross-type | yes | on |
| PreToolUse | check-decisions-yaml | Blocks writing a corrupt .ll/decisions.yaml or .ll/decisions.d/*.json fragment from Claude-side Write/Edit |
yes | on |
| PreToolUse | learning-tests gate | Warns (or blocks) on imports with no Learning Test record | warn/block | off |
| PreToolUse | check-private-refs | Blocks a Write/Edit that would commit a private-codebase reference (gates via ll-verify-private-refs) |
yes | on |
| PostToolUse | install-nudge gate | Nudges /ll:explore-api when a package-install Bash command is detected |
— | off |
| PreToolUse | scratch-pad-redirect | Redirects oversized Bash output to a scratch file | yes | off |
| PostToolUse | post-tool-use | Records tool & file events to history.db |
— | opt-in |
| PostToolUse | context-monitor | Estimates context usage; nudges a handoff near the limit | exit 2 | on |
| PostToolUse | issue-completion-log | Appends a session log entry to issues marked done |
— | on |
| PostToolUse | check-duplicate-issue-id-post | Deletes a just-written duplicate issue file (TOCTOU guard) | exit 2 | on |
| PostToolUse | issue-auto-commit | Auto-commits issue-file edits | — | off |
| PostToolUse | edit-batch-nudge | Nudges batching once per session after a run of consecutive unbatched single edits | exit 0 + additionalContext (Claude Code); exit 2 (other hosts) |
on |
| PostToolUse | session-capture | Appends structured event record (file/task/git/error) to .ll/ll-session-events.jsonl |
— | off |
| Stop | context-handoff-sentinel | Drops a sentinel if the session ended context-heavy | — | on |
| Stop | session-cleanup | Removes locks, state, scratch, orphaned worktrees | — | on |
| Stop | record-hook-event | Telemetry shim: records the session-cleanup fire to hook_events |
— | on |
| Stop | pre_done | Auto-consults the advisor on the working diff, deduped per distinct diff state | — | off |
| PreCompact | precompact | Snapshots task state before compaction (rubric-gated when hooks.pre_compact.rubric.enabled: true) |
exit 2 | on |
| PreCompact | precompact-handoff | Writes session continuation prompt before compaction (passive path for /ll:resume) |
— | on |
| SubagentStart | subagent-start | Records a subagent (Task/Agent) spawn in subagent_runs |
— | on |
| SubagentStop | subagent-stop | Closes out the matching subagent_runs row with ended_at/status |
— | on |
The rest of this guide walks each event in firing order.
A Session from Hook's Perspective¶
Here's what fires during a typical session:
You start a session
→ SessionStart: loads config + local overrides, injects project digest
→ SessionStart (stale refs): reports any open-issue references pointing at issues the previous session marked done
→ SessionStart (drift check): reports throttled mention/route doc-drift findings, at most once per hooks.doc_drift_throttle_days
→ SessionStart (scratch cleanup): prunes dead-PID scratch files
You submit a prompt
→ UserPromptSubmit: optimizes vague prompts; records skill calls and corrections
You use the Write or Edit tool
→ PreToolUse (duplicate-ID guard): blocks if the issue ID collides with an existing one
→ PreToolUse (learning-tests gate, if enabled): warns if import has no proven record
→ PreToolUse (private-refs guard): blocks if the write would commit a private-codebase reference
You run a Bash install command (pip install, npm install, etc.)
→ PostToolUse (install-nudge gate, if learning_tests.enabled): suggests /ll:explore-api for the new package
The tool finishes
→ PostToolUse (analytics, if enabled): records tool + file events to history.db
→ PostToolUse (context monitor): estimates context usage; nudges handoff near the limit
→ PostToolUse (issue completion log): if issue was just marked done, appends session log
A subagent is spawned (Agent/Task tool)
→ SubagentStart: records a running row in subagent_runs
→ SubagentStop: closes the row out with ended_at/status when the subagent finishes
Context window fills up (Claude Code fires PreCompact before compacting)
→ PreCompact (precompact.sh): snapshots task state to .ll/ll-precompact-state.json
→ PreCompact (precompact-handoff.sh): reads state snapshot for idempotency, writes .ll/ll-continue-prompt.md; use /ll:resume after compaction to pick up work
Session ends
→ Stop (cleanup): removes locks, temp files, orphaned worktrees
Safe by Default¶
The behaviors that write data or change your repo are off until you opt in:
analytics.enabled(recording tohistory.db) — off at runtime (the JSON schema listsdefault: truefor documentation/UI purposes, but the hook code'sfeature_enabled()helper treats an absent key asfalse, so a project with no explicitanalytics.enabledin.ll/ll-config.jsongets no analytics recording)scratch_pad.enabled(output redirection) — offlearning_tests.enabled(import gate) — offissues.auto_commit(auto-committing issue files) — offhooks.stale_ref_fix— defaults toreport(never edits files unless set toauto)drift-check— advisory-only, on by default; setLL_DOC_DRIFT_DISABLE(any non-empty value) to opt out entirely
The behaviors that are on by default are non-destructive: they load config, estimate context, inject advisory reminders, protect issue-ID uniqueness, and clean up this session's temp files on exit. None of them modify your source code.
Note on defaults vs. this repo. Defaults below are the fresh-install schema defaults. A given project's
.ll/ll-config.jsonmay tune them — for example, the little-loops repo itself enablesanalytics.enabledand lowerscontext_monitor.auto_handoff_thresholdto 50.
SessionStart¶
Hook: session-start.sh → little_loops.hooks.session_start.handle
Fires once when a Claude Code session begins. It:
- Loads
.ll/ll-config.json(or built-in defaults if absent). - Applies
.ll/ll.local.mdfrontmatter overrides — deep-merged (nested objects merge, arrays replace, explicitnullremoves a key). - Cleans up the previous session's
.ll/ll-context-state.json. - Injects the resolved config into the session as context.
- Optionally injects a 7-day project digest from
history.db(recently touched files, completed issues, recurring corrections) — this is the## Recently touchedblock you see at session start. - Spawns a one-shot background worker that backfills
history.dbfrom session JSONL logs.
Gated by: always loads config; the digest is gated by history.session_digest.enabled (default true), tunable via history.session_digest.days and history.session_digest.char_cap.
You see: the injected config/digest context, plus a [little-loops] Config loaded: <path> line on stderr. Never blocks.
Sweep stale cross-issue references¶
Hook: session-end.sh → little_loops.hooks.sweep_stale_refs.handle
Collects every status: done issue, then scans open issues for prose that still calls those IDs "open", "in_progress", "active", or "blocked by". Behavior is controlled by hooks.stale_ref_fix:
report(default) — lists findings on stderr, edits nothingauto— rewrites the stale phrasing in place (e.g. "is open" → "is done")
Runs once, at the start of each session — catching drift left over from the previous session's edits. Originally bound to SessionEnd, but Claude Code enforces a hard ~1.5s ceiling on SessionEnd hooks before killing them on any exit path (Ctrl+C, Ctrl+D, /exit), regardless of the configured timeout — an unfixed upstream bug (anthropics/claude-code#32712, #41577). The sweep's full-tree issue scan exceeds that ceiling on repos with a few thousand issue files, so it was being silently killed (printing Hook cancelled) on nearly every exit. Re-homed to SessionStart, where there's no forced-kill deadline, with the same detection value.
The dispatch intent stays session_end (host-agnostic) and the adapter file is still named session-end.sh — only the Claude Code hooks.json event binding changed. On Codex, session_end currently has no wired handler (ENH-2105, which would have mapped it onto Codex's Stop event, was cancelled without landing that wiring) — the stale-ref sweep does not run there today.
Every invocation also writes one best-effort stale_ref_sweep row to .ll/history.db's session_lifecycle_events table (findings count, fix mode) — including zero findings — so churn is queryable via ll-session recent --kind session_lifecycle (ENH-2495).
Doc-drift check¶
Hook: drift-check.sh → little_loops.hooks.drift_check.handle
Runs verify_documentation() (the same derived checker behind ll-verify-docs) and surfaces throttled mention/route doc-drift findings — count mismatches between documented and actual file counts — as advisory context at session start. Throttled to at most once every hooks.doc_drift_throttle_days (default 7) days per project. Opt out entirely with LL_DOC_DRIFT_DISABLE (any non-empty value). Never blocks.
Scratch-pad cleanup¶
Hook: scratch-cleanup.sh (pure bash)
Prunes stale files from .loops/tmp/scratch whose owning PID is no longer alive, leaving untouched any file a still-running concurrent session/ll-loop/ll-auto process owns. Only files matching the ${SAFE_NAME}-<pid>.txt shape produced by scratch-pad-redirect.sh are eligible for removal — user-typed scratch files (no -<pid> suffix) are preserved unconditionally (BUG-2525). Runs once at the start of each session — catching files left over from the previous session's exit. Originally bound to SessionEnd, but that event enforces a hard ~1.5s kill ceiling on any exit path (Ctrl+C, Ctrl+D, /exit) regardless of configured timeout (unfixed upstream: anthropics/claude-code#32712, #41577), which could cancel even this fast (~0.07s) hook and print a spurious "Hook cancelled" error (BUG-3363). Re-homed to SessionStart, where there's no forced-kill deadline — the PID-liveness guard already protects concurrently-active writers regardless of which event triggers the sweep. Always on.
UserPromptSubmit¶
Hook: user-prompt-check.sh → little_loops.hooks.user_prompt_submit.handle
Fires every time you submit a prompt. Two independent jobs:
Prompt optimization¶
If your prompt looks like it could be sharpened, the hook renders scripts/little_loops/hooks/prompts/optimize-prompt-hook.md (in-package since FEAT-2274) and injects it as additional context so the model first clarifies intent. It skips prompts that are very short (<10 chars), start with /, #, or ?, or begin with the bypass prefix.
To customize the template, place your own optimize-prompt-hook.md at ${CLAUDE_PLUGIN_ROOT}/hooks/prompts/optimize-prompt-hook.md. When CLAUDE_PLUGIN_ROOT is set and that file exists, it takes precedence over the in-package version — allowing per-installation overrides without modifying the package.
prompt_optimization.enabled(default true)prompt_optimization.mode—quick(default) orthorough(the latter can call theprompt-optimizeragent for codebase context)prompt_optimization.confirm(default true)prompt_optimization.bypass_prefix(default*) — prefix a prompt with*to send it through untouchedprompt_optimization.clarity_threshold(default6, range 1–10)
Toggle interactively with /ll:toggle-autoprompt.
Analytics recording¶
If analytics is enabled, it records user corrections (messages matching patterns like "no", "don't", "instead", "remember") and /ll:* skill invocations to history.db. Gated by analytics.enabled (schema default true, but effectively off at runtime unless set explicitly — see Safe by Default) and analytics.capture.corrections (default true).
Never blocks. Exit 0 always.
PreToolUse¶
Five hooks run before a tool executes. These are the only place little-loops can deny an action outright.
Duplicate issue-ID guard (blocks)¶
Hook: check-duplicate-issue-id.sh (pure bash)
On Write/Edit of a new .md file under issues.base_dir (default .issues), it checks whether the bare issue integer is already allocated under a different type (e.g. creating FEAT-007 when BUG-007 exists). If so it denies the write with:
[little-loops] Duplicate issue ID detected: ${ISSUE_ID} conflicts with ${EXISTING_BASENAME} — integer ${ISSUE_NUM} must be unique across all types (BUG/FEAT/ENH/EPIC). Use the next available ID.
It allows edits to existing files and anything outside .issues/. Always on; uses an advisory lock that fails open.
Decisions YAML guard (blocks)¶
Hook: check-decisions-yaml.sh (bash + Python)
On Write/Edit of either .ll/decisions.yaml or a .ll/decisions.d/*.json fragment (decisions storage is hybrid — a legacy flat file plus append-only per-entry fragments), stages the candidate content in a temporary config root and runs ll-verify-decisions (ENH-2589) against it. For Write, the candidate is tool_input.content; for Edit, it is the post-edit result reconstructed from old_string → new_string (with optional replace_all). Validating the candidate — not the current on-disk file — is what makes this belt effective: a Write that's about to corrupt the file passes against the still-valid existing file and slips past the validator otherwise.
Corruption (any yaml.YAMLError/KeyError/ValueError caught by ll-verify-decisions) emits the validator's single-line ERROR: on stderr and exits 2 (host-level block). Clean candidates exit 0 and let the host write through. Non-target paths and non-Write/Edit tools early-exit 0.
It complements the pre-commit hook (ENH-2590) and pytest CI gate (ENH-2591); only this host-layer belt fires for Claude-driven writes inside the session. Skips gracefully when python3 or ll-verify-decisions is missing.
Learning-tests discoverability gate (warn or block)¶
Hook: pre-tool-use.sh → little_loops.hooks.learning_tests_gate.gate
On Write/Edit, parses the file for import/require statements and checks each package against the Learning Test Registry. Behavior depends on mode:
learning_tests.enabled(default false — the whole gate is off unless you turn it on)learning_tests.discoverability.mode—off,warn(default), orblocklearning_tests.discoverability.skip_packages(default["std", "typing", "os", "sys"])
In warn mode it injects a hint and allows the write; in block mode it denies until a Learning Test exists.
Private-codebase reference guard (blocks)¶
Hook: check-private-refs.sh (pure bash, gates via ll-verify-private-refs)
On Write/Edit, rejects content that would commit a reference to a private
codebase — absolute paths into private checkouts, private project names, and the
other patterns ll-verify-private-refs recognises. Exits 2 on a hit, so the
write never lands.
This is the fifth PreToolUse hook and one of the guide's blocking gates: if a
Write is denied and the message mentions private references, this is why. Two
paths are exempt because they are expected to hold machine-local content and
are gitignored for exactly that reason — .ll/ll-continue-prompt.md and
.ll/private-refs.local.txt. The ignore rules and the gate exemptions are a
matched pair; exempting a file from the gate without also gitignoring it would
let a genuine leak reach a commit.
Scratch-pad redirection (redirects oversized Bash output)¶
Hook: scratch-pad-redirect.sh (pure bash)
Keeps large Bash output out of the conversation: it rewrites allowlisted commands to redirect output into .loops/tmp/scratch/ and shows only the tail. Bash output is uncapped, so this is where context bloat actually comes from. The wrapped command's exit status is preserved (BUG-2491) — a failing pytest/mypy still surfaces as a non-zero exit, with the inline tail summary shown only. The rewrite re-raises $? in an outer subshell at hooks/scripts/scratch-pad-redirect.sh:119 so the failure signal survives the redirection.
Read is not intercepted. Denying a Read would leave the Edit/Write "file has been read" precondition unsatisfied, edit-locking the file for the rest of the session (BUG-2357); and Read is already self-capping via offset/limit pagination, so there was nothing to gain. Use Read with offset/limit to page through large files.
scratch_pad.enabled(default false)scratch_pad.automation_contexts_only(default true) — by default only fires inside automation sessions (bypassPermissions), so it never interrupts interactive workscratch_pad.tail_lines(default20)scratch_pad.command_allowlist(default["cat", "pytest", "mypy", "ruff", "ls", "grep", "find"])
scratch_pad.threshold_linesandscratch_pad.file_extension_filtersare retained for config compatibility but no longer affect behavior (they only gated the removedReadinterception).
Install-nudge gate (warn, PostToolUse)¶
Hook: post-tool-use.sh → little_loops.hooks.post_tool_use.handle → little_loops.hooks.install_learning_gate.gate
After a Bash tool call that just installed a package (pip install, pip3 install, uv add, poetry add, npm install, yarn add, pnpm add — the full set matched by _INSTALL_RE in little_loops.hooks.install_learning_gate), this hook emits a one-line nudge suggesting you run /ll:explore-api to prove the newly-installed package's API assumptions before writing integration code:
[ll: new dependency] No learning test for "stripe". Consider: /ll:explore-api "stripe"
Never blocks (always advisory, exit 0). Gated by learning_tests.enabled (default false) — the nudge is silent unless learning tests are enabled. No additional configuration keys beyond the parent learning_tests block.
Note on lifecycle: although this hook reads from the Bash tool result, it is wired as PostToolUse (not PreToolUse) because the package manager must already have produced a side-effect before a meaningful "you just installed X" nudge can fire.
PostToolUse¶
Eight hooks run after each tool call.
Tool & file analytics¶
Hook: post-tool-use.sh → little_loops.hooks.post_tool_use.handle
Records a tool_events row (tool name, bytes in/out, cache-hit, args hash, agent_type — populated when tool_name=Task, NULL otherwise, ENH-2497) for every call, and a file_events row when a tool touches a file. Gated by analytics.enabled (default false); file capture is further gated by analytics.capture.file_events (default true). Writes to .ll/history.db. Never blocks. See the History & Session Guide.
Context monitor (exit 2 near the limit)¶
Hook: context-monitor.sh (pure bash + jq)
The hook most users notice. After each tool call it estimates how much context you've consumed — heuristically per tool, refined against authoritative token counts from the transcript when available — and tracks it in .ll/ll-context-state.json. When usage crosses the threshold it emits a rate-limited (once/60s) reminder via exit 2:
[ll] Context ~N% used (tokens/limit estimated). Run /ll:handoff to preserve your work…
It also resets its estimate after a compaction event.
The first threshold crossing per pressure episode also writes a best-effort handoff_needed row to .ll/history.db's session_lifecycle_events table (threshold percent, token count, context limit) via a python3 -c '...' || true shell-out that never affects this hook's exit code (ENH-2495).
Every sampled call (at most once per second per session — a new 50/75/80/90/100 pressure-level crossing always persists regardless of the cap) also writes a used_pct/used_tokens_est row to .ll/history.db's context_pressure_events table via a parallel python3 -c '...' || true shell-out, giving the continuous pressure signal a queryable home alongside the handoff_needed crossing events (ll-session recent --kind context_pressure, ll-ctx-stats's "Context pressure curve" section, ENH-2507). The emitted-levels set and last-write epoch live in .ll/ll-context-state.json and reset on compaction alongside threshold_crossed_at.
context_monitor.enabled(default true)context_monitor.auto_handoff_threshold(default80, range 50–95)context_monitor.context_limit_estimate(default0= auto-detect model limit;[1m]-suffixed model ids resolve to 1M by identifier, or the transcript baseline exceeding the resolved limit auto-upgrades to 1000000 as a fallback)context_monitor.estimate_weights.*andcontext_monitor.post_compaction_percent(default30) for fine-tuning
This pairs with Session Handoff.
Issue completion log¶
Hook: issue-completion-log.sh
On Write of an issue .md whose frontmatter is status: done, appends a session-log entry to the issue file for historical traceability, then fires ll-issues decisions extract-from-completed --issue <ID> --min-confidence 0.8 in a background subshell to mine the closed issue for generalizable rules and append them to the decisions log as .ll/decisions.d/*.json fragments (append-only; folded into .ll/decisions.yaml on compaction). The extraction runs asynchronously (fire-and-forget), so the hook exits immediately regardless of LLM latency. Always on; silent. Never blocks.
Duplicate issue-ID cleanup (TOCTOU race guard, exit 2)¶
TOCTOU — "time-of-check to time-of-use" — is the race window between checking a condition and acting on it.
Hook: check-duplicate-issue-id-post.sh (pure bash)
The belt-and-suspenders partner to the PreToolUse guard: after a Write lands, it re-checks for an ID collision and, if the just-written file is the duplicate, deletes it and returns exit 2:
[little-loops] Duplicate ID: ${FILENAME} conflicts with ${DUPLICATE_BASENAME} (integer ${ISSUE_NUM} already allocated). File removed — call ll-issues next-id again for a unique ID.
Always on. Closes the race window where two writes pick the same ID concurrently.
Issue auto-commit¶
Hook: issue-auto-commit.sh (pure bash)
If enabled, stages and commits issue-file changes (matching P[0-5]-(BUG|FEAT|ENH|EPIC)-NNN-*.md) with a conventional message. It skips the commit if you have other staged/unstaged changes, so it never sweeps unrelated work into a commit.
issues.auto_commit(default false)issues.auto_commit_prefix(defaultchore(issues))
Never blocks.
Edit-batch nudge¶
Hook: edit-batch-nudge.sh → little_loops.hooks.edit_batch_nudge.handle
After an Edit, Write, or MultiEdit call, injects a short reminder to batch
independent edits into a single turn (parallel Edit/Write calls, or
MultiEdit for one file) rather than one edit per turn — fewer round-trips means
less avoidable token cost re-reading the conversation prefix (a Tier 0 token-cost
quick-win from EPIC-2456). The channel is host-conditional (ENH-2994): on Claude
Code it returns exit_code=0 with a hookSpecificOutput.additionalContext JSON
payload on stdout, so the reminder reaches the model's context without the
"PostToolUse hook returned blocking error" banner that exit 2 triggers there.
Other hosts (Codex) keep the original exit_code=2 + feedback channel, since
they translate exit codes rather than reading this Claude Code-specific stdout
schema.
The nudge is stateful and once-per-session. Within a session, it fires
only once a run of consecutive unbatched single edits reaches the threshold
(default 3); after firing, a sticky nudged: true latch suppresses every
subsequent nudge for the lifetime of session_id — so it stays silent even if
the user reverts to unbatched edits later in the same session. A new session
re-arms the latch. Because PostToolUse carries no turn id, "unbatched" is
inferred from the wall-clock gap between hook fires: edits closer than
_BATCH_WINDOW_SECONDS (default 3s) are treated as one batched turn and reset
the run counter, while MultiEdit always resets it. The state file at
.ll/ll-edit-batch-state.json carries {session_id, run, last_ts, nudged};
nudged is false pre-fire and flips to true on first fire. All state I/O is
best-effort and degrades to a silent pass-through on failure. Fires for the
three edit tools only; all other tools pass through. On by default; the matcher
is host-agnostic and mirrored to Codex. (ENH-2503)
Session capture¶
Hook: session-capture.sh (pure bash)
Appends one structured record per tool call to .ll/ll-session-events.jsonl, the
event log precompact-handoff.sh prefers over git/loop reconstruction when
building a continuation prompt. Matcher is *, so it observes every tool, not
just edits. Never blocks.
session_capture.enabled(default false)
Record schema (FEAT-1262):
subject is the deduplication key across the whole log: file edits collapse to
the last event per subject, task state is the last event per subject, and an
error counts as unresolved only when the last event for its subject is still
type: error. That last rule is why a later successful event on the same subject
silently clears an earlier error — the log records net state, not history.
Stop¶
Four hooks run after each assistant turn (Claude Code's Stop event). All are advisory and must never fail.
Context handoff sentinel¶
Hook: context-handoff-sentinel.sh (pure bash)
If the session ended context-heavy (≥ ~50% estimated) and no handoff was completed, it writes a .ll/ll-context-handoff-needed sentinel so an automation worker (or your next session) knows to prompt for an explicit handoff. Gated by context_monitor.enabled. Silent.
Session cleanup¶
Hook: session-cleanup.sh (pure bash)
Removes this session's lock and context-state files, and prunes git worktrees under parallel.worktree_base (default .worktrees) on positive evidence of death only (ENH-3378): a worktree is deleted only when its ENH-3376 out-of-tree registry entry or an in-tree .ll-session-* marker names a pid that is now dead. A worktree with neither a registry entry nor a marker is left alone — it's for ll-parallel --cleanup-orphans (which also gets a process-cwd fallback, ENH-3377) to reap, not this hook. This positive-evidence rule is what closes the BUG-3373 failure mode, where the hook fires on every Stop event of every unrelated project-root session and previously deleted on absence of evidence.
The registry path is derived from each worktree's own path (<parent>/.registry/<worktree-name>), not from the WORKTREE_BASE config value, so it correctly covers automation.worktree_base sub-loop worktrees too — even though the git worktree list filter this hook uses still only sees worktrees whose path contains the configured base's basename. Note there is a separate automation.worktree_base key (also default .worktrees) used by ll-auto and FSM sub-loop worktrees (Config.get_worktree_base()); this Stop-hook cleanup reads parallel.worktree_base for the base-directory scan, not automation.worktree_base.
Accepted residual: if the owning ll-loop/ll-parallel process is SIGKILLed but a dispatched host-CLI child survives with its cwd inside the worktree, this hook still sees a dead registry/marker pid and deletes under the live child — bash has no cwd sweep, so only the Python ll-parallel --cleanup-orphans path (ENH-3377) closes this gap.
(Scratch cleanup now lives in scratch-cleanup.sh on SessionStart — see Scratch-pad cleanup, BUG-2420/BUG-3363.) Always on.
Hook-event telemetry shim¶
Hook: record-hook-event.sh <EventName> <shadowed-script> (pure bash)
Registered on Stop only — the one event whose handler is pure bash and
therefore never passes through main_hooks(), where every Python-dispatched
intent is already wrapped by hook_event_context(). The shim takes the event
name and the script it shadows as arguments, and records that fire's outcome
to the hook_events table so bash-only hooks are not a blind spot in hook
telemetry. (SessionEnd has no registered hooks — scratch-cleanup.sh, its
only handler, moved to SessionStart in BUG-3363 — so there is no longer a
second record-hook-event.sh pairing.)
Gated by analytics.enabled and analytics.capture.hooks (both default
true, but analytics.enabled is off unless you turn it on). Records only —
never blocks, never alters the shadowed hook's behavior.
PreDone advisor consult¶
Hook: stop.sh → little_loops.hooks.pre_done.handle (FEAT-3118)
Opt-in: fires only when advisor.enabled: true and pre_done is listed
in advisor.triggers. Because Stop fires after every turn rather than at
task completion, the handler dedups on a SHA-256 of the capped working diff
(git diff HEAD + git status --porcelain, capped at 400 lines / 96,000
bytes) — it consults once per distinct diff state, not once per turn. A
consult whose result actually reached the advisor host records the new hash
to .ll/advisor-budget/<kind>-<value>.pre_done.json; a skipped or failed
consult never poisons the dedup.
An empty diff, a non-git-work-tree root, or advisor.timeout_seconds > 190
(the hook's own timeout margin) all short-circuit to a silent no-op. v1 is
advisory only: a successful verdict is surfaced via exit 0 + feedback
(the recommendation, confidence, risks, and dissent, printed to stderr) —
never via blocking (exit_code=2). A failed or timed-out consult logs a
warning and never blocks the turn. Bounded by advisor.max_consults_per_task
like every other advisor trigger.
PreCompact¶
Hook: precompact.sh → little_loops.hooks.pre_compact.handle
Fires just before Claude Code compacts the conversation. It snapshots task state to .ll/ll-precompact-state.json — timestamp, transcript path, a context-state snapshot, and up to 5 recently-touched plan files — then signals via exit 2:
[ll] Task state preserved before context compaction. Check .ll/ll-precompact-state.json if resuming work.
This is what lets a post-compaction context pick up mid-task. Always on; writes are atomic with a fail-open advisory lock. See Session Handoff.
A successful state write also emits a best-effort compaction row to .ll/history.db's session_lifecycle_events table. pre_compact_handoff (the companion continuation-prompt hook) does NOT emit a second row — this is the single authoritative producer for the compaction discriminator (ENH-2495).
Rubric-gated timing (opt-in): When hooks.pre_compact.rubric.enabled: true, the hook first evaluates four structural conditions over the recent transcript before writing state. If any condition fails, the hook returns exit 0 without writing state — the compaction still happens but without a continuation snapshot. Enable with:
The four conditions (each requires evidence in the transcript; absence → defer):
1. closed_unit — reasoning unit has a definite resolution (default signals: done, completed, fixed, resolved)
2. reducible — content can be summarised to a few facts (default signals: in summary, to summarize, overall)
3. progress — something changed since the last compact (default signals: changed, updated, modified, implemented)
4. not_stuck — no stuck-loop signals detected (default signals: same error, still failing, repeat)
Signal lists are configurable via hooks.pre_compact.rubric.signals.*. Disabled by default; when disabled (or on any transcript-read error), falls back to the original threshold-only behaviour.
Hook: precompact-handoff.sh → little_loops.hooks.pre_compact_handoff.handle
Fires as a second PreCompact handler, after precompact.sh. Reads .ll/ll-precompact-state.json as an idempotency guard — it skips only when .ll/ll-continue-prompt.md is already newer than the snapshot's compacted_at. A missing, unreadable, or unparseable snapshot means freshness cannot be verified, so the hook proceeds with the write rather than skipping. It then writes .ll/ll-continue-prompt.md atomically with a 3s advisory lock, returning exit 2:
[ll] Session continuation prompt written to .ll/ll-continue-prompt.md
Two content paths: when .ll/ll-session-events.jsonl is present (written by the session-capture.sh PostToolUse hook when session_capture.enabled: true), the handler builds the continuation prompt from structured event data — deduplicating file edits by subject and surfacing only unresolved errors. When the event log is absent, it falls back to a git-diff/loop-state snapshot. Enable session_capture.enabled to get richer, event-structured continuation prompts; the fallback still produces a usable prompt without it.
Use /ll:resume after compaction to re-inject the continuation prompt. Always on; passive counterpart to the active /ll:handoff command. See Session Handoff.
SubagentStart / SubagentStop¶
Hooks: subagent-start.sh → little_loops.hooks.subagent_start.handle, subagent-stop.sh → little_loops.hooks.subagent_stop.handle
Fire when the Agent/Task tool spawns a subagent (SubagentStart) and when that subagent finishes (SubagentStop), recording the spawn tree into .ll/history.db's subagent_runs table so it's queryable without re-parsing JSONL transcripts (ENH-2505).
SubagentStart writes a running row keyed on (parent_session_id, agent_id) — agent_id is a spawn-local identifier (e.g. "agent-abc123") scoped to its parent session, not a top-level sessions.session_id. SubagentStop updates that row's ended_at, status (completed), and agent_transcript_path (the nested transcript, <parent-transcript-dir>/subagents/agent-<id>.jsonl). Both are best-effort: a missing/malformed payload is a silent no-op, never a blocking failure. Always on; exits 0 (never blocks, never surfaces feedback).
Query the tree with ll-session recent --kind subagent_run, or via history_reader.subagent_tree(session_id) / subagent_retries(agent_type) / subagent_budget(session_id) for the "which parent burns the most subagent budget" / "which agent type oscillates" questions. See HISTORY_SESSION_GUIDE.md.
A third status mutation, orphaned, is applied by reconcile_stale_subagent_runs() (ENH-3210) from the session_end-intent sweep (sweep_stale_refs.py, see below) for running rows whose SubagentStop never fired.
Turning Hooks Off¶
Most behavior is controlled by config keys in .ll/ll-config.json (or your gitignored .ll/ll.local.md for local-only overrides). Set the gate to false:
{
"prompt_optimization": { "enabled": false },
"context_monitor": { "enabled": false },
"history": { "session_digest": { "enabled": false } }
}
To fully unregister a hook (including the always-on issue-ID guards and session cleanup), edit hooks/hooks.json and remove its entry — but note the issue-ID guards and cleanup exist to prevent real corruption, so prefer leaving them on.
A few quick controls:
/ll:toggle-autoprompt— flip prompt optimization without editing config/ll:configure— interactive config editor- Prefix any prompt with
*— bypass prompt optimization for that message only
Configuration Reference¶
| Config key | Hook | Default | Effect |
|---|---|---|---|
history.session_digest.enabled |
SessionStart | true |
Inject 7-day project digest |
prompt_optimization.enabled |
UserPromptSubmit | true |
Optimize vague prompts |
prompt_optimization.mode |
UserPromptSubmit | quick |
quick or thorough |
prompt_optimization.bypass_prefix |
UserPromptSubmit | * |
Per-prompt bypass char |
analytics.enabled |
UserPromptSubmit, PostToolUse | false |
Record events to history.db |
analytics.capture.corrections |
UserPromptSubmit | true |
Record user corrections (needs analytics.enabled) |
analytics.capture.file_events |
PostToolUse | true |
Record file events (needs analytics.enabled) |
learning_tests.enabled |
PreToolUse | false |
Enable the import gate |
learning_tests.discoverability.mode |
PreToolUse | warn |
off / warn / block |
hooks.pre_compact.rubric.enabled |
PreCompact | false |
Enable rubric-gated compaction timing |
hooks.pre_compact.rubric.hard_ceiling_pct |
PreCompact | 0.95 |
Reserved: hard ceiling fraction (not yet enforced) |
scratch_pad.enabled |
PreToolUse | false |
Redirect oversized Bash output |
scratch_pad.automation_contexts_only |
PreToolUse | true |
Only fire in automation |
context_monitor.enabled |
PostToolUse, Stop | true |
Track context usage |
context_monitor.auto_handoff_threshold |
PostToolUse | 80 |
% at which to nudge handoff |
issues.auto_commit |
PostToolUse | false |
Auto-commit issue files |
session_capture.enabled |
PostToolUse | false |
Append per-tool structured event records to .ll/ll-session-events.jsonl |
issues.base_dir |
(all issue hooks) | .issues |
Issue directory |
hooks.stale_ref_fix |
SessionStart | report |
report or auto |
hooks.doc_drift_throttle_days |
SessionStart (drift-check) | 7 |
Minimum days between doc-drift checks per project; LL_DOC_DRIFT_DISABLE opts out entirely |
parallel.worktree_base |
Stop | .worktrees |
Worktree cleanup scope (distinct from automation.worktree_base, which ll-auto/FSM sub-loops use and this hook does not read) |
advisor.enabled |
Stop (pre_done) | false |
Master switch for advisor auto-consults |
advisor.triggers |
Stop (pre_done) | [] |
Must include "pre_done" for the Stop hook to fire |
Full schema and substitution rules: Configuration Reference.
Common Combinations¶
| Combination | Interaction |
|---|---|
analytics.enabled + history.session_digest.enabled |
Complementary — analytics writes the data; digest reads it back at session start. Enable both for the full historical context experience. |
scratch_pad.enabled + learning_tests.enabled |
Compatible — scratch pad affects output size; learning tests affect import gates. No interaction. |
scratch_pad.enabled + scratch_pad.automation_contexts_only: true |
Safe default — scratch pad only fires inside automation sessions (bypassPermissions), never during interactive work. |
issues.auto_commit + manual /ll:commit |
Redundant — auto_commit will commit issue files automatically after each change; /ll:commit is for source code. Keep auto_commit: false if you want to review all changes in one commit. |
See Also¶
- Configuration Reference — every config key and its schema
- Session Handoff — the context-monitor / precompact / sentinel story end to end
- History & Session Guide — what the analytics hooks record and how to query it
- Learning Tests Guide — the registry behind the PreToolUse import gate
- Write a Hook — author your own
LLHookIntentExtension - Event Schema Reference —
LLHookEvent/LLHookResultwire format