Hooks
What open-second-brain runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add itechmeat/open-second-brain > /plugin install open-second-brain@open-second-brain
Ships with open-second-brain. Installing the plugin gets these hooks.
What fires, and when
PostToolUse
- Matches
brain_feedbackr="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0 - Matches
Write|Edit|MultiEdit|apply_patchr="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" post-write-reminder; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook post-write-reminder; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0 - Matches
*r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
- Matches
startup|resume|clear|compactr="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-agenda; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-agenda; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
- Matches
*r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" recall-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook recall-inject; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" nav-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook nav-inject; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
PreToolUse
- Matches
*r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" pretool-orient; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-orient; exit 0
Stop
- Matches
*r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" stop-log-guardrail; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook stop-log-guardrail; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" answer-quote-check; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook answer-quote-check; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" hygiene-digest; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook hygiene-digest; exit 0
SubagentStart
- Matches
*r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0
SubagentStop
- Matches
*r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
SessionEnd
- Matches
*r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-promote; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-promote; exit 0
PostCompact
- Matches
manual|autor="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
Where it lives
- hooks/active-inject.tsGitHub
Read the script
#!/usr/bin/env -S bun /** * SessionStart / PostCompact hook: inject the current `Brain/active.md` * digest as `additionalContext` so the agent sees the live set of * confirmed and quarantined preferences without explicitly calling * `brain_query` first. * * Three lanes share the surface, and the order between them is the point. * `Brain/standing-rules.md` is operator-authored and goes FIRST, read * outside the fail-open memory load so it survives a memory-layer * failure and never enters the inject cache. The scoped operator rules * for this session's project and this device * (`Brain/standing-rules/{project,host}/<key>.md`) come next, also read * outside the boundary and never cached - the cache key is vault-wide, so * a cached block could replay one project's rules in another - but * charged against the injection budget. Everything after them - runtime * notices, `active.md`, `lessons.md` - is what the agent learned about * itself, assembled inside the fail-open boundary and charged against * what is left of the configured injection budget. * * Contract (identical for Claude Code and Codex): * stdin: hook payload JSON. The vault path is resolved from the * persisted Open Second Brain config (env `VAULT_DIR` → config * `vault:` field), not from the payload — both runtimes route the * hook through the same `o2b-hook` PATH-shim, so this stays * runtime-agnostic. * stdout: JSON of the shape * { * "hookSpecificOutput": { * "hookEventName": "SessionStart" | "PostCompact", * "additionalContext": "<standing-rules block, the scoped rules * block, then the budgeted memory context: * runtime notices, the rendered * Brain/active.md body, lessons>" * } * } * * Quiet on the failure modes that leave it with nothing to say (no config, * no vault, malformed payload): the hook exits 0 with no output and the * runtime proceeds as if it never ran. A SessionStart that silently fails * is far less harmful than one that aborts the session with a stderr * trace. The agent simply does not get the per-session preferences nudge — * exactly the v0.9.0 behaviour. * * A missing `Brain/active.md` is NOT one of those modes any more, and * neither is a memory layer that threw. The standing-rules lane is read * outside the fail-open boundary, so a vault whose operator wrote rules * still emits them with an empty memory context behind them; a rules file * that exists and cannot be read emits the block that says so. The empty * exit is reached only when there are NEITHER standing rules NOR memory, * and `tests/hooks/active-inject.test.ts` asserts it. */ import { resolveRecallInjectEnabled, resolveRegroundPartChars, resolveRegroundPartsEnabled, resolveVault, } from "../src/core/config.ts"; import { healCliSymlinks } from "../src/cli/install-cli.ts"; import { ensureVaultCurrent } from "../src/core/maintenance/ensure-current.ts"; import { hookAuditDir } from "../src/core/brain/paths.ts"; import { loadInjectContextFailOpen } from "../src/core/brain/inject-failopen.ts"; import { armProcessCeiling, resolveHookCeilingMs } from "./lib/process-ceiling.ts"; import { appendAuditRecord } from "../src/core/reliability/audit.ts"; import { asHookPayload, readHookInput, type HookPayloadBase } from "./lib/stdin.ts"; import { beginInjectionEpoch, digestNotePaths, isRealSessionId } from "./lib/injection-ledger.ts"; import { LANE_BUDGETED, renderStandingBlock, type InjectionMeter } from "./lib/standing-block.ts"; import { assembleActiveContext, BLOCK_SEPARATOR, createInjectionMeter, joinBlocks, MEMORY_SOURCES, renderScopedBlock, resolveInjectionLimits, scopedSectionCap, SOURCE_ACTIVE_BODY, SOURCE_LESSONS_BODY, } from "./lib/active-context.ts"; import { pruneHookStateFiles } from "./lib/session-state.ts"; import { detectHookRuntime } from "./lib/detect.ts"; import { splitRegroundParts } from "../src/core/brain/reground-parts.ts"; import { isContextEventName } from "./lib/context-events.ts"; import { emitContextReceipt } from "../src/core/brain/context-receipts.ts"; import { estimateTokens } from "../src/core/brain/text/tokenizer.ts"; import type { InjectContextSource } from "../src/core/brain/inject-failopen.ts"; /** * Best-effort hook audit line. Never throws: a failure to record must never * disturb the fail-soft hook contract, and a hung filesystem is exactly when * this runs, so it is wrapped defensively. */ function auditHook(vault: string | null, action: string, details: Record<string, unknown>): void { if (vault === null) return; try { appendAuditRecord(hookAuditDir(vault), { timestamp: new Date().toISOString(), actor: "active-inject", action, target: "SessionStart", ok: false, details, }); } catch { // best-effort } } async function main(): Promise<void> { // Arm the process self-watchdog first so a hang anywhere below (vault // resolution, a stalled read, an overrunning assembly) still self-terminates // at the ceiling with a clean exit instead of orphaning the hook process. let auditVault: string | null = null; const disarm = armProcessCeiling({ ceilingMs: resolveHookCeilingMs(), onExpire: () => auditHook(auditVault, "hook_ceiling_exceeded", { hook: "active-inject" }), }); try { let payload; try { payload = asHookPayload(await readHookInput()); } catch { return; } // The hook is registered separately for each event; the payload's // `hook_event_name` tells us which one fired. Default to // `SessionStart` only when the field is missing entirely (e.g. an // empty stdin payload, or a runtime that doesn't populate the name). const hookEventName = typeof payload.hook_event_name === "string" && payload.hook_event_name.length > 0 ? payload.hook_event_name : "Session - hooks/answer-quote-check.tsGitHub
Read the script
#!/usr/bin/env -S bun /** * Stop hook: answer-time quote verification (answer-trust wave, Lane A). * * At turn end this step reads the LAST assistant messages of the turn, * finds their quoted spans with the distill lane's own * `findQuoteSpans`, and checks each span against the evidence the turn * actually had: * * - the note the span cites - a wikilink target or a vault-relative * path in the message text, resolved against the vault; and * - otherwise the notes the injection ledger says entered context this * session, across the four phase keys (recall keys, the active * digest's emitted paths plus its queued re-delivery parts, and the * note paths the sub-agent carrier recorded with its deliveries). * * Each span gets one verdict from * `src/core/brain/answer-quotes/check.ts` - `verified`, `not-in-note`, * `source-truncated`, `note-unavailable` or `not-text` - and the * unverified ones are listed in exactly ONE * `hookSpecificOutput.additionalContext` annotation (the * `hooks/stop-log-guardrail.ts` precedent). The hook NEVER blocks and * NEVER edits the transcript: a verdict is reported, not enforced. * * Phases. A phase runs only when BOTH hold: the phase's injection is * enabled (recall requires `recall_inject_enabled`; active and subagent * require a live injection ledger for the session) and its * `quote_check.<phase>` resolver returns more than `off`. * `shadow` annotates only; `enforce` additionally appends exactly one * Brain-log event per turn with the unverified spans, so findings survive * the session - and never blocks. * * Channel. The annotation rides `hookSpecificOutput.additionalContext` * on the Stop event, which Claude Code renders into the turn. Codex and * grok accept the registration but render no Stop annotation - and grok * ships no `transcript_path` at all - so the check is silently inert on * them: fail-open silence, kept deliberately, with the `enforce` * Brain-log trail as the one finding surface that survives the session. * * Bounded, fail-open. The step reads at most * {@link ANSWER_QUOTE_TAIL_BYTES} of the transcript, the last * {@link ANSWER_QUOTE_MAX_MESSAGES} assistant messages of the turn, * {@link ANSWER_QUOTE_MAX_CHARS} characters of each, and gives up by * checking nothing further once {@link ANSWER_QUOTE_BUDGET_MS} have * passed - unchecked spans are dropped rather than run long. Every error * path exits 0 with exactly one stderr audit line, producing nothing. * Fires at most once per turn: when the runtime reports * `stop_hook_active === true` (this turn was already continued by a Stop * hook) the hook exits 0 silently. */ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, } from "node:fs"; import { resolveAnswerQuoteCheckActive, resolveAnswerQuoteCheckRecall, resolveAnswerQuoteCheckSubagent, resolveAgentName, resolveRecallInjectEnabled, resolveVault, type AnswerQuoteCheckMode, type AnswerQuoteCheckPhase, ANSWER_QUOTE_CHECK_PHASES, } from "../src/core/config.ts"; import { ANSWER_QUOTE_VERDICT, checkAnswerQuotes, mergeAnswerQuoteVerdicts, previewAnswerQuoteSpan, type AnswerQuoteEvidence, type AnswerQuoteVerdict, } from "../src/core/brain/answer-quotes/check.ts"; import { findQuoteSpans } from "../src/core/brain/distill/quote-spans.ts"; import { brainConfigPath } from "../src/core/brain/paths.ts"; import { appendLogEvent } from "../src/core/brain/log.ts"; import { BRAIN_LOG_EVENT_KIND } from "../src/core/brain/types.ts"; import { isoSecond } from "../src/core/brain/time.ts"; import { ensureInsideVault } from "../src/core/path-safety.ts"; import { asHookPayload, readHookInput } from "./lib/stdin.ts"; import { isRealSessionId, LEDGER_KEY_REGROUND, readActiveEmittedPaths, readRecallInjected, readSubagentDeliveredPaths, } from "./lib/injection-ledger.ts"; import { readHookStamp } from "./lib/session-state.ts"; /** * The last assistant messages of the turn the check reads. Everything * older is another turn's answer. */ export const ANSWER_QUOTE_MAX_MESSAGES = 6; /** Per-message character budget: the prefix of a message that is checked. */ export const ANSWER_QUOTE_MAX_CHARS = 4000; /** Wall-clock budget for the whole check; past it, unchecked spans are dropped. */ export const ANSWER_QUOTE_BUDGET_MS = 1500; /** * Ceiling on findings rendered in one annotation - and in one enforce * event: past it the tail of the list collapses into one "and N more" * line, so a pathological answer annotates its first spans instead of * flooding the context channel. */ export const ANSWER_QUOTE_MAX_FINDINGS = 20; /** * Byte window of the bounded transcript tail read. Small enough that a * huge transcript cannot slow the Stop path, large enough to hold the * current turn's answers; the reader drops the one line the window may * have cut in half. */ export const ANSWER_QUOTE_TAIL_BYTES = 262_144; /** * Ceiling on how much of one cited note the check reads, on the 1 MiB * scale of the ingest archive ceiling. Past it only the head of the file * is read and the evidence is named truncated - a miss against it is * `source-truncated`, never `not-in-note` - and a pointer that names a * line range is refused as `unavailable` rather than served a slice of * the prefix as if it were the named lines. */ export const ANSWER_QUOTE_NOTE_MAX_BYTES = 1_048_576; /** Stderr audit prefix: every error arm prints exactly one line with it. */ const AUDIT_PREFIX = "answer-quote-check:"; /** One quoted span to check: its text and where it sits in its message. */ interface QuotedSpan { readonly message: number; readonly inner: string; readonly open: number; readonly close: number; } /** One citation mark found in a message: a note reference and its offset. */ interface CitationMark { readonly at: number; /** Vault-relative note path, `.md`-complete. */ readonly path: string; /** 1-based inclusiv - hooks/gap-agenda.tsGitHub
Read the script
#!/usr/bin/env -S bun /** * SessionStart hook: opt-in knowledge-gap agenda (theme A, t_67d38036). * * When `gap_loop_enabled` is set (default OFF), renders the open recall-gap * task notes as a compact agenda and injects it as `additionalContext` so * the agent starts the session aware of the recurring gaps still unresolved. * The agenda is rendered through the shared session-start activity helper. * * Flag off is an immediate no-op with zero output, keeping the session * preamble byte-identical. Fail-open: the hook arms the shared process * ceiling and exits 0 on every path. */ import { resolveGapLoopEnabled, resolveVault } from "../src/core/config.ts"; import { renderGapAgenda } from "../src/core/brain/gaps/gap-loop.ts"; import { armProcessCeiling, resolveHookCeilingMs } from "./lib/process-ceiling.ts"; import { asHookPayload, readHookInput } from "./lib/stdin.ts"; import { isContextEventName } from "./lib/context-events.ts"; async function main(): Promise<void> { // Fast opt-out FIRST: default OFF means an immediate no-op, no output. if (!resolveGapLoopEnabled()) return; const disarm = armProcessCeiling({ ceilingMs: resolveHookCeilingMs() }); try { let payload; try { payload = asHookPayload(await readHookInput()); } catch { return; } const hookEventName = typeof payload.hook_event_name === "string" && payload.hook_event_name.length > 0 ? payload.hook_event_name : "SessionStart"; // Default-closed: only an additionalContext-eligible event may emit. if (!isContextEventName(hookEventName)) return; const vault = resolveVault(); if (vault === null) return; const agenda = renderGapAgenda(vault, new Date()); if (agenda.length === 0) return; const out = { hookSpecificOutput: { hookEventName, additionalContext: agenda, }, }; process.stdout.write(JSON.stringify(out) + "\n"); } finally { disarm(); } } main().catch(() => { // Never crash the runtime; the session start must proceed regardless. }); - hooks/gap-promote.tsGitHub
Read the script
#!/usr/bin/env -S bun /** * SessionEnd hook: opt-in knowledge-gap promotion and auto-close (theme A, * t_67d38036). * * When `gap_loop_enabled` is set (default OFF), at session end: * 1. every recurring recall gap (from the recall-telemetry gap_counts * aggregate, above a tunable threshold) promotes to ONE durable * gap-task note under the Brain area, deduped on a stable gap key so * re-promotion never collides; * 2. every open gap task whose topic now recalls with sufficient * confidence auto-closes (a recorded status flip in frontmatter), * mirroring the dream freshness auto-resolve precedent. * * Gap-task notes are plain durable files; they never touch the Hermes * kanban board. Flag off writes nothing (byte-identical no-op). Fail-open: * the hook arms the shared process ceiling and exits 0 on every path; a * best-effort audit line records the run's counts, and a run that threw * records a `gap_loop_failed` line plus a stderr notice instead of * vanishing. SessionEnd cannot carry additionalContext, so this hook never * writes stdout. */ import { defaultConfigPath, resolveGapLoopEnabled, resolveGapLoopThreshold, resolveVault, } from "../src/core/config.ts"; import { appendAuditRecord } from "../src/core/reliability/audit.ts"; import { hookAuditDir } from "../src/core/brain/paths.ts"; import { gapScopedRecallRetriever } from "../src/core/brain/gaps/gap-recall.ts"; import { autoCloseRecalledGaps, promoteGapsToTasks } from "../src/core/brain/gaps/gap-loop.ts"; import { armProcessCeiling, resolveHookCeilingMs } from "./lib/process-ceiling.ts"; import { asHookPayload, readHookInput } from "./lib/stdin.ts"; /** * The vault this run resolved, remembered for the failure path. * * A failure is only auditable once the vault is known, and the vault is * resolved inside the run - so the failure reporter reads it from here * rather than re-resolving (which could itself be the thing that threw). */ let auditedVault: string | null = null; function auditRun(vault: string, details: Record<string, unknown>): void { try { appendAuditRecord(hookAuditDir(vault), { timestamp: new Date().toISOString(), actor: "gap-promote", action: "gap_loop_run", target: "SessionEnd", ok: true, details, }); } catch { // best-effort: auditing must never disturb the fail-open contract } } /** * Report a run that threw, on both channels, and never rethrow. * * Fail-open is about the SESSION, not about the fault: swallowing the * throw silently is what let a retriever that ignored its membership rule * leave this hook doing nothing at all, run after run, with no trace * anywhere - a fallback that quietly does nothing, which this repository * does not allow. So the failure lands as its OWN audit action (`ok: * false`), never as a `gap_loop_run` row: a run that threw produced no * counts, and a counts row with zeros would read as a quiet, healthy * no-op - exactly the report this fault already got away with. Stderr * carries it too, for the operator who is not reading the audit log. * * Every step is individually guarded, because this is the last frame * before the process exits and there is nothing left to catch it. */ function reportRunFailure(exc: unknown): void { const kind = exc instanceof Error ? exc.name : typeof exc; const message = exc instanceof Error ? exc.message : ""; try { process.stderr.write(`gap-promote: gap loop failed [${kind}] ${message}\n`); } catch { // a closed stderr must not become the crash the fail-open forbids } if (auditedVault === null) return; try { appendAuditRecord(hookAuditDir(auditedVault), { timestamp: new Date().toISOString(), actor: "gap-promote", action: "gap_loop_failed", target: "SessionEnd", ok: false, details: { error: kind, message }, }); } catch { // best-effort: auditing must never disturb the fail-open contract } } async function main(): Promise<void> { // Fast opt-out FIRST: default OFF means an immediate no-op, no writes. if (!resolveGapLoopEnabled()) return; const disarm = armProcessCeiling({ ceilingMs: resolveHookCeilingMs() }); try { try { asHookPayload(await readHookInput()); } catch { return; } const vault = resolveVault(); if (vault === null) return; auditedVault = vault; const configPath = defaultConfigPath(); const now = new Date(); const threshold = resolveGapLoopThreshold(configPath); const promotion = promoteGapsToTasks(vault, { now, ...(threshold !== undefined ? { threshold } : {}), }); const closure = await autoCloseRecalledGaps( vault, // NOT `defaultRecallRetriever`: that one takes only the query, and a // one-parameter function is structurally assignable here, so it // compiled while silently ignoring the membership rule - returning // the gap-task note itself and making the gate throw on every run. gapScopedRecallRetriever(configPath, vault), { now }, ); auditRun(vault, { promoted: promotion.created.length, skipped: promotion.skipped.length, // Both bounds the run enforces are reported, never dropped: a pruned // count explains a shrinking directory, and a non-zero cap refusal is // the operator's only signal that gap tasks are being turned away. pruned: promotion.pruned.length, capped: promotion.capped, closed: closure.closed.length, kept: closure.kept.length, // Why a recall narrowed (a gated query embed, a missing semantic // lane) rides this local line only, present only when non-empty. ...(closure.retrievalDegraded !== undefined ? { retrieval_degraded: closure.retrievalDegraded } : {}), }); } finally { disarm(); } } main().catch((exc: unknown) => { // Never crash the runtime; the session end must proceed regardless - // - hooks/hygiene-digest.tsGitHub
Read the script
#!/usr/bin/env -S bun /** * Stop hook: surfaces pending Brain hygiene findings as ONE line, * once per change, behind the opt-in `hygiene_digest_enabled` flag * (context-injection-pipeline, lane B, task B3). * * The line folds the default detector sweep plus the index-backed * dangling-link count into counts per detector (composer: * hooks/lib/hygiene-digest-text.ts). Silence is the steady state: zero * eligible findings, an unmeasurable scan, a state the vault-level * hash ledger already emitted, or a runtime with no non-blocking * channel all exit 0 with no output. * * Gates, in cost order: * * 1. Unreadable payload -> silent. * 2. `stop_hook_active === true` -> silent (the once-per-turn guard; * this hook never continues a turn itself, and a continued turn * must not re-run the sweep). * 3. Flag off -> silent. Checked BEFORE vault resolution, and before * the heavy imports: the detectors and the search index load * lazily after every gate, so a default-off run pays one config * read (reground-deliver precedent). * 4. Runtime without a non-blocking Stop channel -> silent. Only * Claude Code gets the line: its Stop feedback renders as * non-blocking "Stop hook feedback". Everywhere else a Stop block * (`decision: "block"`) is a forced continuation turn, and an * opt-in digest must never buy one - so Codex, Grok Build and * unrecognised runtimes exit here, before any transcript work. * 5. No transcript, unreadable transcript, or a turn that wrote no * artifact -> silent. "Once per change" means once per * artifact-writing turn (`summarizeTurn` hadArtifact, the * stop-log-guardrail gate) - an artifact write anywhere, code * included, counts; the sweep is not vault-scoped. * 6. `resolveVault()` null -> silent. * * Change detection is a hash ledger, * `<vault>/.open-second-brain/hygiene-digest.hash` * (hooks/lib/hygiene-digest-state.ts): hash differs -> emit, then * write; hash equal -> exit 0. The write lands AFTER the emit, so a * crash in between loses the dedupe and the next eligible turn emits * again - lose-not-duplicate. * * Output shape: `hookSpecificOutput.additionalContext` on the Stop * event - non-error "Stop hook feedback", not the red block shape. The * per-runtime helper this file once shipped is gone with the runtimes * it served: the only emitting runtime left is the one whose channel * is additive. The Stop guardrail (hooks/stop-log-guardrail.ts) keeps * its portable block shape - it is always-on and its continuation is * the point. * * Crashes exit 0 - never deadlock. */ import { writeSync } from "node:fs"; import { resolveHygieneDigestEnabled, resolveVault } from "../src/core/config.ts"; import { detectHookRuntime, summarizeTurn } from "./lib/detect.ts"; import { composeHygieneDigest } from "./lib/hygiene-digest-text.ts"; import { computeHygieneDigestHash, hygieneDigestHashMatches, writeHygieneDigestHash, } from "./lib/hygiene-digest-state.ts"; import { asHookPayload, readHookInput } from "./lib/stdin.ts"; import { readTranscript } from "./lib/transcript.ts"; async function main(): Promise<void> { let payload; try { payload = asHookPayload(await readHookInput()); } catch { return; } if (payload.stop_hook_active === true) return; // Off is the default and this runs after every turn: one config read, // no vault resolution, no heavy imports. if (!resolveHygieneDigestEnabled()) return; // Claude Code only: its Stop `additionalContext` is non-blocking // feedback. On every other runtime a Stop block forces a continuation // turn, and an opt-in digest must never do that. Payload-shape // detection only, so a silent runtime pays nothing further. if (detectHookRuntime(payload) !== "claudecode") return; const transcriptPath = payload.transcript_path; if (typeof transcriptPath !== "string" || transcriptPath.length === 0) return; let signal; try { signal = readTranscript(transcriptPath); } catch { return; } const summary = summarizeTurn(signal.toolCalls, signal.bashCommands); if (!summary.hadArtifact) return; const vault = resolveVault(); if (vault === null) return; // Heavy imports, only past every gate: the detector sweep and the // search index are the whole cost of this hook. const { runHygieneScan } = await import("../src/core/brain/hygiene/scan.ts"); const { resolveSearchConfig } = await import("../src/core/search/index.ts"); const { measureFromIndex } = await import("../src/core/search/link-ratchet.ts"); const report = runHygieneScan(vault, { now: new Date() }); // The dangling-link measurement must never fail the digest: resolving // the search config itself can throw (a bad `search_chunk_size` env or // config value), and an index that cannot be measured is unmeasured. // Both degrade to `null` here, never flattened into a zero - the same // composition the hygiene tool makes (measured:false with a reason). let danglingLinks: number | null = null; try { const measurement = await measureFromIndex(resolveSearchConfig({ vault })); danglingLinks = measurement.measurable ? measurement.dangling : null; } catch { // Absorbed: the count is unmeasured, the findings still surface. } const line = composeHygieneDigest({ findings: report.findings, danglingLinks }); if (line === null) return; const hash = computeHygieneDigestHash({ findings: report.findings, danglingLinks }); if (hygieneDigestHashMatches(vault, hash)) return; // One blocking write to fd 1, not process.stdout.write: a write error // must surface HERE, before the ledger write below. Bun and Node // deliver stdout errors asynchronously, which would resolve main, // record this state as emitted while the line never landed, and crash // with a stderr banner. The synchronous write throws into main's // fail-soft catch instead and the next eligible turn - hooks/nav-inject.tsGitHub
Read the script
#!/usr/bin/env -S bun /** * UserPromptSubmit hook: opt-in, cadence-controlled, audited NAV TIER * (retrieval-quality-and-context-delivery, D1 / t_2d4f34d7). * * This is the additive navigation/map tier layered on top of - and entirely * separate from - the always-on injection kernel (active-inject / * recall-inject), both of which stay behaviorally unchanged. Guarantees: * - OPT-IN: `nav_tier_enabled` (default OFF) is checked first; unset means an * immediate no-op with zero output, so the prompt preamble is byte-identical. * - CADENCE-CONTROLLED: the map injects only when no fresh cadence stamp is * live for this session (first-turn trigger, then at most once per window). * Cadence state lives under the namespaced `osb.nav_tier.*` session-state * key with an explicit epoch-ms expiry. * - STRUCTURAL: the map content is derived from the vault's link graph * (`buildNavmap` -> `graphStats`), never LLM-authored. * - AUDITED: every decision (inject / suppress) writes exactly one structured, * payload-safe audit line recording when, why, and the added char count. * - FAIL-SOFT: any error injects nothing and the session proceeds; a missing * or malformed cadence stamp is treated as "due" (absent), never a throw. */ import { defaultConfigPath, resolveNavTierCadenceMinutes, resolveNavTierEnabled, resolveVault, } from "../src/core/config.ts"; import { appendAuditRecord } from "../src/core/reliability/audit.ts"; import { hookAuditDir } from "../src/core/brain/paths.ts"; import { buildNavmap, renderNavmap } from "../src/core/brain/navmap.ts"; import { decideNavInject, navInjectAuditDetails, NAV_TIER_CADENCE_MINUTES_DEFAULT, type NavInjectDecision, } from "../src/core/brain/nav-inject.ts"; import { armProcessCeiling, resolveHookCeilingMs } from "./lib/process-ceiling.ts"; import { readHookStamp, writeHookStamp } from "./lib/session-state.ts"; import { asHookPayload, readHookInput } from "./lib/stdin.ts"; import { isContextEventName } from "./lib/context-events.ts"; /** Namespaced cadence stamp key (binding D1 convention). */ const NAV_TIER_STAMP_KEY = "osb.nav_tier.last_injected"; const MINUTE_MS = 60_000; /** * One payload-safe audit line per decision. Never throws (a hung filesystem is * exactly when this runs) and never records the navmap content - only the * decision kind, reason, and added char count. */ function auditDecision(vault: string, decision: NavInjectDecision): void { try { appendAuditRecord(hookAuditDir(vault), { timestamp: new Date().toISOString(), actor: "nav-inject", action: "nav_tier_decision", target: "UserPromptSubmit", ok: decision.kind === "inject", details: navInjectAuditDetails(decision), }); } catch { // best-effort: auditing must never disturb the fail-soft contract } } async function main(): Promise<void> { // Fast opt-out FIRST: default OFF means an immediate no-op, no process // ceiling armed, no payload read, no output - byte-identical to before. if (!resolveNavTierEnabled()) return; const disarm = armProcessCeiling({ ceilingMs: resolveHookCeilingMs() }); try { let payload; try { payload = asHookPayload(await readHookInput()); } catch { return; } const hookEventName = typeof payload.hook_event_name === "string" && payload.hook_event_name.length > 0 ? payload.hook_event_name : "UserPromptSubmit"; // Default-closed: only an additionalContext-eligible event may emit. if (!isContextEventName(hookEventName)) return; const vault = resolveVault(); if (vault === null) return; const sessionId = typeof payload.session_id === "string" ? payload.session_id : undefined; const nowMs = Date.now(); const cadenceActive = readHookStamp(vault, sessionId, NAV_TIER_STAMP_KEY, nowMs) !== null; // Only pay for the navmap build when cadence is actually due. let block = ""; if (!cadenceActive) { try { const navmap = await buildNavmap(vault, defaultConfigPath()); block = navmap === null ? "" : renderNavmap(navmap); } catch { block = ""; } } const decision = decideNavInject(block, cadenceActive); auditDecision(vault, decision); if (decision.kind !== "inject") return; // Stamp the cadence window closed before emitting so a crash after write // still suppresses re-injection rather than looping. const cadenceMinutes = resolveNavTierCadenceMinutes() ?? NAV_TIER_CADENCE_MINUTES_DEFAULT; writeHookStamp(vault, sessionId, NAV_TIER_STAMP_KEY, { expiresAt: nowMs + cadenceMinutes * MINUTE_MS, }); const out = { hookSpecificOutput: { hookEventName, additionalContext: decision.block, }, }; process.stdout.write(JSON.stringify(out) + "\n"); } finally { disarm(); } } main().catch(() => { // Never crash the runtime; the prompt submission must proceed regardless // of any hook misbehaviour. }); - hooks/post-write-reminder.tsGitHub
- hooks/pretool-orient.tsGitHub
- hooks/recall-inject.tsGitHub
- hooks/reground-deliver.tsGitHub
- hooks/session-capture.tsGitHub
- hooks/stop-log-guardrail.tsGitHub
- hooks/subagent-inject.tsGitHub
All 13 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Open Second Brain is a memory layer for AI agents that lives in an Obsidian vault. Agents read and write it through MCP servers, lifecycle hooks and the o2b CLI.

