Skip to content
Content
Hook

Hooks

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

From plugin
paper-trail
56 skills6 agents22 commands3 hooks
Install
$ npx -y skills add roomi-fields/paper-trail --agent claude-code

Ships with paper-trail. Installing the plugin gets these hooks.

What fires, and when

PreToolUse

  • MatchesWrite|Editpython3 "${CLAUDE_PLUGIN_ROOT}/tools/precheck_sota_wikilinks.py"python3 "${CLAUDE_PLUGIN_ROOT}/hooks/pre_save_sota_check.py"

PostToolUse

  • MatchesWrite|Editpython3 "${CLAUDE_PLUGIN_ROOT}/hooks/post_edit_ref_check.py"python3 "${CLAUDE_PLUGIN_ROOT}/hooks/post_edit_sota_check.py"

SessionEnd

  • python3 "${CLAUDE_PLUGIN_ROOT}/hooks/session_end_doctor.py"
Read hooks/hooks.json

In the plugin's words

How paper-trail describes its own hook set.

paper-trail hooks — PreToolUse SOTA wikilink check, PreToolUse SOTA pre-save check (I21/I22), PostToolUse doctor on refs and SOTAs, SessionEnd doctor sweep

Where it lives

  • hooks/_hook_env.pyGitHub
    Read the script
    """Bootstrap commun aux hooks — charge `~/.config/paper-trail/env`.
    
    Pourquoi : les hooks Claude Code n'héritent pas systématiquement de
    l'environnement shell (selon comment Claude Code a été lancé, selon
    le shell rc sourcé ou pas). RESEARCH_VAULT_PATH peut donc être absent
    du process hook même si l'utilisateur l'a défini globalement.
    
    Conséquence avant ce module : `from pipeline.config import …` levait
    `ConfigError`, l'exception était silencieusement avalée (registre vide),
    et tous les wikilinks du SOTA étaient flagués comme « absents du
    registre » (I22 faux positif).
    
    Ce module duplique la logique de `pipeline.config._load_user_config_file`
    parce qu'on doit charger AVANT d'importer config (qui lèverait sinon).
    """
    from __future__ import annotations
    import os
    from pathlib import Path
    
    
    def load_user_env() -> Path | None:
        """Charge `~/.config/paper-trail/env` (XDG-aware). Retourne le path
        chargé, ou `None`. Idempotent : `os.environ.setdefault` ne réécrit
        pas les variables déjà définies."""
        xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
        cfg_path = Path(xdg) / "paper-trail" / "env"
        if not cfg_path.is_file():
            return None
        try:
            for raw in cfg_path.read_text(encoding="utf-8").splitlines():
                line = raw.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, _, value = line.partition("=")
                key, value = key.strip(), value.strip().strip('"').strip("'")
                if key:
                    os.environ.setdefault(key, value)
            return cfg_path
        except OSError:
            return None
    
  • hooks/post_edit_ref_check.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """PostToolUse hook — mini doctor check on the edited ref.
    
    Triggered by Claude Code after a Write or Edit tool call. Reads the
    tool input from stdin (JSON), checks if the file path matches a ref
    in the registry, and if so runs `pipeline doctor --severity warn` on
    that single ref to flag any invariant violation introduced by the edit.
    
    Non-blocking : prints warnings to stdout/stderr but always exits 0.
    
    Hook contract (Claude Code) :
    - stdin : JSON object with tool_input (containing file_path)
    - exit 0 : non-blocking warning
    - exit ≠ 0 : would block the action (not used here)
    """
    from __future__ import annotations
    import json
    import os
    import re
    import subprocess
    import sys
    from pathlib import Path
    
    
    def main() -> int:
        # Read hook input from stdin
        try:
            hook_input = json.loads(sys.stdin.read())
        except json.JSONDecodeError:
            # Malformed input — don't block, just exit silently
            return 0
    
        # Extract file path from tool_input (varies by tool: Write, Edit, MultiEdit)
        tool_input = hook_input.get("tool_input", {})
        file_path = (
            tool_input.get("file_path")
            or tool_input.get("path")
            or ""
        )
        if not file_path:
            return 0
    
        # Match registry refs : */refs/*.md
        if not re.search(r"/refs/[^/]+\.md$", file_path):
            return 0
    
        # Find the plugin root (env var CLAUDE_PLUGIN_ROOT injected by harness,
        # fallback to script's parent.parent)
        plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT") or str(
            Path(__file__).resolve().parent.parent
        )
    
        # Compute the ref slug for filtering (basename without .md)
        slug = Path(file_path).stem
    
        # Run doctor scoped to that slug via grep on the JSON output.
        # The CLI doesn't natively filter by slug, so we run full doctor with
        # --json and grep client-side. This is fast (~3s for 909 refs).
        try:
            proc = subprocess.run(
                ["python3", "-m", "pipeline", "doctor", "--json",
                 "--severity", "warn"],
                cwd=plugin_root,
                capture_output=True,
                text=True,
                timeout=30,
            )
        except (subprocess.TimeoutExpired, OSError):
            return 0  # non-blocking
    
        try:
            data = json.loads(proc.stdout)
        except json.JSONDecodeError:
            return 0
    
        violations = [
            v for v in (data.get("violations") or [])
            if v.get("ref_slug") == slug
        ]
        if not violations:
            return 0
    
        print(f"[paper-trail hook] Doctor warnings on {slug}:", file=sys.stderr)
        for v in violations:
            print(f"  - {v['invariant']} ({v['severity']}): {v['message']}",
                  file=sys.stderr)
        return 0
    
    
    if __name__ == "__main__":
        sys.exit(main())
    
  • hooks/post_edit_sota_check.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """PostToolUse hook — warning si SOTA modifié avec citations en texte libre.
    
    Déclenché par Claude Code après Write/Edit. Si le fichier est un SOTA
    (SOTA_*.md), scanne ses sections bibliographiques pour détecter des
    citations en texte libre sans wikilink correspondant. Non-bloquant :
    émet juste un warning dans stderr.
    
    L'utilisateur peut alors lancer `/paper-trail:ingest <SOTA>` pour
    absorber les citations dans le registre.
    
    Hook contract :
    - stdin : JSON tool_input (contient file_path)
    - exit 0 : toujours non-bloquant (warning seulement)
    """
    from __future__ import annotations
    import json
    import os
    import re
    import sys
    from pathlib import Path
    
    # Charge le module d'invariants pour réutiliser la détection
    _CITATION_LINE_RE = re.compile(
        r"^\s*(?:[-*+]|\d+\.)\s+.*\b(19|20)\d{2}\b.+$",
        re.MULTILINE,
    )
    _HAS_WIKILINK_RE = re.compile(r"\[\[[a-z0-9_]+\]\]")
    
    
    def main() -> int:
        try:
            hook_input = json.loads(sys.stdin.read())
        except json.JSONDecodeError:
            return 0
        tool_input = hook_input.get("tool_input", {})
        file_path = tool_input.get("file_path") or tool_input.get("path") or ""
        if not file_path:
            return 0
        p = Path(file_path)
        if not p.exists():
            return 0
        # Filtre : fichier doit ressembler à un SOTA
        if not re.search(r"(SOTA_|sota_)", p.name):
            return 0
        # Charge le module ingest/adapter pour réutiliser
        # extract_bibliography_sections
        plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT") or str(
            Path(__file__).resolve().parent.parent
        )
        sys.path.insert(0, plugin_root)
        try:
            from adapters import get_adapter
            adapter = get_adapter()
        except Exception:
            return 0
        try:
            sections = adapter.extract_bibliography_sections(p)
        except Exception:
            return 0
        free_text = 0
        for s in sections:
            if s.is_excluded:
                continue
            for line in s.raw_text.splitlines():
                if _CITATION_LINE_RE.match(line) and not _HAS_WIKILINK_RE.search(line):
                    free_text += 1
        if free_text:
            print(
                f"[paper-trail] {p.name} contains {free_text} free-text "
                f"citation(s) with no wikilink. Run `/paper-trail:ingest "
                f"{p.name}` to absorb them into the registry.",
                file=sys.stderr,
            )
        return 0
    
    
    if __name__ == "__main__":
        sys.exit(main())
    
  • hooks/pre_save_sota_check.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """PreToolUse hook — bloque le save d'un SOTA non conforme.
    
    Déclenché par Claude Code AVANT Write/Edit sur un fichier SOTA. Vérifie
    les invariants I21 (texte libre non ingéré), I22 (wikilink vers ref
    absente du registre), I23 (wikilink vers retracted) sur le fichier.
    
    Si l'un de ces invariants > 0, retourne exit code 2 → Claude Code
    bloque l'écriture et affiche le message à l'utilisateur.
    
    L'utilisateur doit alors lancer `/paper-trail:ingest <SOTA>` avant de
    finaliser.
    
    Bypass via env var : `PAPER_TRAIL_SKIP_PRE_SAVE=1` (pour les hotfix).
    
    Hook contract :
    - stdin : JSON tool_input (contient file_path et new_string/content)
    - exit 0 : OK, autorise l'écriture
    - exit 2 : bloque, message dans stderr
    """
    from __future__ import annotations
    import json
    import os
    import re
    import sys
    from pathlib import Path
    
    # Bootstrap : charge la config globale AVANT tout import qui dépend
    # de RESEARCH_VAULT_PATH. Évite les faux positifs I22 quand Claude Code
    # n'a pas hérité de la variable d'environnement shell.
    sys.path.insert(0, str(Path(__file__).resolve().parent))
    from _hook_env import load_user_env  # noqa: E402
    load_user_env()
    
    _CITATION_LINE_RE = re.compile(
        r"^\s*(?:[-*+]|\d+\.)\s+.*\b(19|20)\d{2}\b.+$",
        re.MULTILINE,
    )
    # Match les trois formes de citation :
    #   [[slug]]                    (Obsidian simple)
    #   [[slug|display]]            (Obsidian piped, alias)
    #   [display](path/slug.md)     (flat markdown link)
    _HAS_WIKILINK_RE = re.compile(
        r"\[\[[a-z0-9_]+(?:\|[^\]]+)?\]\]"
        r"|\]\((?:[^)]*?/)?[a-z0-9_]+\.md\)"
    )
    # Extraction du slug — couvre [[slug]] ET [[slug|display]]
    _WIKILINK_RE = re.compile(r"\[\[([a-z0-9_]+)(?:\|[^\]]+)?\]\]")
    _REF_SLUG_RE = re.compile(r"^[a-z][a-z0-9]*_(19|20)\d{2}_[a-z0-9_]+$")
    
    
    def main() -> int:
        if os.environ.get("PAPER_TRAIL_SKIP_PRE_SAVE") == "1":
            return 0  # bypass explicit
    
        try:
            hook_input = json.loads(sys.stdin.read())
        except json.JSONDecodeError:
            return 0  # ne pas bloquer si input malformé
        tool_input = hook_input.get("tool_input", {})
        file_path = tool_input.get("file_path") or tool_input.get("path") or ""
        if not file_path:
            return 0
        p = Path(file_path)
        if not re.search(r"(SOTA_|sota_)", p.name):
            return 0
    
        # Récupère le contenu qui sera écrit (Write : `content`, Edit : on
        # ne sait pas le résultat final → skip car le contenu n'est pas tout)
        content = tool_input.get("content") or tool_input.get("new_string") or ""
        if not content:
            return 0
    
        # I21 : citations texte libre sans wikilink
        free_text_lines = []
        for line in content.splitlines():
            if _CITATION_LINE_RE.match(line) and not _HAS_WIKILINK_RE.search(line):
                free_text_lines.append(line.strip()[:80])
    
        # I22/I23 : nécessite accès au registre
        plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT") or str(
            Path(__file__).resolve().parent.parent
        )
        sys.path.insert(0, plugin_root)
        registry_slugs: set[str] = set()
        retracted_slugs: set[str] = set()
        registry_load_error: str | None = None
        try:
            from pipeline.registry import iter_refs
            for ref in iter_refs():
                registry_slugs.add(ref.slug)
                if ref.state == "retracted":
                    retracted_slugs.add(ref.slug)
        except Exception as e:
            # Critique : si on ne peut PAS lire le registre, on doit absolument
            # éviter de bloquer sur I22/I23, sinon TOUS les wikilinks d'un
            # SOTA légitime sont flagués « absents » (faux positif massif).
            # On garde I21 (qui ne dépend pas du registre).
            registry_load_error = f"{type(e).__name__}: {str(e)[:200]}"
    
        wikilinks_in_content = set(_WIKILINK_RE.findall(content))
        if registry_load_error is None:
            missing_refs = [
                slug for slug in wikilinks_in_content
                if _REF_SLUG_RE.match(slug) and slug not in registry_slugs
            ]
            retracted_cites = [
                slug for slug in wikilinks_in_content
                if slug in retracted_slugs
            ]
        else:
            # Pas de check I22/I23 — on l'annonce explicitement, on ne ment
            # pas avec un « rien à signaler ».
            missing_refs = []
            retracted_cites = []
            print(
                f"[paper-trail WARN] registry unreachable — I22/I23 skipped.\n"
                f"  Detail: {registry_load_error}\n"
                f"  Hint: set RESEARCH_VAULT_PATH in "
                f"~/.config/paper-trail/env if you have not already.",
                file=sys.stderr,
            )
    
        errors = []
        if free_text_lines:
            errors.append(
                f"I21: {len(free_text_lines)} free-text citation(s) with no "
                f"wikilink. Examples: {free_text_lines[:2]}"
            )
        if missing_refs:
            errors.append(
                f"I22: wikilinks to refs missing from the registry: "
                f"{missing_refs[:3]}"
            )
        if retracted_cites:
            # I23 = WARN, pas bloquant. Juste avertir.
            print(
                f"[paper-trail WARN] {p.name} cites retracted refs: "
                f"{retracted_cites[:3]}",
                file=sys.stderr,
            )
    
        if errors:
            print(f"[paper-trail PRE-SAVE BLOCKED] {p.name}", file=sys.stderr)
            for e in errors:
                print(f"  - {e}", file=sys.stderr)
            print(
                f"\nRun `/paper-trail:ingest {p.name}` to resolve, or "
                f"`PAPER_TRAIL_SKIP_PRE_SAVE=1` for an exceptional bypass.",
                file=sys.stderr,
            )
            return 2
    
        return 0
    
    
    if __name__ == "__main__":
        sys.exit(main())
    
  • hooks/session_end_doctor.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """SessionEnd hook — full doctor sweep at end of session.
    
    Triggered by Claude Code when the session ends. Runs the full doctor
    audit at severity=error and prints the recap. Non-blocking.
    
    Skip mechanism : if `RESEARCH_SKIP_END_DOCTOR=1` is set, exits silently.
    
    Hook contract :
    - stdin : JSON (ignored in this hook)
    - exit 0 : non-blocking
    """
    from __future__ import annotations
    import os
    import subprocess
    import sys
    from pathlib import Path
    
    
    def main() -> int:
        if os.environ.get("RESEARCH_SKIP_END_DOCTOR") == "1":
            return 0
    
        plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT") or str(
            Path(__file__).resolve().parent.parent
        )
    
        try:
            proc = subprocess.run(
                ["python3", "-m", "pipeline", "doctor", "--severity", "error"],
                cwd=plugin_root,
                capture_output=True,
                text=True,
                timeout=120,
            )
        except (subprocess.TimeoutExpired, OSError) as e:
            print(f"[paper-trail hook] SessionEnd doctor crashed: "
                  f"{type(e).__name__}", file=sys.stderr)
            return 0  # non-blocking
    
        # Extract only the recap line for terseness
        recap = None
        for line in (proc.stdout or "").splitlines():
            if line.startswith("Summary:"):
                recap = line
                break
    
        if recap:
            print(f"[paper-trail SessionEnd] {recap}", file=sys.stderr)
        elif proc.returncode != 0:
            print(f"[paper-trail SessionEnd] doctor exited {proc.returncode}",
                  file=sys.stderr)
        # If exit 0 and no recap, nothing to say (no violations)
        return 0
    
    
    if __name__ == "__main__":
        sys.exit(main())
    

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 withpaper-trail

Anti-hallucination plugin for academic research in Claude Code. Create literature reviews and papers guaranteed without fabricated citations.

Get the whole plugin