Claude Code's on-disk JSONL transcript format is internal and undocumented, so a CLI update can silently break backend/conversations.py. Add scripts/schema-drift.py as the early-warning system: it diffs a structural footprint (key paths + content-block types, no values) of recent ~/.claude/projects transcripts against a committed baseline, and runs the real parser over the same files cross-checking its summary against raw record counts (usage turns, tokens, models, timestamps). New CC versions are INFO-only; new schema paths or a failed invariant exit 1. Baseline generated from the 300 newest transcripts (cc 2.1.206-207); verified the check trips on a simulated usage-key rename. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
264 lines
11 KiB
Python
Executable File
264 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Detect Claude Code transcript-schema drift before it breaks the parser.
|
|
|
|
The ai-agent viewer parses Claude Code's on-disk transcripts
|
|
(~/.claude/projects/<proj>/<session>.jsonl) with backend/conversations.py.
|
|
That format is internal to Claude Code and undocumented, so a CLI update can
|
|
change it silently. This script is the early-warning system, two checks in one:
|
|
|
|
1. **Structural footprint** — walk recent transcripts and collect every key
|
|
path / content-block type the records use (values are never recorded, only
|
|
shapes, so the baseline is safe to commit). Compare against the committed
|
|
baseline (scripts/schema-baseline.json): a NEW path means Claude Code
|
|
started emitting something the parser has never seen; a MISSING path that
|
|
the parser *relies on* would show up in check 2.
|
|
|
|
2. **Parser canary** — run the real backend/conversations.py over the same
|
|
files and cross-check its summary against raw record counts (assistant
|
|
turns with a usage block, token totals, model/timestamps extracted). This
|
|
catches semantic breakage a structure diff can't (e.g. `usage` renamed:
|
|
the new key is reported by check 1, and the zeroed token counts fail here).
|
|
|
|
A previously unseen Claude Code `version` stamp is reported as INFO only —
|
|
updates land ~daily and almost never drift; failing on every release would
|
|
just train you to ignore the check.
|
|
|
|
Usage:
|
|
scripts/schema-drift.py # check newest transcripts vs baseline
|
|
scripts/schema-drift.py --update # re-baseline (after reviewing drift)
|
|
scripts/schema-drift.py --days 30 --max-files 300 # wider sweep
|
|
|
|
Exit codes: 0 = no drift, 1 = drift or canary failure, 2 = usage/setup error.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
import time
|
|
|
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
DEFAULT_BASELINE = HERE / "schema-baseline.json"
|
|
DEFAULT_DIR = pathlib.Path.home() / ".claude" / "projects"
|
|
|
|
sys.path.insert(0, str(HERE.parent / "backend"))
|
|
try:
|
|
import conversations # noqa: E402 (the real parser — the thing we protect)
|
|
except Exception as e: # pragma: no cover
|
|
print(f"error: cannot import backend/conversations.py: {e}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
# Subtrees whose *children* vary legitimately per tool / per feature — recording
|
|
# them would make every new tool look like schema drift. We record the node
|
|
# itself (so a rename of the container is still caught) but stop descending.
|
|
STOP_DESCENT_KEYS = {"input", "toolUseResult", "attachment", "usage"}
|
|
# …except usage: its keys are exactly what norm_usage() bills from, so those we
|
|
# DO want, one level deep (input_tokens, cache_creation.*, …).
|
|
USAGE_DEPTH = 2
|
|
MAX_DEPTH = 6
|
|
|
|
|
|
def _walk(node, prefix: str, depth: int, out: set[str]) -> None:
|
|
if depth > MAX_DEPTH:
|
|
return
|
|
if isinstance(node, dict):
|
|
for k, v in node.items():
|
|
path = f"{prefix}.{k}"
|
|
out.add(path)
|
|
if k == "usage":
|
|
_walk_usage(v, path, out)
|
|
elif k in STOP_DESCENT_KEYS:
|
|
continue
|
|
else:
|
|
_walk(v, path, depth + 1, out)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
if isinstance(item, dict) and isinstance(item.get("type"), str):
|
|
# Content blocks: fold the block type into the path segment so
|
|
# the block-type vocabulary (text/tool_use/thinking/…) — the
|
|
# thing the parser branches on — is part of the footprint.
|
|
bpath = f"{prefix}[{item['type']}]"
|
|
out.add(bpath)
|
|
_walk({k: v for k, v in item.items() if k != "type"},
|
|
bpath, depth + 1, out)
|
|
else:
|
|
_walk(item, f"{prefix}[]", depth + 1, out)
|
|
|
|
|
|
def _walk_usage(node, prefix: str, out: set[str]) -> None:
|
|
if not isinstance(node, dict):
|
|
return
|
|
for k, v in node.items():
|
|
out.add(f"{prefix}.{k}")
|
|
if isinstance(v, dict):
|
|
for k2 in v:
|
|
out.add(f"{prefix}.{k}.{k2}")
|
|
|
|
|
|
def footprint(records) -> tuple[set[str], set[str]]:
|
|
"""(paths, cc_versions) for one transcript's records."""
|
|
paths: set[str] = set()
|
|
versions: set[str] = set()
|
|
for o in records:
|
|
t = o.get("type") or "?"
|
|
v = o.get("version")
|
|
if isinstance(v, str):
|
|
versions.add(v)
|
|
_walk(o, t, 0, paths)
|
|
return paths, versions
|
|
|
|
|
|
# ── parser canary ────────────────────────────────────────────────────────────
|
|
|
|
def canary(path: pathlib.Path, records: list[dict]) -> list[str]:
|
|
"""Invariant violations between raw records and the parser's summary."""
|
|
problems: list[str] = []
|
|
s = conversations.parse_conversation(path)
|
|
|
|
raw_usage_turns = sum(
|
|
1 for o in records
|
|
if o.get("type") == "assistant"
|
|
and isinstance(o.get("message"), dict)
|
|
and isinstance(o["message"].get("usage"), dict))
|
|
if s["assistantTurns"] != raw_usage_turns:
|
|
problems.append(
|
|
f"assistantTurns={s['assistantTurns']} but {raw_usage_turns} raw "
|
|
"assistant records carry a usage block")
|
|
if raw_usage_turns and not s["tokens"]:
|
|
problems.append("usage blocks present but summary tokens == 0")
|
|
|
|
raw_models = {
|
|
o["message"]["model"] for o in records
|
|
if o.get("type") == "assistant"
|
|
and isinstance(o.get("message"), dict)
|
|
and conversations._real_model(o["message"].get("model"))}
|
|
if raw_models and not s.get("models"):
|
|
problems.append(f"raw models {sorted(raw_models)} but summary has none")
|
|
|
|
if any(o.get("timestamp") for o in records) and not (
|
|
s["startedAt"] and s["endedAt"]):
|
|
problems.append("timestamps present but startedAt/endedAt missing")
|
|
|
|
raw_session = next((o["sessionId"] for o in records
|
|
if isinstance(o.get("sessionId"), str)), None)
|
|
if raw_session and s["sessionId"] != raw_session:
|
|
problems.append("sessionId not extracted")
|
|
return problems
|
|
|
|
|
|
# ── file selection ───────────────────────────────────────────────────────────
|
|
|
|
def pick_files(dirs: list[pathlib.Path], days: float,
|
|
max_files: int) -> list[pathlib.Path]:
|
|
"""Newest .jsonl files modified within `days`, across all dirs (falls back
|
|
to the newest few overall so the check never runs on nothing)."""
|
|
cutoff = time.time() - days * 86400
|
|
all_files: list[tuple[float, pathlib.Path]] = []
|
|
for d in dirs:
|
|
if not d.is_dir():
|
|
continue
|
|
for root, _dirs, names in os.walk(d):
|
|
for n in names:
|
|
if not n.endswith(".jsonl"):
|
|
continue
|
|
p = pathlib.Path(root) / n
|
|
try:
|
|
all_files.append((p.stat().st_mtime, p))
|
|
except OSError:
|
|
continue
|
|
all_files.sort(reverse=True)
|
|
recent = [p for m, p in all_files if m >= cutoff]
|
|
return (recent or [p for _, p in all_files[:5]])[:max_files]
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--dir", action="append", type=pathlib.Path,
|
|
help=f"transcript dir(s) to scan (default {DEFAULT_DIR})")
|
|
ap.add_argument("--days", type=float, default=3.0,
|
|
help="scan files modified in the last N days (default 3)")
|
|
ap.add_argument("--max-files", type=int, default=100,
|
|
help="cap on files scanned (default 100)")
|
|
ap.add_argument("--baseline", type=pathlib.Path, default=DEFAULT_BASELINE)
|
|
ap.add_argument("--update", action="store_true",
|
|
help="write the observed footprint as the new baseline")
|
|
args = ap.parse_args()
|
|
|
|
files = pick_files(args.dir or [DEFAULT_DIR], args.days, args.max_files)
|
|
if not files:
|
|
print("error: no .jsonl transcripts found", file=sys.stderr)
|
|
return 2
|
|
|
|
seen_paths: set[str] = set()
|
|
seen_versions: set[str] = set()
|
|
canary_failures: list[str] = []
|
|
for p in files:
|
|
records = list(conversations._iter_records(p))
|
|
if not records:
|
|
continue
|
|
paths, versions = footprint(records)
|
|
seen_paths |= paths
|
|
seen_versions |= versions
|
|
for problem in canary(p, records):
|
|
canary_failures.append(f"{p.name}: {problem}")
|
|
|
|
print(f"scanned {len(files)} transcript(s); "
|
|
f"{len(seen_paths)} schema paths; "
|
|
f"cc versions: {', '.join(sorted(seen_versions)) or '?'}")
|
|
|
|
if args.update or not args.baseline.exists():
|
|
if not args.baseline.exists() and not args.update:
|
|
print("no baseline yet — writing one (first run)")
|
|
if canary_failures:
|
|
print(f"CANARY FAILED ({len(canary_failures)}) — fix the parser "
|
|
"before trusting this baseline:")
|
|
for f in canary_failures:
|
|
print(f" ! {f}")
|
|
args.baseline.write_text(json.dumps({
|
|
"note": "structural footprint of Claude Code transcript JSONL — "
|
|
"key paths & block types only, no content",
|
|
"ccVersions": sorted(seen_versions),
|
|
"paths": sorted(seen_paths),
|
|
}, indent=1) + "\n")
|
|
print(f"baseline written: {args.baseline}")
|
|
return 0
|
|
|
|
base = json.loads(args.baseline.read_text())
|
|
base_paths = set(base.get("paths") or [])
|
|
base_versions = set(base.get("ccVersions") or [])
|
|
|
|
new_paths = sorted(seen_paths - base_paths)
|
|
gone_paths = sorted(base_paths - seen_paths)
|
|
new_versions = sorted(seen_versions - base_versions)
|
|
|
|
ok = True
|
|
if new_versions:
|
|
print(f"INFO: new Claude Code version(s) since baseline: "
|
|
f"{', '.join(new_versions)}")
|
|
if gone_paths:
|
|
# Absence in a small recent sample is usually just "feature unused
|
|
# this week" — informational unless the canary also failed.
|
|
print(f"INFO: {len(gone_paths)} baseline path(s) unseen in this sample "
|
|
"(likely just unused features)")
|
|
if new_paths:
|
|
ok = False
|
|
print(f"DRIFT: {len(new_paths)} new schema path(s) not in baseline:")
|
|
for p in new_paths:
|
|
print(f" + {p}")
|
|
print("→ review whether backend/conversations.py should handle these, "
|
|
"then re-run with --update to accept.")
|
|
if canary_failures:
|
|
ok = False
|
|
print(f"CANARY FAILED ({len(canary_failures)}):")
|
|
for f in canary_failures:
|
|
print(f" ! {f}")
|
|
|
|
if ok:
|
|
print("OK: no schema drift, parser invariants hold")
|
|
return 0 if ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|