watch
Lets Claude watch a video from a URL or local file by pulling timestamped frames and a transcript. Works in Claude Code, and the watch skill downloads with yt-dlp and extracts frames with ffmpeg.
What paper-trail runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Claude Code
$ npx -y skills add roomi-fields/paper-trail --agent claude-code
Cursor
Cursor also runs Claude Code hooks from .claude/settings.json, so copy these hooks there to use them in Cursor.
What fires, and when
PreToolUse
Write|Editpython3 "${CLAUDE_PLUGIN_ROOT}/tools/precheck_sota_wikilinks.py"python3 "${CLAUDE_PLUGIN_ROOT}/hooks/pre_save_sota_check.py"PostToolUse
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
"""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
#!/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())
#!/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())
#!/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())
#!/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.
Anti-hallucination plugin for academic research in Claude Code. Create literature reviews and papers guaranteed without fabricated citations.
Lets Claude watch a video from a URL or local file by pulling timestamped frames and a transcript. Works in Claude Code, and the watch skill downloads with yt-dlp and extracts frames with ffmpeg.
Gives Claude Code users access to MeiGen for discovering inspiration and creating images and videos with AI models. It also offers skills for product photoshoot planning, social video thumbnails, and visual creative direction.
Edits CapCut and JianYing video projects from the terminal or an LLM agent, covering subtitles, timing, speed, volume, templates, animations, and cutting long videos into shorts. The result opens in CapCut with every track still editable.
Turns a single line of story text into a finished short drama, covering the script, storyboards, character-consistent video and voiceover. Works with the AI model providers you already use, so you skip assembling each step by hand.
A Claude Code plugin for planning and producing AI-generated music albums, with skills for album concepts, tracklists, progress dashboards, idea tracking, and album artwork prompts.
A skill that builds AI teaching personas of specific Buddhist masters, grounded in Buddhist scripture texts and tested for fidelity to their teachings. It includes ethics gates for boundaries on what the persona will say.
Gives journalists and researchers ready-made guidance for fact-checking, source verification, interview prep, story pitches and data journalism, plus accessibility audits for news sites. Works in Claude Code and Codex.
A local video studio for your coding agent. Describe a video in one sentence: your agent directs, your machine renders. Claude Code, Codex, Cursor, Devin and more. No API keys, no uploads.