Hooks
“Hooks” here means Claude Code’s hook mechanism: scripts the assistant invokes on events like SessionStart and Stop. ai-knowledge-base consumes them; it does not expose a hook API of its own for third-party extension.
init registers three hook scripts in .claude/settings.json.
| Script | Event(s) | Mode |
|---|---|---|
kb-capture.mjs | Stop, SessionEnd, PreCompact | sync, ≤1s |
kb-proposal-drain.mjs | SessionStart | async |
kb-session-start.mjs | SessionStart | sync, ≤1s |
The two SessionStart entries are independent; a failure in one doesn’t block the other.
Recursion guard
All three hooks exit immediately if KB_BUILDER_INTERNAL=1 is set. The extractor, curator, and bootstrap-incremental set this on every claude -p child so spawned sessions don’t trigger our hooks recursively. If you wrap the claude CLI, propagate KB_BUILDER_INTERNAL=1 only into intentionally-internal subprocesses.
kb-capture.mjs (capture)
- Read hook input from stdin.
- Validate
session_idviaassertValidSessionId(strict UUID v4 shape). On bad input, throw with a named error message; the catch handler writes it to stderr. - Parse the transcript (
user/assistanttext, role-tagged). - Run secretlint (with the recommended preset); replace findings with
[REDACTED:<ruleId>]. If secretlint fails to load or times out, capture aborts. - Write
_sessions/<YYYYMMDD-HHmm-<sessionId>>.mdwith frontmatter and the redacted slice. A re-fire for the samesession_id(multi-turn sessions, PreCompact after Stop) reuses the existing file viafindSessionLogBySessionId, so the session-log count stays at one per session.
The only difference between the three triggers is the captured_by field (stop, session_end, pre_compact).
Never invokes the LLM. 1s deadline. A missed deadline exits silently; the next trigger retries.
Capture failure modes
| Condition | Outcome |
|---|---|
KB_BUILDER_INTERNAL=1 | Exit. No capture. |
| Empty / malformed stdin | Exit silently. |
session_id not a UUID v4 | Write the error to stderr; no session log. |
transcript_path missing | Exit silently. |
| Transcript empty | Exit silently. |
| Secretlint fails to load or crashes | Log to stderr, no session log written. |
| 1s deadline exceeded | Exit silently; next trigger retries. |
kb-proposal-drain.mjs (extraction)
Per SessionStart:
- Recursion guard.
- Acquire the
proposal-drainlock (PID + 30-min TTL). Stale locks reclaimed. - Load the prompt (local override first, bundled fallback).
- Sweep
_sessions/*.mdfor frontmatter withproposal_status: pendingand process each entry. - Per pending log: spawn
claude -p --output-format stream-json --verbose, stream to_logs/proposal/<session-id>__<ts>.jsonl, parse the finalresult, validate againstProposalOutputSchema. - On success: update frontmatter with
proposal_status: done, populatedproposals.{practice,map}, dedupedtopics. - On failure: write
proposal_status: failedwithproposal_error. The failure modes here (timeout, schema mismatch, bad JSON) do not heal on retry, so the drain does not rotate them.
kb-session-start.mjs (consume)
- Recursion guard.
- Load
INDEX.md. If missing, emit “The knowledge base is empty.”. - Compare frontmatter
nodes_hashagainst the live hash ofnodes/. On drift, append> KB index is stale, run \npx @e0ipso/ai-knowledge-base index rebuild``. - Count pending logs. If above
curationThresholdAND last nudge was over an hour ago, append a nudge and writelast_nudged_at. -
Emit:
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"..."}}
1s hard deadline. Overrun exits 0 so session startup isn’t blocked.
Registration shape
After init, .claude/settings.json carries one block per event:
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "node .claude/hooks/kb-capture.mjs" }] }
],
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "node .claude/hooks/kb-proposal-drain.mjs", "async": true }] },
{ "hooks": [{ "type": "command", "command": "node .claude/hooks/kb-session-start.mjs" }] }
]
}
}
User-defined hooks in the same file are preserved on re-init.