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.
Install
$ npx -y skills add roomi-fields/paper-trail --agent claude-codeShips with paper-trail. Installing the plugin gets these hooks.
What fires, and when
PreToolUse
- Matches
Write|Editpython3 "${CLAUDE_PLUGIN_ROOT}/tools/precheck_sota_wikilinks.py"python3 "${CLAUDE_PLUGIN_ROOT}/hooks/pre_save_sota_check.py"
PostToolUse
- Matches
Write|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"
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

