"""agent-transcripts-as-documentation.py - turn a coding-agent session log into a short, redacted Markdown record that can sit next to the commit it produced. What it does 1. Reads a Claude Code session file (JSONL, one object per line, or the JSON {"loglines": [...]} shape) the way simonw/claude-code-transcripts does at commit 316fd093aca3: only "user" and "assistant" lines are kept, and a user line whose content carries text (not only tool results) starts a new prompt. 2. Writes a record per prompt: the prompt, tool calls by name, files written or edited, shell commands, and the commits found in tool output. Commits are found two ways: with the regular expression copied verbatim from claude-code-transcripts (src/claude_code_transcripts/__init__.py, line 39, COMMIT_PATTERN) and with a broader one that also accepts git's "(root-commit)" marker, "detached HEAD" and any branch-name character. 3. Redacts before writing: every exact value listed in --secrets (an env-style file: NAME=VALUE per line), plus a pattern pass for NAME=VALUE lines whose NAME contains KEY, TOKEN, SECRET or PASSWORD, and PEM private-key blocks. The pattern pass is a backstop, not a secret scanner. Modes python agent-transcripts-as-documentation.py Self-test. Downloads the two test sessions of claude-code-transcripts pinned at 316fd093aca3 into ./cct_fixtures (once), runs both commit patterns over the seven git summary lines listed in GIT_LINES (captured from git 2.51.2.windows.1 on 2026-10-15), runs the redaction on a constructed session, prints a summary and writes ./agent-transcripts-as-documentation.csv. python agent-transcripts-as-documentation.py --live-git Same, but first re-captures the git lines from a throwaway repository with the git on your PATH. python agent-transcripts-as-documentation.py SESSION.jsonl [--secrets .env] [--out record.md] Write the record for one session file (to stdout without --out). python agent-transcripts-as-documentation.py --hook [--secrets .env] [--dir docs/agent-sessions] For a Claude Code SessionEnd hook: reads the hook's JSON from stdin, takes transcript_path, session_id and cwd, and writes //.md. It never blocks and always exits 0. Needs Python 3.9+, standard library only. Network only in the self-test, on the first run (raw.githubusercontent.com, which serves no robots.txt rules); a descriptive User-Agent is sent. Caveat Anthropic's docs call the transcript entry format internal and say it "changes between versions", so this parser, like claude-code-transcripts, can break on any release. It reads; it never writes the log. """ from __future__ import annotations import argparse import csv import json import os import re import subprocess import sys import tempfile import time import urllib.request UA = "VibeCodeTextbook research script (contact@vibecodetextbook.com)" CCT_SHA = "316fd093aca3c9c0ad8aa70092711aee3adacc6e" CCT_FILES = ["tests/sample_session.jsonl", "tests/sample_session.json"] # Verbatim from claude-code-transcripts @ 316fd093aca3, src/claude_code_transcripts/__init__.py line 39. CCT_COMMIT_PATTERN = re.compile(r"\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)") # Broader: any branch text without "]", optional "(root-commit) ", 7-40 hex digits. BROAD_COMMIT_PATTERN = re.compile(r"^\[([^\]\n]+?) (?:\(root-commit\) )?([0-9a-f]{7,40})\] (.+)$", re.M) # The summary line git printed for each commit in a throwaway repository, 2026-10-15, git 2.51.2.windows.1. GIT_LINES = [ ("root commit on main", "[main (root-commit) eab8e32] First commit"), ("main", "[main f0c0b91] Second on main"), ("branch with a dot", "[release-1.2 5892f03] On a dotted branch"), ("branch with a slash", "[feature/login fe45fc0] On a slash branch"), ("detached HEAD", "[detached HEAD 7930034] On a detached HEAD"), ("non-ASCII branch", "[fix_ünï 1469ca2] Non-ASCII branch"), ("branch with a plus", "[v2+hotfix 1076f21] Plus branch"), ] SECRET_NAME = re.compile(r"^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*\s*=\s*)(\S.*)$", re.M | re.I) PEM_BLOCK = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S) # ---------------------------------------------------------------- reading def load_entries(path): """Return (kept entries, counts of every line type seen). Mirrors cct's _parse_jsonl_file / parse_session_file.""" seen = {} kept = [] if path.endswith(".jsonl"): with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: seen["(unparseable)"] = seen.get("(unparseable)", 0) + 1 continue t = obj.get("type", "(none)") seen[t] = seen.get(t, 0) + 1 if t in ("user", "assistant"): kept.append(obj) else: with open(path, encoding="utf-8") as f: data = json.load(f) for obj in data.get("loglines", []): t = obj.get("type", "(none)") seen[t] = seen.get(t, 0) + 1 if t in ("user", "assistant"): kept.append(obj) return kept, seen def text_of(content): """Prompt text of a user message: a string, or the text blocks of a list (tool results do not count).""" if isinstance(content, str): return content.strip() if isinstance(content, list): return "\n".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text").strip() return "" def result_text(block): """Tool-result content as text. cct's commit scan reads only the string form; this also joins list form.""" c = block.get("content", "") if isinstance(c, str): return c if isinstance(c, list): return "\n".join(b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text") return "" def find_commits(text, broad=True): if broad: return [(m.group(2), m.group(3).strip()) for m in BROAD_COMMIT_PATTERN.finditer(text)] return [(m.group(1), m.group(2).strip()) for m in CCT_COMMIT_PATTERN.finditer(text)] def summarise(entries): """Group entries into prompts the way cct's generate_html does, and collect what each prompt did.""" prompts = [] cur = None for e in entries: msg = e.get("message") or {} if not msg: continue content = msg.get("content", "") if e.get("type") == "user": t = text_of(content) if t: cur = {"prompt": t, "time": e.get("timestamp", ""), "tools": {}, "files": [], "commands": [], "commits_cct": [], "commits_broad": [], "continuation": bool(e.get("isCompactSummary"))} prompts.append(cur) continue if cur is None or not isinstance(content, list): continue for b in content: if not isinstance(b, dict): continue if b.get("type") == "tool_use": name = b.get("name", "Unknown") cur["tools"][name] = cur["tools"].get(name, 0) + 1 inp = b.get("input") or {} if name in ("Write", "Edit", "MultiEdit", "NotebookEdit") and inp.get("file_path"): if inp["file_path"] not in cur["files"]: cur["files"].append(inp["file_path"]) if name == "Bash" and inp.get("command"): cur["commands"].append(inp["command"]) elif b.get("type") == "tool_result": if isinstance(b.get("content"), str): # cct reads the string form only cur["commits_cct"] += find_commits(b["content"], broad=False) cur["commits_broad"] += find_commits(result_text(b), broad=True) return prompts # ---------------------------------------------------------------- redaction def load_secret_values(path): vals = [] if not path: return vals with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue v = line.split("=", 1)[1].strip().strip('"').strip("'") if len(v) >= 6: # very short values would redact ordinary words vals.append(v) return sorted(set(vals), key=len, reverse=True) def redact(text, secret_values=(), use_patterns=True): """Return (text, number of replacements).""" n = 0 for v in secret_values: c = text.count(v) if c: text = text.replace(v, "[REDACTED]") n += c if use_patterns: text, k = PEM_BLOCK.subn("[REDACTED PRIVATE KEY]", text) n += k k = 0 def repl(m): nonlocal k if m.group(2).strip() == "[REDACTED]": # already removed by the exact-value pass return m.group(0) k += 1 return m.group(1) + "[REDACTED]" text = SECRET_NAME.sub(repl, text) n += k return text, n def count_leaks(text, secret_values): return sum(text.count(v) for v in secret_values) # ---------------------------------------------------------------- record def record_markdown(entries, seen, source_name, secret_values=()): prompts = summarise(entries) first = next((e for e in entries if e.get("type") == "user"), {}) lines = [f"# Agent session record: {source_name}", ""] lines.append(f"- session: {first.get('sessionId', 'unknown')}") lines.append(f"- branch at start: {first.get('gitBranch', 'unknown')}") times = [e.get("timestamp") for e in entries if e.get("timestamp")] if times: lines.append(f"- from {min(times)} to {max(times)}") dropped = {k: v for k, v in seen.items() if k not in ("user", "assistant")} lines.append(f"- log lines kept: {len(entries)}; other line types not shown: {dropped or 'none'}") lines.append("") for i, p in enumerate(prompts, 1): lines.append(f"## Prompt {i}{' (after compaction)' if p['continuation'] else ''} - {p['time']}") lines.append("") lines.append("> " + p["prompt"].replace("\n", "\n> ")) lines.append("") if p["tools"]: lines.append("- tools: " + ", ".join(f"{k} x{v}" for k, v in sorted(p["tools"].items()))) for f in p["files"]: lines.append(f"- wrote or edited: `{f}`") for c in p["commands"]: lines.append(f"- ran: `{c}`") for h, m in p["commits_broad"]: lines.append(f"- commit {h}: {m}") lines.append("") text, n = redact("\n".join(lines), secret_values) return text + f"\n\n", prompts, n # ---------------------------------------------------------------- self-test def fetch_fixtures(cache): os.makedirs(cache, exist_ok=True) out = [] for rel in CCT_FILES: dest = os.path.join(cache, os.path.basename(rel)) if not os.path.exists(dest): url = f"https://raw.githubusercontent.com/simonw/claude-code-transcripts/{CCT_SHA}/{rel}" req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=40) as r: data = r.read() with open(dest, "wb") as f: f.write(data) time.sleep(1) out.append(dest) return out def capture_git_lines(): """Re-make the seven commits in a throwaway repository and return git's summary lines.""" steps = [ ("root commit on main", None, "First commit", True), ("main", None, "Second on main", False), ("branch with a dot", ["checkout", "-q", "-b", "release-1.2"], "On a dotted branch", False), ("branch with a slash", ["checkout", "-q", "-b", "feature/login"], "On a slash branch", False), ("detached HEAD", ["checkout", "-q", "--detach"], "On a detached HEAD", False), ("non-ASCII branch", ["checkout", "-q", "-b", "fix_ünï"], "Non-ASCII branch", False), ("branch with a plus", ["checkout", "-q", "-b", "v2+hotfix"], "Plus branch", False), ] out = [] with tempfile.TemporaryDirectory() as d: def git(*a): return subprocess.run(["git", *a], cwd=d, capture_output=True, text=True, encoding="utf-8").stdout git("init", "-q", "-b", "main", ".") git("config", "user.email", "probe@invalid") git("config", "user.name", "probe") for label, pre, msg, first in steps: if pre: git(*pre) if first: with open(os.path.join(d, "a.txt"), "w") as f: f.write("a\n") git("add", "a.txt") o = git("commit", "-m", msg) else: o = git("commit", "--allow-empty", "-m", msg) out.append((label, o.splitlines()[0])) return out CONSTRUCTED_ENV = "DB_PASSWORD=hunter2-correct-horse\nPAYMENT_API_KEY=cv-0001-constructed-not-a-real-key\nDEBUG=true\n" def constructed_session(): """A hand-built three-prompt session: the agent reads .env, echoes one value into a curl command, commits.""" secret = "cv-0001-constructed-not-a-real-key" return [ {"type": "user", "timestamp": "2026-10-15T10:00:00Z", "sessionId": "constructed", "gitBranch": "main", "message": {"role": "user", "content": "Why does the payment test fail?"}}, {"type": "assistant", "timestamp": "2026-10-15T10:00:04Z", "message": {"role": "assistant", "content": [ {"type": "tool_use", "id": "t1", "name": "Read", "input": {"file_path": "/repo/.env"}}]}}, {"type": "user", "timestamp": "2026-10-15T10:00:05Z", "message": {"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t1", "content": CONSTRUCTED_ENV}]}}, {"type": "assistant", "timestamp": "2026-10-15T10:00:09Z", "message": {"role": "assistant", "content": [ {"type": "tool_use", "id": "t2", "name": "Bash", "input": {"command": f"curl -s -H 'Authorization: Bearer {secret}' https://api.invalid/v1/ping"}}]}}, {"type": "user", "timestamp": "2026-10-15T10:00:11Z", "message": {"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t2", "content": "401 key expired"}]}}, {"type": "user", "timestamp": "2026-10-15T10:01:00Z", "message": {"role": "user", "content": "Rotate it in the test config and commit."}}, {"type": "assistant", "timestamp": "2026-10-15T10:01:06Z", "message": {"role": "assistant", "content": [ {"type": "tool_use", "id": "t3", "name": "Edit", "input": {"file_path": "/repo/tests/conftest.py"}}, {"type": "tool_use", "id": "t4", "name": "Bash", "input": {"command": "git commit -am 'Use fixture key in payment test'"}}]}}, {"type": "user", "timestamp": "2026-10-15T10:01:09Z", "message": {"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t4", "content": [ {"type": "text", "text": "[release-1.2 9f3c2ab] Use fixture key in payment test\n 1 file changed"}]}]}}, ] def full_dump(entries): """Everything a naive export would publish: prompts, tool inputs and tool outputs, as text.""" parts = [] for e in entries: c = (e.get("message") or {}).get("content", "") if isinstance(c, str): parts.append(c) continue for b in c: if b.get("type") == "text": parts.append(b.get("text", "")) elif b.get("type") == "tool_use": parts.append(json.dumps(b.get("input", {}))) elif b.get("type") == "tool_result": parts.append(result_text(b)) return "\n".join(parts) def selftest(args): rows = [] def row(section, item, value, unit, note=""): rows.append({"section": section, "item": item, "value": value, "unit": unit, "note": note}) print(f"# {section:<9} {item:<44} {value} {unit}") for path in fetch_fixtures(args.cache): name = os.path.basename(path) entries, seen = load_entries(path) prompts = summarise(entries) row("fixture", f"{name}: lines or loglines read", sum(seen.values()), "lines", f"cct @ {CCT_SHA[:12]}") row("fixture", f"{name}: kept (user + assistant)", len(entries), "lines") row("fixture", f"{name}: prompts", len(prompts), "prompts") row("fixture", f"{name}: tool calls", sum(sum(p['tools'].values()) for p in prompts), "calls") row("fixture", f"{name}: files written or edited", len({f for p in prompts for f in p['files']}), "files") row("fixture", f"{name}: commits, cct pattern", sum(len(p['commits_cct']) for p in prompts), "commits") row("fixture", f"{name}: commits, broad pattern", sum(len(p['commits_broad']) for p in prompts), "commits") git_lines = capture_git_lines() if args.live_git else GIT_LINES cct_hits = broad_hits = 0 for label, line in git_lines: a = bool(find_commits(line, broad=False)) b = bool(find_commits(line, broad=True)) cct_hits += a broad_hits += b row("git", f"{label}", f"{'match' if a else 'miss'}/{'match' if b else 'miss'}", "cct/broad", line) row("git", "summary lines matched, cct pattern", cct_hits, f"of {len(git_lines)}", "live capture" if args.live_git else "git 2.51.2.windows.1, 2026-10-15") row("git", "summary lines matched, broad pattern", broad_hits, f"of {len(git_lines)}") entries = constructed_session() secret_vals = ["hunter2-correct-horse", "cv-0001-constructed-not-a-real-key"] dump = full_dump(entries) row("redact", "secret occurrences in a full export", count_leaks(dump, secret_vals), "occurrences", "constructed") patterned, n1 = redact(dump, (), use_patterns=True) row("redact", "left after the pattern pass alone", count_leaks(patterned, secret_vals), "occurrences", f"{n1} replacements") exact, n2 = redact(dump, secret_vals, use_patterns=True) row("redact", "left after exact values + patterns", count_leaks(exact, secret_vals), "occurrences", f"{n2} replacements") rec, prompts, n3 = record_markdown(entries, {"user": 5, "assistant": 3}, "constructed", secret_vals) row("redact", "left in the Markdown record", count_leaks(rec, secret_vals), "occurrences", f"{n3} replacements") row("record", "constructed session: prompts", len(prompts), "prompts") row("record", "constructed: commits, cct pattern", sum(len(p['commits_cct']) for p in prompts), "commits", "list-form tool result on a dotted branch") row("record", "constructed: commits, broad pattern", sum(len(p['commits_broad']) for p in prompts), "commits") with open(args.csv, "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=["section", "item", "value", "unit", "note"]) w.writeheader() w.writerows(rows) print(f"# wrote {args.csv} ({len(rows)} rows)") return rows # ---------------------------------------------------------------- hook def hook_mode(args): try: data = json.load(sys.stdin) tp, sid, cwd = data.get("transcript_path"), data.get("session_id", "session"), data.get("cwd", ".") if not tp or not os.path.exists(tp): return 0 entries, seen = load_entries(tp) rec, _, _ = record_markdown(entries, seen, os.path.basename(tp), load_secret_values(args.secrets)) outdir = os.path.join(cwd, args.dir) os.makedirs(outdir, exist_ok=True) with open(os.path.join(outdir, f"{sid}.md"), "w", encoding="utf-8") as f: f.write(rec) except Exception as exc: # a SessionEnd hook must never get in the way print(f"transcript record skipped: {exc}", file=sys.stderr) return 0 def main(argv=None): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("session", nargs="?") ap.add_argument("--secrets") ap.add_argument("--out") ap.add_argument("--hook", action="store_true") ap.add_argument("--dir", default="docs/agent-sessions") ap.add_argument("--live-git", action="store_true") ap.add_argument("--cache", default="cct_fixtures") ap.add_argument("--csv", default="agent-transcripts-as-documentation.csv") args = ap.parse_args(argv) if args.hook: return hook_mode(args) if args.session: entries, seen = load_entries(args.session) rec, _, _ = record_markdown(entries, seen, os.path.basename(args.session), load_secret_values(args.secrets)) if args.out: with open(args.out, "w", encoding="utf-8") as f: f.write(rec) else: print(rec) return 0 selftest(args) return 0 if __name__ == "__main__": sys.exit(main())