Add deploy/hooks/capture_stop.py — a Claude Code Stop hook that reads the transcript JSONL, extracts the last user prompt, and POSTs to the AtoCore /interactions endpoint in conservative mode (reinforce=false). Conservative mode means: capture only, no automatic reinforcement or extraction into the review queue. Kill switch: ATOCORE_CAPTURE_DISABLED=1. Also: note build_sha cosmetic issue after restore in runbook, update project status docs to reflect drill pass and auto-capture wiring. 17 new tests (243 total, all passing). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
5.6 KiB
Python
188 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Claude Code Stop hook: capture interaction to AtoCore.
|
|
|
|
Reads the Stop hook JSON from stdin, extracts the last user prompt
|
|
from the transcript JSONL, and POSTs to the AtoCore /interactions
|
|
endpoint in conservative mode (reinforce=false, no extraction).
|
|
|
|
Fail-open: always exits 0, logs errors to stderr only.
|
|
|
|
Environment variables:
|
|
ATOCORE_URL Base URL of the AtoCore instance (default: http://dalidou:8100)
|
|
ATOCORE_CAPTURE_DISABLED Set to "1" to disable capture (kill switch)
|
|
|
|
Usage in ~/.claude/settings.json:
|
|
"Stop": [{
|
|
"matcher": "",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "python /path/to/capture_stop.py",
|
|
"timeout": 15
|
|
}]
|
|
}]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
ATOCORE_URL = os.environ.get("ATOCORE_URL", "http://dalidou:8100")
|
|
TIMEOUT_SECONDS = 10
|
|
|
|
# Minimum prompt length to bother capturing. Single-word acks,
|
|
# slash commands, and empty lines aren't useful interactions.
|
|
MIN_PROMPT_LENGTH = 15
|
|
|
|
# Maximum response length to capture. Truncate very long assistant
|
|
# responses to keep the interactions table manageable.
|
|
MAX_RESPONSE_LENGTH = 50_000
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point. Always exits 0."""
|
|
try:
|
|
_capture()
|
|
except Exception as exc:
|
|
print(f"capture_stop: {exc}", file=sys.stderr)
|
|
|
|
|
|
def _capture() -> None:
|
|
if os.environ.get("ATOCORE_CAPTURE_DISABLED") == "1":
|
|
return
|
|
|
|
raw = sys.stdin.read()
|
|
if not raw.strip():
|
|
return
|
|
|
|
hook_data = json.loads(raw)
|
|
session_id = hook_data.get("session_id", "")
|
|
assistant_message = hook_data.get("assistant_message", "")
|
|
transcript_path = hook_data.get("transcript_path", "")
|
|
cwd = hook_data.get("cwd", "")
|
|
|
|
prompt = _extract_last_user_prompt(transcript_path)
|
|
if not prompt or len(prompt.strip()) < MIN_PROMPT_LENGTH:
|
|
return
|
|
|
|
response = assistant_message or ""
|
|
if len(response) > MAX_RESPONSE_LENGTH:
|
|
response = response[:MAX_RESPONSE_LENGTH] + "\n\n[truncated]"
|
|
|
|
project = _infer_project(cwd)
|
|
|
|
payload = {
|
|
"prompt": prompt,
|
|
"response": response,
|
|
"client": "claude-code",
|
|
"session_id": session_id,
|
|
"project": project,
|
|
"reinforce": False,
|
|
}
|
|
|
|
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
f"{ATOCORE_URL}/interactions",
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
resp = urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS)
|
|
result = json.loads(resp.read().decode("utf-8"))
|
|
print(
|
|
f"capture_stop: recorded interaction {result.get('id', '?')} "
|
|
f"(project={project or 'none'}, prompt_chars={len(prompt)}, "
|
|
f"response_chars={len(response)})",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def _extract_last_user_prompt(transcript_path: str) -> str:
|
|
"""Read the JSONL transcript and return the last real user prompt.
|
|
|
|
Skips meta messages (isMeta=True) and system/command messages
|
|
(content starting with '<').
|
|
"""
|
|
if not transcript_path:
|
|
return ""
|
|
|
|
# Normalize path for the current OS
|
|
path = os.path.normpath(transcript_path)
|
|
if not os.path.isfile(path):
|
|
return ""
|
|
|
|
last_prompt = ""
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if entry.get("type") != "user":
|
|
continue
|
|
if entry.get("isMeta", False):
|
|
continue
|
|
|
|
msg = entry.get("message", {})
|
|
if not isinstance(msg, dict):
|
|
continue
|
|
|
|
content = msg.get("content", "")
|
|
|
|
if isinstance(content, str):
|
|
text = content.strip()
|
|
elif isinstance(content, list):
|
|
# Content blocks: extract text blocks
|
|
parts = []
|
|
for block in content:
|
|
if isinstance(block, str):
|
|
parts.append(block)
|
|
elif isinstance(block, dict) and block.get("type") == "text":
|
|
parts.append(block.get("text", ""))
|
|
text = "\n".join(parts).strip()
|
|
else:
|
|
continue
|
|
|
|
# Skip system/command XML and very short messages
|
|
if text.startswith("<") or len(text) < MIN_PROMPT_LENGTH:
|
|
continue
|
|
|
|
last_prompt = text
|
|
except OSError:
|
|
pass
|
|
|
|
return last_prompt
|
|
|
|
|
|
# Project inference from working directory.
|
|
# Maps known repo paths to AtoCore project IDs. The user can extend
|
|
# this table or replace it with a registry lookup later.
|
|
_PROJECT_PATH_MAP: dict[str, str] = {
|
|
# Add mappings as needed, e.g.:
|
|
# "C:\\Users\\antoi\\gigabit": "p04-gigabit",
|
|
# "C:\\Users\\antoi\\interferometer": "p05-interferometer",
|
|
}
|
|
|
|
|
|
def _infer_project(cwd: str) -> str:
|
|
"""Try to map the working directory to an AtoCore project."""
|
|
if not cwd:
|
|
return ""
|
|
norm = os.path.normpath(cwd).lower()
|
|
for path_prefix, project_id in _PROJECT_PATH_MAP.items():
|
|
if norm.startswith(os.path.normpath(path_prefix).lower()):
|
|
return project_id
|
|
return ""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|