Hooks
What lore runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add andresanemic/lore-plugin > /plugin install lore@lore-plugin
Ships with lore. Installing the plugin gets these hooks.
What fires, and when
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.
node "${CLAUDE_PLUGIN_ROOT}/hooks/codex-guard.mjs" session_start
PostToolUse
node "${CLAUDE_PLUGIN_ROOT}/hooks/codex-guard.mjs" post_tool_use
Where it lives
- hooks/codex-guard.mjsRunsGitHub
Read the script
#!/usr/bin/env node // SessionStart + PostToolUse hook — Lore Plugin (2.4.6, declaración federada 2.4.8). // // Codex adapter of the same guard. `SessionStart` records a silent per-session // baseline and never evaluates Lore state; since 2.4.8 it adds one static check: // when the cwd is a federated bot whose always-on block does not declare its // load, it emits exactly one line, else zero bytes. `PostToolUse` evaluates // only once the current Lore digest departs from that baseline — i.e. once THIS // session has touched the Lore. A receipt that was already stale when the // session opened stays silent until the first in-session Lore edit. // Fails open on any error. import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { evaluateState, formatIntervention } from "./lore-guard.mjs"; import { loreDeparted, readReceipt, readSessionBaseline, snapshot, writeReceipt, writeSessionBaseline, } from "./lore-state.mjs"; const event = process.argv[2]; const OK = () => process.exit(0); let data; try { const raw = readFileSync(0, "utf8"); data = JSON.parse(raw || "{}"); } catch { OK(); } if (!["session_start", "post_tool_use"].includes(event)) OK(); if (event === "post_tool_use" && typeof data.turn_id !== "string") OK(); const root = typeof data.cwd === "string" && data.cwd ? data.cwd : process.cwd(); const sessionId = typeof data.session_id === "string" ? data.session_id : null; let current; try { current = snapshot(root); if (current.fileCount === 0) OK(); } catch { OK(); } // SessionStart: fix the silent baseline, bootstrap the receipt if missing, and // never evaluate. Nobody checks whether something is broken in the first second // of a session — that is exactly the entry noise this defers. if (event === "session_start") { writeSessionBaseline(sessionId, root, current); try { if (readReceipt(root) === null) writeReceipt(root, current); } catch { /* read-only tree: fail open */ } // Declaración federada, solo-en-rojo (2.4.8-rc.3): si el cwd es un bot federado // y su always-on no nombra los tres cuerpos, una línea y nada más. Verde = 0 // bytes. No evalúa estado del Lore — solo la declaración escrita. Fail open. try { const line = federatedRedLine(root); if (line) process.stdout.write(line + "\n"); } catch { /* fail open */ } OK(); } /** Una línea cuando un bot federado no declara la regla del triplete; null en otro caso. * Mismo predicado que `mycelium federated` y que bots/scripts/verificar-triplete.mjs: * la regla (palabra + marca de hermano), nunca cuerpos literales —un bot empaquetado * legítimo no tiene canon/ y no debe sonar el rojo. */ function federatedRedLine(root) { const contract = ["CLAUDE.md", "AGENTS.md"].map((n) => join(root, n)).find((p) => existsSync(p)); if (!contract || !existsSync(join(root, "lore", "enrutamiento.md"))) return null; const text = readFileSync(contract, "utf8"); const block = (text.match(/<!-- lore:always-on -->([\s\S]*?)<!-- \/lore:always-on -->/) || [])[1] || ""; const hasRule = /triplete/i.test(block) && /(hermano|no ancestro|no los inyecta|no lo inyecta)/i.test(block); if (hasRule) return null; return `Lore: el always-on no declara la regla del triplete — corre lore-plugin mycelium federated en este árbol o transmute-lore UPGRADE.`; } // PostToolUse: deferred arming. Without a baseline the first sight becomes it — // never an intervention. Arming is on the CHANGE. const baseline = readSessionBaseline(sessionId, root); if (!baseline) { writeSessionBaseline(sessionId, root, current); OK(); } if (!loreDeparted(baseline, current)) OK(); // this session has not touched the Lore let recorded; try { recorded = readReceipt(root); } catch { OK(); } if (recorded === null) { try { writeReceipt(root, current); } catch { /* read-only tree: fail open */ } OK(); } const result = evaluateState(current, recorded); if (!result.pendingLore && !result.requiresApproval) { if (recorded.version === 1) { try { writeReceipt(root, current); } catch { /* read-only tree: fail open */ } } OK(); } const additionalContext = formatIntervention(result); process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext, }, })); - hooks/lore-guard.mjsGitHub
Read the script
export const MATERIAL_GROWTH_BYTES = 8_192; export function evaluateState(current, recorded) { if (recorded === null) { return { pendingLore: false, expansion: null, requiresApproval: false }; } const pendingLore = recorded.digest !== current.digest; if (recorded.version !== 2) { return { pendingLore, expansion: null, requiresApproval: false }; } const delta = current.alwaysOnBytes - recorded.alwaysOnBytes; const expansion = { before: recorded.alwaysOnBytes, after: current.alwaysOnBytes, delta, percent: recorded.alwaysOnBytes === 0 ? null : delta / recorded.alwaysOnBytes * 100, material: delta >= MATERIAL_GROWTH_BYTES, }; return { pendingLore, expansion, requiresApproval: expansion.material }; } function size(bytes) { if (bytes === 0) return "0 bytes"; return `${(Math.abs(bytes) / 1_000).toFixed(1).replace(".", ",")} KB`; } export function formatIntervention(result) { const lines = []; if (result.pendingLore) { lines.push( "Mensaje del hook, no del usuario: no lo cites ni lo respondas, y no le informes al usuario que revisaste, " + "registraste, o que “quedó todo conectado”. En silencio: comprueba que los cambios de criterio de esta " + "sesión siguen conectados al trabajo que deben guiar y registra el estado local si están bien. Tu respuesta al " + "usuario conserva una sola parte: la respuesta que ya ibas a dar. Menciona algo sólo si bloquea la tarea, en una " + "línea. Si la acción requiere permiso, solicita sólo ese permiso.", ); } if (result.requiresApproval) { const { before, after, delta, percent } = result.expansion; const growth = percent === null ? `+${size(delta)}` : `+${size(delta)}; +${Math.round(percent)}%`; lines.push( `El criterio que se carga en cada tarea creció de ${size(before)} a ${size(after)} (${growth}). ` + "Necesito tu aprobación antes de registrar el nuevo estado.", ); } return lines.join("\n"); } - hooks/lore-state.mjsGitHub
Read the script
// Estado del Lore de un árbol, por contenido — Lore Plugin. // // El único piso común que tienen los dos hosts y todos los usuarios es el sistema // de archivos. No `git` (no es requisito del kit, y hay árboles de Lore sin él), // no el transcript (invisible a las escrituras por script), no lo que el agente // diga haber corrido (una frase no es un hecho). // // Se usa desde la guardia de Codex y desde los subcomandos locales de `lore-plugin mycelium`. import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; // Un archivo de Lore: un `.md` dentro de un `lore/`, o uno de los nombres // distintivos de criterio en cualquier nivel. FASES/PHASES es estado y nunca entra. const LORE_DIR = /(^|[/\\])lore[/\\][^/\\]+\.md$/i; const LORE_FILE = /(^|[/\\])(principios|principles|identidad|identity|enrutamiento|routing)\.md$/i; const PHASE_FILE = /(^|[/\\])(FASES|PHASES)\.md$/i; // Directorios universales de dependencias y artefactos, más las dos formas en que // un árbol contiene Lore que no es suyo: **fixtures** (dato de prueba) y **backups** // (una copia de otro árbol). El principio detrás de las dos es el mismo — *una copia // de un Lore no es el Lore del árbol que la contiene* — y la frontera es honesta: el // recorrido no puede distinguir una copia cualquiera de un original, así que se apoya // en convenciones de nombre. Un árbol que guarde copias con otro nombre las verá // contadas, y su digest se moverá cuando esas copias se regeneren. // // No se listan nombres de carpeta propios de ningún ecosistema: generalizar desde un // solo caso es cómo una forma se lleva puesto lo que era propio de ese caso. const SKIP = new Set([ "node_modules", ".git", ".venv", "venv", "dist", "build", "coverage", ".next", "__pycache__", "fixtures", "__fixtures__", "test-fixtures", "_backup", "backup", "backups", ".backup", ]); const MAX_DEPTH = 6; const MAX_BYTES = 2_000_000; export const RECEIPT = ".lore-mycelium"; export function loreFiles(root, { maxDepth = MAX_DEPTH } = {}) { const found = []; const walk = (dir, depth) => { if (depth > maxDepth) return; let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { const full = join(dir, e.name); if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(full, depth + 1); } else if (e.isFile() && !PHASE_FILE.test(full) && (LORE_DIR.test(full) || LORE_FILE.test(full))) { found.push(full); } } }; walk(root, 0); return found.sort(); } function normalizedBody(file) { return readFileSync(file, "utf8").replace(/\r\n/g, "\n"); } function loreBodies(files) { const bodies = new Map(); for (const file of files) { try { if (statSync(file).size <= MAX_BYTES) bodies.set(file, normalizedBody(file)); } catch {} } return bodies; } function digestBodies(root, files, bodies) { const h = createHash("sha256"); for (const file of files) { const body = bodies.get(file); if (body === undefined) continue; h.update(relative(root, file).split(sep).join("/")); h.update("\0"); h.update(createHash("sha256").update(body).digest("hex")); h.update("\n"); } return h.digest("hex"); } // Digest por CONTENIDO, no por mtime: tocar un archivo sin cambiarlo no cuenta, // y el mismo árbol da el mismo digest en otra máquina. export function digest(root) { const files = loreFiles(root); return digestBodies(root, files, loreBodies(files)); } const CONTRACTS = ["CLAUDE.md", "AGENTS.md"]; const BLOCK = /<!--\s*lore:always-on\s*-->([\s\S]*?)<!--\s*\/lore:always-on\s*-->/; function alwaysOnFiles(root) { const contract = CONTRACTS.find((name) => existsSync(join(root, name))); if (!contract) return []; let scope; try { scope = BLOCK.exec(readFileSync(join(root, contract), "utf8"))?.[1]; } catch { return []; } if (!scope) return []; const found = new Set(); for (const match of scope.matchAll(/`([^`]+)`/g)) { const pointer = match[1].trim(); const segments = pointer.split(/[/\\]+/); if (isAbsolute(pointer) || !pointer.toLowerCase().endsWith(".md") || !segments.some((part) => /^(lore|canon)$/i.test(part)) || /^(FASES|PHASES)\.md$/i.test(basename(pointer))) continue; const file = resolve(root, pointer); try { if (statSync(file).isFile()) found.add(file); } catch {} } return [...found]; } export function snapshot(root) { const files = loreFiles(root); const bodies = loreBodies(files); return { digest: digestBodies(root, files, bodies), fileCount: files.length, alwaysOnBytes: alwaysOnFiles(root).reduce((sum, file) => sum + Buffer.byteLength(bodies.get(file) ?? normalizedBody(file)), 0), }; } export function readReceipt(root) { try { const raw = readFileSync(join(root, RECEIPT), "utf8").trim(); if (/^[0-9a-f]{64}$/.test(raw)) { return { version: 1, digest: raw, alwaysOnBytes: null }; } const receipt = JSON.parse(raw); if (receipt?.version !== 2 || !/^[0-9a-f]{64}$/.test(receipt.digest) || !Number.isInteger(receipt.alwaysOnBytes) || receipt.alwaysOnBytes < 0) return null; const state = { version: 2, digest: receipt.digest, alwaysOnBytes: receipt.alwaysOnBytes, }; // El pool solo aparece si está: un recibo sin Anuncio y uno con el pool en // cero no son el mismo hecho, y la clave ausente es la que dice «nunca hubo». const announce = readAnnounce(receipt.announce); if (announce) state.announce = announce; return state; } catch { return null; } } export function writeReceipt(root, state = snapshot(root)) { if (!state || !/^[0-9a-f]{64}$/.test(stat
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.
Local fine-tuning for your own tasks — and the one doing the training is you. A provider-neutral kit that turns project experience into reusable criteria, distilled at a threshold you control, pruned when it grows, and portable between models.
Repo: andresanemic/lore-plugin

