@martinffx/atelier 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,800 @@
1
+ #!/usr/bin/env python3
2
+ """Extract the human-signal slice of agent session logs for one git branch.
3
+
4
+ Read-only on every log. Standard library only. Emits human turns, tool denials,
5
+ interrupts, question answers and a little nearby assistant text, each with its source
6
+ file and line. Ordinary tool output is left out. Log content is untrusted data: this
7
+ script never evaluates or follows it. Claude, Codex and OpenCode logs are read.
8
+
9
+ Verify mode (--verify <range-file> --line <n>) reads a quote on stdin and prints only
10
+ "match" or "no match", so a quote can be checked without reading the log.
11
+
12
+ See ../references/transcripts.md for the format facts this relies on.
13
+ """
14
+ import argparse
15
+ import glob
16
+ import json
17
+ import os
18
+ import re
19
+ import shutil
20
+ import sqlite3
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ from collections import Counter
25
+ from pathlib import Path
26
+ from datetime import datetime, timedelta, timezone
27
+
28
+ HUMAN_MAX = 1200
29
+ ASSISTANT_MAX = 300
30
+ ANSWER_MAX = 500
31
+ TARGET_MAX = 120
32
+ DENIAL_MARKER = re.compile(r"the user said:\s*\n?", re.IGNORECASE)
33
+ DENIAL_BOILERPLATE = "The user doesn't want to proceed"
34
+ # origin.kind values known to be written by something other than the human
35
+ NON_HUMAN_KINDS = {"task-notification", "peer", "coordinator"}
36
+
37
+
38
+ def trunc(text, limit, tail=False):
39
+ text = (text or "").strip()
40
+ if len(text) <= limit:
41
+ return text
42
+ extra = len(text) - limit
43
+ if tail:
44
+ return "[%d more chars]..." % extra + text[-limit:]
45
+ return text[:limit] + "...[%d more chars]" % extra
46
+
47
+
48
+ def list_worktrees(repo):
49
+ """[(path, branch or None)] for the repo's git worktrees, main one first (empty if git
50
+ is unavailable). A detached worktree has no branch."""
51
+ try:
52
+ out = subprocess.run(["git", "-C", repo, "worktree", "list", "--porcelain"],
53
+ capture_output=True, text=True, timeout=10).stdout
54
+ except (OSError, subprocess.SubprocessError):
55
+ return []
56
+ found = []
57
+ for line in out.splitlines():
58
+ if line.startswith("worktree "):
59
+ found.append([line[len("worktree "):], None])
60
+ elif line.startswith("branch ") and found:
61
+ found[-1][1] = line[len("branch "):].replace("refs/heads/", "", 1)
62
+ return [tuple(w) for w in found]
63
+
64
+
65
+ def under(path, roots):
66
+ return any(path == r or path.startswith(r.rstrip("/") + "/") for r in roots)
67
+
68
+
69
+ def encode_project_dir(path):
70
+ return re.sub(r"[^A-Za-z0-9]", "-", path)
71
+
72
+
73
+ def claude_project_dirs(root, roots):
74
+ """Project dirs for the repo, its in-repo worktrees, and any listed worktree."""
75
+ if not os.path.isdir(root):
76
+ return []
77
+ enc = [encode_project_dir(p) for p in roots]
78
+ found = []
79
+ for name in sorted(os.listdir(root)):
80
+ full = os.path.join(root, name)
81
+ if not os.path.isdir(full):
82
+ continue
83
+ if any(name == e or name.startswith(e + "--") for e in enc):
84
+ found.append(full)
85
+ return found
86
+
87
+
88
+ def _json_obj(raw):
89
+ """The JSON object in `raw`, or None for bad JSON and non-object values."""
90
+ try:
91
+ value = json.loads(raw)
92
+ except (TypeError, ValueError, RecursionError):
93
+ return None
94
+ return value if isinstance(value, dict) else None
95
+
96
+
97
+ def _clean(value):
98
+ return re.sub(r"[^\w.-]", "?", str(value))[:30]
99
+
100
+
101
+ def _printable(text):
102
+ return re.sub(r"[\x00-\x1f\x7f-\x9f\u2028\u2029]", "?", text)
103
+
104
+
105
+ def _warn_count(warnings, found, template):
106
+ """Add a counted warning. `found` is a number (template takes the count) or a Counter
107
+ of names (template takes the count and the sorted names)."""
108
+ if isinstance(found, Counter):
109
+ if found:
110
+ warnings.append(template % (sum(found.values()), ", ".join(sorted(found))))
111
+ elif found:
112
+ warnings.append(template % found)
113
+
114
+
115
+ def read_jsonl(path):
116
+ """Rows by line. Bad JSON and non-object rows keep their slot as None."""
117
+ with open(path, encoding="utf-8", errors="replace") as fh:
118
+ return [_json_obj(line) for line in fh]
119
+
120
+
121
+ def short_target(inp):
122
+ if not isinstance(inp, dict):
123
+ return ""
124
+ for key in ("command", "file_path", "path", "filePath", "pattern", "description"):
125
+ if isinstance(inp.get(key), str):
126
+ return trunc(inp[key], TARGET_MAX)
127
+ return ""
128
+
129
+
130
+ class Collector:
131
+ """Builds records in line order, adding nearby assistant text before each event."""
132
+
133
+ def __init__(self, fname):
134
+ self.fname = fname
135
+ self.records = []
136
+ self.pending_assistant = None
137
+
138
+ def assistant(self, line, text):
139
+ if text and text.strip():
140
+ self.pending_assistant = (line, text)
141
+
142
+ def event(self, kind, line, text, lead_in=False, **extra):
143
+ if self.pending_assistant:
144
+ aline, atext = self.pending_assistant
145
+ self.pending_assistant = None
146
+ self._add("assistant", aline, trunc(atext, ASSISTANT_MAX, tail=True), lead_in)
147
+ self._add(kind, line, text, lead_in, **extra)
148
+
149
+ def _add(self, kind, line, text, lead_in, **extra):
150
+ rec = {"kind": kind, "file": self.fname, "line": line, "text": text}
151
+ rec.update({k: v for k, v in extra.items() if v not in (None, "")})
152
+ if lead_in:
153
+ rec["lead_in"] = True
154
+ self.records.append(rec)
155
+
156
+
157
+ # ---------------------------------------------------------------- Claude
158
+
159
+ def _blocks(row):
160
+ msg = row.get("message")
161
+ content = msg.get("content") if isinstance(msg, dict) else None
162
+ if isinstance(content, str):
163
+ return [{"type": "text", "text": content}]
164
+ return [b for b in content if isinstance(b, dict)] if isinstance(content, list) else []
165
+
166
+
167
+ def _text_of(blocks):
168
+ return "\n".join(b.get("text", "") for b in blocks if b.get("type") == "text"
169
+ and isinstance(b.get("text"), str))
170
+
171
+
172
+ def _branch(row):
173
+ b = row.get("gitBranch") if row else None
174
+ return b if isinstance(b, str) and b else None
175
+
176
+
177
+ def claude_ranges(rows, branch, base):
178
+ """Return [(start, end, lead_in_start, borders_head)] using the per-line gitBranch."""
179
+ segs = [] # [branch, first_line]
180
+ for i, row in enumerate(rows, 1):
181
+ if not row or row.get("isSidechain"):
182
+ continue
183
+ b = _branch(row)
184
+ if b and (not segs or segs[-1][0] != b):
185
+ segs.append([b, i])
186
+ out = []
187
+ for n, (b, first) in enumerate(segs):
188
+ if b != branch:
189
+ continue
190
+ end = segs[n + 1][1] - 1 if n + 1 < len(segs) else len(rows)
191
+ lead = segs[n - 1][1] if n > 0 and segs[n - 1][0] == base else None
192
+ near = [segs[k][0] for k in (n - 1, n + 1) if 0 <= k < len(segs)]
193
+ out.append((first, end, lead, "HEAD" in near))
194
+ return out
195
+
196
+
197
+ def _claude_attachment(col, row, i, lead):
198
+ """Human prompts queued while the agent was busy arrive as attachment rows."""
199
+ att = row.get("attachment")
200
+ if not isinstance(att, dict) or att.get("type") != "queued_command":
201
+ return
202
+ origin = att.get("origin")
203
+ if not isinstance(origin, dict) or origin.get("kind") != "human":
204
+ return
205
+ prompt = att.get("prompt")
206
+ if isinstance(prompt, str):
207
+ text = prompt
208
+ elif isinstance(prompt, list):
209
+ text = _text_of([b for b in prompt if isinstance(b, dict)])
210
+ else:
211
+ text = ""
212
+ text = text.strip()
213
+ if text and not text.startswith("<task-notification"):
214
+ col.event("human", i, trunc(text, HUMAN_MAX), lead)
215
+
216
+
217
+ def _claude_records(rows, fname, start, end, lead_start, roots, stats):
218
+ names = {}
219
+ for row in rows:
220
+ if row and row.get("type") == "assistant":
221
+ for b in _blocks(row):
222
+ if b.get("type") == "tool_use" and isinstance(b.get("id"), str):
223
+ names[b["id"]] = (b.get("name"), b.get("input"))
224
+ col = Collector(fname)
225
+ lo = lead_start or start
226
+ for i in range(lo, end + 1):
227
+ row = rows[i - 1]
228
+ if not row or row.get("isSidechain"):
229
+ continue
230
+ cwd = row.get("cwd")
231
+ if isinstance(cwd, str) and cwd and not under(cwd, roots):
232
+ stats["cwd_skipped"] += 1
233
+ continue
234
+ lead = i < start
235
+ rtype = row.get("type")
236
+ if rtype == "assistant":
237
+ col.assistant(i, _text_of(_blocks(row)))
238
+ continue
239
+ if rtype == "attachment":
240
+ _claude_attachment(col, row, i, lead)
241
+ continue
242
+ if rtype != "user":
243
+ continue
244
+ blocks = _blocks(row)
245
+ results = [b for b in blocks if b.get("type") == "tool_result"]
246
+ if results:
247
+ _claude_result(col, row, results[0], names, i, lead)
248
+ continue
249
+ if row.get("isMeta") or row.get("isCompactSummary"):
250
+ continue
251
+ text = _text_of(blocks).strip()
252
+ if not text:
253
+ origin = row.get("origin")
254
+ if not isinstance(origin, dict) or origin.get("kind") == "human":
255
+ stats["no_text"] += 1
256
+ continue
257
+ if text.startswith("[Request interrupted by user"):
258
+ col.event("interrupt", i, text, lead)
259
+ continue
260
+ origin = row.get("origin")
261
+ if isinstance(origin, dict):
262
+ kind = origin.get("kind")
263
+ if kind != "human":
264
+ if kind not in NON_HUMAN_KINDS:
265
+ stats["unknown_kinds"][_clean(kind)] += 1
266
+ continue
267
+ elif text.startswith("<"):
268
+ continue
269
+ col.event("human", i, trunc(text, HUMAN_MAX), lead)
270
+ return col.records
271
+
272
+
273
+ def _claude_result(col, row, block, names, i, lead):
274
+ tid = block.get("tool_use_id")
275
+ name, inp = names.get(tid, (None, None)) if isinstance(tid, str) else (None, None)
276
+ tur = row.get("toolUseResult")
277
+ content = block.get("content")
278
+ if not isinstance(content, str):
279
+ content = _text_of([b for b in content if isinstance(b, dict)]) if isinstance(content, list) else ""
280
+ denied = row.get("toolDenialKind") == "user-rejected" or tur == "User rejected tool use"
281
+ if denied:
282
+ m = DENIAL_MARKER.search(content)
283
+ if m:
284
+ text = content[m.end():].strip()
285
+ elif content.startswith(DENIAL_BOILERPLATE):
286
+ text = ""
287
+ else:
288
+ text = content
289
+ col.event("denial", i, trunc(text, HUMAN_MAX), lead, tool=name, target=short_target(inp))
290
+ elif name == "AskUserQuestion" and isinstance(tur, dict):
291
+ parts = []
292
+ notes = tur.get("annotations") if isinstance(tur.get("annotations"), dict) else {}
293
+ answers = tur.get("answers") if isinstance(tur.get("answers"), dict) else {}
294
+ for q, a in answers.items():
295
+ piece = "%s => %s" % (trunc(str(q), 120), a)
296
+ note = notes[q].get("notes") if isinstance(notes.get(q), dict) else None
297
+ if note:
298
+ piece += " (note: %s)" % note
299
+ parts.append(piece)
300
+ col.event("answer", i, trunc("; ".join(parts), ANSWER_MAX), lead)
301
+
302
+
303
+ def extract_claude(root, roots, branch, base):
304
+ warnings = []
305
+ dirs = claude_project_dirs(root, roots)
306
+ stats = {"cwd_skipped": 0, "unknown_kinds": Counter(), "no_text": 0}
307
+ ranges, scanned, skipped = [], 0, 0
308
+ head_borders = head_only = no_branch = 0
309
+ for d in dirs:
310
+ skipped += len(glob.glob(os.path.join(d, "*", "subagents", "*.jsonl")))
311
+ for path in sorted(glob.glob(os.path.join(d, "*.jsonl"))):
312
+ scanned += 1
313
+ try:
314
+ rows = read_jsonl(path)
315
+ branches = {_branch(r) for r in rows if r and not r.get("isSidechain")} - {None}
316
+ if any(rows) and not branches:
317
+ no_branch += 1
318
+ elif branches == {"HEAD"}:
319
+ head_only += 1
320
+ found = []
321
+ for start, end, lead, head in claude_ranges(rows, branch, base):
322
+ ts = rows[start - 1].get("timestamp")
323
+ head_borders += 1 if head else 0
324
+ found.append({"file": path, "start": start, "end": end, "lead_in_start": lead,
325
+ "started": ts if isinstance(ts, str) else "",
326
+ "records": _claude_records(rows, os.path.basename(path), start,
327
+ end, lead, roots, stats)})
328
+ except Exception as exc: # one bad file must not hide the others
329
+ warnings.append("%s could not be read (%s)" % (os.path.basename(path),
330
+ type(exc).__name__))
331
+ continue
332
+ ranges += found
333
+ _warn_count(warnings, head_borders, "%d branch range(s) border rows whose gitBranch is HEAD; "
334
+ "sessions from a detached HEAD may be missing")
335
+ _warn_count(warnings, head_only, "%d log file(s) hold only gitBranch HEAD rows and cannot be "
336
+ "matched to the branch")
337
+ _warn_count(warnings, no_branch, "%d log file(s) have rows but no gitBranch and cannot be "
338
+ "matched to the branch")
339
+ _warn_count(warnings, stats["unknown_kinds"], "%d user row(s) have an unknown origin.kind "
340
+ "(%s) and were not treated as human")
341
+ _warn_count(warnings, stats["no_text"], "%d user row(s) had no readable text")
342
+ _warn_count(warnings, stats["cwd_skipped"], "%d row(s) skipped because their cwd is outside "
343
+ "the repo and its worktrees")
344
+ ranges.sort(key=lambda r: (r["started"], r["file"], r["start"]))
345
+ notes = ["%d subagent log(s) skipped" % skipped] if skipped else []
346
+ return {"ranges": ranges, "project_dirs": len(dirs), "files_scanned": scanned,
347
+ "notes": notes, "warnings": warnings}
348
+
349
+
350
+ # ---------------------------------------------------------------- Codex
351
+
352
+ def _codex_text(payload):
353
+ content = payload.get("content")
354
+ if not isinstance(content, list):
355
+ return ""
356
+ return "\n".join(b.get("text", "") for b in content
357
+ if isinstance(b, dict) and isinstance(b.get("text"), str))
358
+
359
+
360
+ def _codex_records(rows, fname):
361
+ names = {}
362
+ for row in rows:
363
+ p = (row or {}).get("payload")
364
+ if isinstance(p, dict) and p.get("type") == "function_call" and isinstance(p.get("call_id"), str):
365
+ names[p["call_id"]] = p.get("name")
366
+ col = Collector(fname)
367
+ for i, row in enumerate(rows, 1):
368
+ p = (row or {}).get("payload")
369
+ if not isinstance(p, dict):
370
+ continue
371
+ t = p.get("type")
372
+ if row.get("type") == "response_item" and t == "message":
373
+ text = _codex_text(p).strip()
374
+ if p.get("role") == "assistant":
375
+ col.assistant(i, text)
376
+ elif p.get("role") == "user" and text and not text.startswith(("<", "# AGENTS.md")):
377
+ col.event("human", i, trunc(text, HUMAN_MAX))
378
+ elif row.get("type") == "event_msg" and t == "turn_aborted":
379
+ col.event("interrupt", i, str(p.get("reason") or "interrupted"))
380
+ elif (t == "function_call_output" and isinstance(p.get("call_id"), str)
381
+ and names.get(p["call_id"]) == "request_user_input"):
382
+ col.event("answer", i, trunc(str(p.get("output")), ANSWER_MAX))
383
+ return col.records
384
+
385
+
386
+ def extract_codex(root, roots, branch):
387
+ warnings = []
388
+ ranges, scanned, bad_header = [], 0, 0
389
+ for path in sorted(glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True)):
390
+ try:
391
+ with open(path, encoding="utf-8", errors="replace") as fh:
392
+ first = json.loads(fh.readline())
393
+ except OSError as exc:
394
+ warnings.append("%s could not be read (%s)" % (os.path.basename(path), type(exc).__name__))
395
+ continue
396
+ except (ValueError, RecursionError):
397
+ first = None
398
+ meta = first.get("payload") if isinstance(first, dict) and first.get("type") == "session_meta" else None
399
+ if not isinstance(meta, dict):
400
+ bad_header += 1
401
+ continue
402
+ scanned += 1
403
+ git = meta.get("git") if isinstance(meta.get("git"), dict) else {}
404
+ if (git.get("branch") != branch or isinstance(meta.get("source"), dict)
405
+ or meta.get("thread_source") == "automation"
406
+ or not under(str(meta.get("cwd") or ""), roots)):
407
+ continue
408
+ try:
409
+ rows = read_jsonl(path)
410
+ records = _codex_records(rows, os.path.basename(path))
411
+ except Exception as exc: # one bad file must not hide the others
412
+ warnings.append("%s could not be read (%s)" % (os.path.basename(path), type(exc).__name__))
413
+ continue
414
+ ts = meta.get("timestamp")
415
+ ranges.append({"file": path, "start": 1, "end": len(rows), "lead_in_start": None,
416
+ "started": ts if isinstance(ts, str) else "", "records": records})
417
+ _warn_count(warnings, bad_header, "%d .jsonl file(s) skipped for an unrecognised header")
418
+ ranges.sort(key=lambda r: (r["started"], r["file"]))
419
+ return {"ranges": ranges, "project_dirs": 0, "files_scanned": scanned,
420
+ "notes": ["the branch match is approximate (the branch is recorded once, not per "
421
+ "turn).", "Codex tool denials are not extracted"],
422
+ "warnings": warnings}
423
+
424
+
425
+ # ---------------------------------------------------------------- OpenCode
426
+
427
+ OC_TYPES = {"user", "assistant", "synthetic", "system", "compaction", "idle",
428
+ "agent-switched", "model-switched"}
429
+ DISMISSED = "The user dismissed"
430
+ RULE_REFUSAL = "The user has specified a rule which prevents"
431
+ OC_ERRORS = {"tool.execution", "tool.interrupted", "aborted", "unknown"}
432
+ EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
433
+ DATE_ONLY = re.compile(r"^\d{4}-\d{2}-\d{2}$")
434
+
435
+
436
+ def _parse_time(text, end_of_day):
437
+ """Epoch ms from an ISO date or datetime. A trailing Z is accepted (Python 3.9 rejects it)
438
+ and a missing offset means UTC. A date-only value is the start of that day, or its end."""
439
+ text = text.strip()
440
+ # accept git's %ai form too: a space for the T, a space before the offset, +HHMM
441
+ text = re.sub(r"^(\d{4}-\d{2}-\d{2}) (?=\d)", r"\1T", text)
442
+ text = re.sub(r"\s+([+-]\d{2}:?\d{2})$", r"\1", text)
443
+ text = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", text)
444
+ try:
445
+ dt = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith(("Z", "z")) else text)
446
+ except ValueError:
447
+ raise argparse.ArgumentTypeError("not an ISO date or datetime: %r" % text[:40])
448
+ if dt.tzinfo is None:
449
+ dt = dt.replace(tzinfo=timezone.utc)
450
+ ms = (dt - EPOCH) // timedelta(milliseconds=1)
451
+ if end_of_day and DATE_ONLY.match(text):
452
+ ms += 24 * 3600 * 1000 - 1
453
+ return ms
454
+
455
+
456
+ def parse_since(text):
457
+ return _parse_time(text, False)
458
+
459
+
460
+ def parse_until(text):
461
+ return _parse_time(text, True)
462
+
463
+
464
+ def iso_ms(ms):
465
+ try:
466
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
467
+ except (OverflowError, OSError, ValueError):
468
+ return ""
469
+
470
+
471
+ def _is_int(v):
472
+ return isinstance(v, int) and not isinstance(v, bool)
473
+
474
+
475
+ def _oc_answers(answers):
476
+ parts = []
477
+ for a in answers:
478
+ parts.append(", ".join(str(x) for x in a) if isinstance(a, list) else str(a))
479
+ return "; ".join(parts)
480
+
481
+
482
+ def _oc_assistant(col, seq, data, stats):
483
+ content = data.get("content")
484
+ interrupted = False
485
+ for item in content if isinstance(content, list) else []:
486
+ if not isinstance(item, dict):
487
+ continue
488
+ if item.get("type") == "text" and isinstance(item.get("text"), str):
489
+ col.assistant(seq, item["text"])
490
+ elif item.get("type") == "tool" and isinstance(item.get("state"), dict):
491
+ state = item["state"]
492
+ name = item.get("name") if isinstance(item.get("name"), str) else None
493
+ meta = state.get("metadata")
494
+ answers = meta.get("answers") if isinstance(meta, dict) else None
495
+ if name == "question" and isinstance(answers, list):
496
+ col.event("answer", seq, trunc(_oc_answers(answers), ANSWER_MAX))
497
+ err = state.get("error")
498
+ if not isinstance(err, dict):
499
+ continue
500
+ etype, msg = err.get("type"), err.get("message")
501
+ msg = msg if isinstance(msg, str) else ""
502
+ if etype == "tool.execution":
503
+ if name == "question" and msg.startswith(DISMISSED):
504
+ col.event("answer", seq, "(dismissed)")
505
+ elif msg.startswith(RULE_REFUSAL):
506
+ # a configured permission rule refused the call, not a live human choice
507
+ col.event("denial", seq, trunc(msg, HUMAN_MAX), tool=name, rule=True,
508
+ target=short_target(state.get("input")))
509
+ # All 185 matches seen in real logs were permission or question rejections
510
+ # (for example PermissionRejectedError, QuestionRejectedError).
511
+ elif "reject" in msg.lower():
512
+ col.event("denial", seq, trunc(msg, HUMAN_MAX), tool=name,
513
+ target=short_target(state.get("input")))
514
+ elif etype in ("tool.interrupted", "aborted"):
515
+ if not interrupted:
516
+ col.event("interrupt", seq, etype)
517
+ interrupted = True
518
+ elif etype not in OC_ERRORS:
519
+ stats["unknown_errors"][_clean(etype)] += 1
520
+ err = data.get("error")
521
+ if isinstance(err, dict):
522
+ if err.get("type") == "aborted":
523
+ if not interrupted:
524
+ col.event("interrupt", seq, "aborted")
525
+ elif err.get("type") not in OC_ERRORS and err.get("type") is not None:
526
+ stats["unknown_errors"][_clean(err.get("type"))] += 1
527
+
528
+
529
+ def _opencode_records(rows, fname, stats):
530
+ col = Collector(fname)
531
+ for seq, mtype, raw in rows:
532
+ if not _is_int(seq):
533
+ continue
534
+ if mtype not in OC_TYPES:
535
+ stats["unknown_types"][_clean(mtype)] += 1
536
+ continue
537
+ data = _json_obj(raw)
538
+ if mtype == "user":
539
+ text = data.get("text") if data else None
540
+ if isinstance(text, str) and text.strip():
541
+ col.event("human", seq, trunc(text, HUMAN_MAX))
542
+ else:
543
+ stats["no_text"] += 1
544
+ continue
545
+ if data is not None and mtype == "assistant":
546
+ _oc_assistant(col, seq, data, stats)
547
+ return col.records
548
+
549
+
550
+ # review-decision: OpenCode sessions are matched by window overlap and every message in them is read — sessions are short-lived in practice, so out-of-window messages are rare. Reconsider if resumed long-running sessions become common or a retro reports Corrections from outside the branch's window.
551
+ def _in_window(created, updated, since, until):
552
+ if not _is_int(created):
553
+ return True # cannot be placed in the window; run() warns about the missing start
554
+ updated = updated if _is_int(updated) else created
555
+ return (until is None or created <= until) and (since is None or updated >= since)
556
+
557
+
558
+ def _opencode_uri(path):
559
+ """Read-only URI. With no side files, immutable=1 stops SQLite creating -wal/-shm files
560
+ when it opens a WAL-mode database."""
561
+ uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
562
+ if not (os.path.exists(path + "-wal") or os.path.exists(path + "-shm")):
563
+ uri += "&immutable=1"
564
+ return uri
565
+
566
+
567
+ def _opencode_session(conn, path, sid, directory, created):
568
+ rows = conn.execute("SELECT seq, type, data FROM session_message "
569
+ "WHERE session_id = ? ORDER BY seq", (sid,)).fetchall()
570
+ seqs = [r[0] for r in rows if _is_int(r[0])]
571
+ stats = {"unknown_types": Counter(), "unknown_errors": Counter(), "no_text": 0}
572
+ records = _opencode_records(rows, "%s#%s" % (os.path.basename(path), sid), stats)
573
+ return {"file": "%s#%s" % (path, sid), "start": min(seqs, default=0),
574
+ "end": max(seqs, default=0), "lead_in_start": None, "dir": directory,
575
+ "started": iso_ms(created) if _is_int(created) else "", "records": records}, stats
576
+
577
+
578
+ def _opencode_db(path, roots, skip, since, until, stats):
579
+ """(ranges, subagents skipped), or None when the database has no session_v2 table.
580
+ A session that cannot be read is counted in stats["failed"], not fatal."""
581
+ conn = sqlite3.connect(_opencode_uri(path), uri=True)
582
+ try:
583
+ conn.text_factory = lambda b: b.decode("utf-8", "replace")
584
+ found = conn.execute("SELECT name FROM sqlite_master WHERE type = ? AND name = ?",
585
+ ("table", "session_v2")).fetchone()
586
+ if not found:
587
+ return None
588
+ sessions = conn.execute("SELECT id, parent_id, directory, time_created, time_updated "
589
+ "FROM session_v2").fetchall()
590
+ ranges, subagents = [], 0
591
+ for sid, parent, directory, created, updated in sessions:
592
+ if (not isinstance(sid, str) or not isinstance(directory, str)
593
+ or not under(directory, roots) or under(directory, skip)
594
+ or not _in_window(created, updated, since, until)):
595
+ continue
596
+ if parent:
597
+ subagents += 1
598
+ continue
599
+ try:
600
+ rng, got = _opencode_session(conn, path, sid, directory, created)
601
+ except Exception as exc: # one bad session must not hide the others
602
+ stats["failed"][type(exc).__name__] += 1
603
+ continue
604
+ for key, counter in got.items():
605
+ stats[key] += counter
606
+ ranges.append(rng)
607
+ return ranges, subagents
608
+ finally:
609
+ conn.close()
610
+
611
+
612
+ def extract_opencode(root, roots, skip, since=None, until=None):
613
+ """`skip` holds the directories of linked worktrees checked out on another branch."""
614
+ warnings = []
615
+ stats = {"unknown_types": Counter(), "unknown_errors": Counter(), "no_text": 0,
616
+ "failed": Counter()}
617
+ ranges, scanned, subagents = [], 0, 0
618
+ for path in sorted(glob.glob(os.path.join(root, "*.db"))):
619
+ name = os.path.basename(path)
620
+ if os.path.exists(path + "-wal") and not os.path.exists(path + "-shm"):
621
+ warnings.append("%s has a WAL file but no -shm file and was skipped" % name)
622
+ continue
623
+ try:
624
+ got = _opencode_db(path, roots, skip, since, until, stats)
625
+ except Exception as exc: # one bad database must not hide the others
626
+ warnings.append("%s could not be read (%s)" % (name, type(exc).__name__))
627
+ continue
628
+ if got is None:
629
+ warnings.append("%s has no session_v2 table and was skipped" % name)
630
+ continue
631
+ scanned += 1
632
+ ranges += got[0]
633
+ subagents += got[1]
634
+ if since is None and until is None:
635
+ warnings.append("no --since/--until given, so every top-level session in the repo "
636
+ "directories matched")
637
+ _warn_count(warnings, stats["unknown_types"],
638
+ "%d message(s) have an unknown type (%s) and were skipped")
639
+ _warn_count(warnings, stats["unknown_errors"],
640
+ "%d error(s) have an unknown type (%s) and were not read")
641
+ _warn_count(warnings, stats["no_text"], "%d user row(s) had no readable text")
642
+ _warn_count(warnings, stats["failed"], "%d session(s) could not be read (%s)")
643
+ ranges.sort(key=lambda r: (r["started"], r["file"]))
644
+ notes = ["the branch match is approximate (OpenCode records no branch; sessions are matched "
645
+ "by directory and time window)."]
646
+ if subagents:
647
+ notes.append("%d subagent session(s) skipped" % subagents)
648
+ return {"ranges": ranges, "project_dirs": 0, "files_scanned": scanned,
649
+ "notes": notes, "warnings": warnings}
650
+
651
+
652
+ # ---------------------------------------------------------------- CLI
653
+
654
+ DEFAULT_ROOTS = {"claude": "~/.claude/projects", "codex": "~/.codex/sessions",
655
+ "opencode": "~/.local/share/opencode"}
656
+
657
+
658
+ def describe(r):
659
+ where = "%s:%d-%d" % (r["file"], r["start"], r["end"])
660
+ if r["lead_in_start"]:
661
+ where += " (lead-in from line %d)" % r["lead_in_start"]
662
+ if r.get("dir"):
663
+ where += " (dir: %s)" % r["dir"][:200]
664
+ return _printable(where)
665
+
666
+
667
+ def _norm(s):
668
+ return " ".join(s.split())
669
+
670
+
671
+ def verify(range_file, line, quote):
672
+ """True if the quote is a substring of the text of the human-signal record at source
673
+ line `line`. Assistant records are context and never match. Raises OSError if the range
674
+ file cannot be read."""
675
+ q = _norm(quote)
676
+ if not q:
677
+ return False
678
+ with open(range_file, encoding="utf-8", errors="replace") as fh:
679
+ for raw in fh:
680
+ rec = _json_obj(raw)
681
+ if (rec and rec.get("line") == line and rec.get("kind") != "assistant"
682
+ and isinstance(rec.get("text"), str) and q in _norm(rec["text"])):
683
+ return True
684
+ return False
685
+
686
+
687
+ def _write_ranges(out, useful):
688
+ files, total = [], 0
689
+ for n, r in enumerate(useful, 1):
690
+ path = os.path.join(out, "range-%03d.jsonl" % n)
691
+ body = "".join(json.dumps(rec, ensure_ascii=False, separators=(",", ":")) + "\n"
692
+ for rec in r["records"])
693
+ data = body.encode("utf-8", "backslashreplace")
694
+ with open(path, "wb") as fh:
695
+ fh.write(data)
696
+ total += len(data)
697
+ files.append(path)
698
+ return files, total
699
+
700
+
701
+ def run(args):
702
+ repo = os.path.abspath(os.path.expanduser(args.repo))
703
+ root = os.path.expanduser(args.projects_dir or DEFAULT_ROOTS[args.harness])
704
+ worktrees = list_worktrees(repo)
705
+ roots = [repo] + [path for path, _ in worktrees]
706
+ if args.harness == "claude":
707
+ res = extract_claude(root, roots, args.branch, args.base)
708
+ elif args.harness == "opencode":
709
+ # linked worktrees (not the first, main one) on a different branch are not this branch's
710
+ other = [path for n, (path, br) in enumerate(worktrees) if n and br and br != args.branch]
711
+ res = extract_opencode(root, roots, other, args.since, args.until)
712
+ else:
713
+ res = extract_codex(root, roots, args.branch)
714
+ warnings = [] if os.path.isdir(root) else ["log root %s does not exist" % root]
715
+ warnings += res["warnings"]
716
+ starts = sorted(r["started"] for r in res["ranges"] if r["started"])
717
+ _warn_count(warnings, sum(1 for r in res["ranges"] if not r["started"]),
718
+ "%d range(s) have no timestamp, so range start is unknown")
719
+
720
+ useful = [r for r in res["ranges"] if r["records"]]
721
+ lines = []
722
+ if not res["ranges"]:
723
+ lines.append("RESULT: no sessions found")
724
+ lines.append("harness: %s | repo: %s | branch: %s | log root: %s" % (
725
+ args.harness, repo, args.branch, root))
726
+ lines.append("scanned %d log files in %d project dirs; none matched the branch."
727
+ % (res["files_scanned"], res["project_dirs"]))
728
+ lines.append("range start: none")
729
+ lines += ["note: " + n for n in res["notes"]]
730
+ lines += ["warning: " + w for w in warnings]
731
+ lines.append("Fall back to git, PR and review evidence only.")
732
+ print("\n".join(lines))
733
+ return 0
734
+
735
+ out = os.path.abspath(tempfile.mkdtemp(prefix="code-retro-"))
736
+ try: # on any failure the caller gets no output: line, so remove what was written
737
+ files, total = _write_ranges(out, useful)
738
+ lines.append("RESULT: sessions found")
739
+ lines.append("harness: %s | repo: %s | branch: %s" % (args.harness, repo, args.branch))
740
+ lines.append("output: %s (%d bytes in %d range files)" % (out, total, len(useful)))
741
+ for path, r in zip(files, useful):
742
+ lines.append(" %s %s records=%d" % (path, describe(r), len(r["records"])))
743
+ for r in res["ranges"]:
744
+ if not r["records"]:
745
+ lines.append(" (no human-signal records) %s" % describe(r))
746
+ lines.append("range start: earliest %s | latest %s" % (
747
+ (starts[0], starts[-1]) if starts else ("unknown", "unknown")))
748
+ cov = "coverage: %d log files scanned" % res["files_scanned"]
749
+ if res["project_dirs"]:
750
+ cov += " in %d project dirs" % res["project_dirs"]
751
+ lines.append(cov + "; %d matched ranges" % len(res["ranges"]))
752
+ lines += ["note: " + n for n in res["notes"]]
753
+ lines += ["warning: " + w for w in warnings]
754
+ print("\n".join(lines))
755
+ except BaseException:
756
+ shutil.rmtree(out, ignore_errors=True)
757
+ raise
758
+ return 0
759
+
760
+
761
+ def main(argv=None):
762
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
763
+ ap.add_argument("--repo", help="path to the repository")
764
+ ap.add_argument("--branch")
765
+ ap.add_argument("--base", default="main", help="base branch whose contiguous lead-in is included (Claude)")
766
+ ap.add_argument("--harness", choices=sorted(DEFAULT_ROOTS), default="claude")
767
+ ap.add_argument("--projects-dir", help="override the log root (for fixtures)")
768
+ ap.add_argument("--since", type=parse_since, metavar="WHEN",
769
+ help="OpenCode: only sessions active at or after this ISO date or datetime")
770
+ ap.add_argument("--until", type=parse_until, metavar="WHEN",
771
+ help="OpenCode: only sessions started at or before this; a date means "
772
+ "the end of that day")
773
+ ap.add_argument("--verify", metavar="RANGE_FILE",
774
+ help="verify mode: read a quote on stdin, print match or no match")
775
+ ap.add_argument("--line", type=int, help="with --verify: the cited source line")
776
+ args = ap.parse_args(argv)
777
+
778
+ if args.verify:
779
+ if args.line is None:
780
+ ap.error("--verify needs --line")
781
+ try:
782
+ found = verify(args.verify, args.line, sys.stdin.read())
783
+ except OSError:
784
+ print("error: cannot read range file")
785
+ return 1
786
+ print("match" if found else "no match")
787
+ return 0
788
+ if not (args.repo and args.branch):
789
+ ap.error("--repo and --branch are required")
790
+ try:
791
+ return run(args)
792
+ except Exception as exc: # always give the caller a RESULT line
793
+ print("RESULT: extractor error")
794
+ print("error: %s" % type(exc).__name__)
795
+ print("Do not open the logs. Fall back to git, PR and review evidence only.")
796
+ return 1
797
+
798
+
799
+ if __name__ == "__main__":
800
+ sys.exit(main())