Skip to content
Development
Hook

Hooks

What lore runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
lore
77 skills2 hooks
Install
> /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
Read hooks/hooks.json

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.

Ships withlore

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.

Get the whole plugin
Stats
7
Stars
0
Forks
Active
Maintenance
JavaScript
Language
MIT
License
6d ago
Last commit
2mo ago
Created

Repo: andresanemic/lore-plugin