@iseoane/agent-depot 0.2.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +465 -0
  4. package/package.json +69 -0
  5. package/skills/doctor-md-agents/README.md +51 -0
  6. package/skills/doctor-md-agents/SKILL.md +275 -0
  7. package/skills/doctor-md-agents/references/rule-model.md +56 -0
  8. package/skills/doctor-md-agents/references/structure.md +89 -0
  9. package/skills/doctor-md-agents/scorers/adherence.md +73 -0
  10. package/skills/doctor-md-agents/scorers/rule-value.md +49 -0
  11. package/skills/doctor-md-agents/scripts/collect_sessions.py +1135 -0
  12. package/skills/doctor-md-agents/scripts/extract_rules.py +704 -0
  13. package/skills/doctor-md-agents/scripts/render_report.py +644 -0
  14. package/skills/doctor-md-agents/scripts/scan_corrections.py +781 -0
  15. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +780 -0
  16. package/skills/doctor-md-agents/scripts/test_extract_rules.py +530 -0
  17. package/skills/doctor-md-agents/scripts/test_render_report.py +383 -0
  18. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +343 -0
  19. package/skills/doctor-md-agents/scripts/test_verify_citations.py +236 -0
  20. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +507 -0
  21. package/skills/doctor-md-agents/scripts/verify_citations.py +217 -0
  22. package/skills/doctor-md-skill/README.md +27 -0
  23. package/skills/doctor-md-skill/SKILL.md +212 -0
  24. package/skills/doctor-md-skill/assets/pierre-diffs.js +1531 -0
  25. package/skills/doctor-md-skill/references/skill-improvements.md +32 -0
  26. package/skills/doctor-md-skill/references/supported-harnesses.md +44 -0
  27. package/skills/doctor-md-skill/scorers/code-quality.md +41 -0
  28. package/skills/doctor-md-skill/scorers/efficiency.md +38 -0
  29. package/skills/doctor-md-skill/scripts/collect_sessions.py +1112 -0
  30. package/skills/doctor-md-skill/scripts/detect_machine_sessions.py +315 -0
  31. package/skills/doctor-md-skill/scripts/render_report.py +545 -0
  32. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +593 -0
  33. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +181 -0
  34. package/skills/doctor-md-skill/scripts/test_render_report.py +241 -0
  35. package/skills/doctor-md-skill/scripts/test_verify_citations.py +196 -0
  36. package/skills/doctor-md-skill/scripts/verify_citations.py +221 -0
@@ -0,0 +1,781 @@
1
+ #!/usr/bin/env python3
2
+ """Mine the raw session logs for hard evidence that the user had to correct
3
+ the agent -- the whole window, not a sample.
4
+
5
+ Sources: Claude Code JSONL (`<claude-home>/projects/*/*.jsonl`, required)
6
+ and, best-effort, Codex rollout JSONL (`<codex-home>/{sessions,
7
+ archived_sessions}/**/rollout-*.jsonl`) when that layout is present. When
8
+ Codex is not found, `sources` in the output is `["claude"]` only.
9
+
10
+ Detection is a case-insensitive regex over the user's own words, seeded with
11
+ both Spanish and English phrasings. The patterns intentionally over-match --
12
+ a missed correction can never be recovered later, a false positive costs one
13
+ human read of the report. Session logs also carry harness-injected content
14
+ disguised as a "user" turn (Claude Code's local-command wrappers, Codex's
15
+ own "# AGENTS.md instructions for ..." context dump) -- those are stripped
16
+ before matching, or the correction count would be dominated by the user's
17
+ own instruction file being echoed back at them.
18
+
19
+ Clustering is a rough, deterministic, non-model grouping by shared salient
20
+ tokens -- a starting point for a human to merge, split and rename, not an
21
+ answer.
22
+
23
+ Not every "user" turn in a session log was typed by a human. Sub-agents,
24
+ audit harnesses, judges and other skills all write turns the log records as
25
+ "user", and on a real corpus they can dwarf the genuine corrections: one
26
+ run found 688 pattern-matched "corrections" of which 686 were a single
27
+ agent-written prompt template, repeated verbatim across hundreds of
28
+ sessions. Three filters drop these by shape, never by naming a specific
29
+ tool or skill, so this script stays independent of whatever else runs on
30
+ this machine:
31
+
32
+ 1. --max-turn-chars a user turn longer than this cannot be a typed
33
+ correction; dropped before pattern matching.
34
+ 2. --template-threshold a candidate turn (one that already matched a
35
+ correction pattern) whose normalized text repeats
36
+ across this many or more DISTINCT sessions is a
37
+ template, not a correction -- every occurrence is
38
+ dropped. This needs two passes: collect every
39
+ candidate first, then decide per normalized
40
+ prefix, because the same short text repeated many
41
+ times within one session is not a template. The
42
+ grouping key is only the first --template-prefix-
43
+ chars of the folded text (default 160): a
44
+ template's identity is its opening, not whatever
45
+ variable payload (an id, a JSON blob) follows it --
46
+ a key long enough to reach into that payload makes
47
+ every instance hash differently and defeats the
48
+ filter on exactly the turns it exists to catch.
49
+ 3. self-session sentinel a turn containing SELF_SESSION_SENTINEL is this
50
+ skill's own prior-run judging prompt, not a user
51
+ turn; its whole session is skipped, since the
52
+ session's other turns are this skill talking to
53
+ itself, not a human correcting an agent.
54
+
55
+ Emits:
56
+ <out>/corrections.json - every detected correction (session, timestamp,
57
+ matched pattern, text, preceding assistant
58
+ context) plus keyword clusters.
59
+
60
+ Python 3.9+, stdlib only. Reads session logs, writes only under --out,
61
+ makes no network call, and never modifies a session file.
62
+ """
63
+
64
+ import argparse
65
+ import hashlib
66
+ import json
67
+ import os
68
+ import re
69
+ import sys
70
+ from collections import Counter
71
+ from datetime import datetime, timedelta, timezone
72
+ from pathlib import Path
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Correction patterns
76
+ # ---------------------------------------------------------------------------
77
+
78
+ APOS = "['’ʼ]" # straight and curly apostrophes -- real logs carry both
79
+
80
+ SEED_PATTERNS = [
81
+ # -- Spanish --
82
+ ("no, usa", r"\bno,\s*usa\b"),
83
+ ("no uses", r"\bno\s+uses\b"),
84
+ ("no me has", r"\bno\s+me\s+has\b"),
85
+ ("no hagas", r"\bno\s+hagas\b"),
86
+ ("te dije", r"\bte\s+dije\b"),
87
+ ("ya te dije", r"\bya\s+te\s+dije\b"),
88
+ ("otra vez", r"\botra\s+vez\b"),
89
+ ("vuelve a", r"\bvuelve\s+a\b"),
90
+ ("siempre usa/haz/pon", r"\bsiempre\s+(usa|haz|pon)\b"),
91
+ ("nunca", r"\bnunca\b"),
92
+ ("sin co-authored/atribución", r"\bsin\s+(co-authored|atribuci[oó]n)\b"),
93
+ ("está mal", r"\best[aá]\s+mal\b"),
94
+ ("eso no", r"\beso\s+no\b"),
95
+ ("no es así", r"\bno\s+es\s+as[ií]\b"),
96
+ ("deberías haber", r"\bdeber[ií]as\s+haber\b"),
97
+ ("acuérdate", r"\bacu[eé]rdate\b"),
98
+ ("recuerda que", r"\brecuerda\s+que\b"),
99
+ ("mal,", r"\bmal\s*,"),
100
+ ("perdona no/pero", r"\bperdona,?\s+(no|pero)\b"),
101
+ # -- English --
102
+ ("no, use", r"\bno,\s*use\b"),
103
+ ("don't use", rf"\bdon{APOS}t\s+use\b"),
104
+ ("do not use", r"\bdo\s+not\s+use\b"),
105
+ ("you didn't", rf"\byou\s+didn{APOS}t\b"),
106
+ ("you did not", r"\byou\s+did\s+not\b"),
107
+ ("i told you", r"\bi\s+told\s+you\b"),
108
+ ("again,", r"\bagain\s*,"),
109
+ ("always use", r"\balways\s+use\b"),
110
+ ("never use", r"\bnever\s+use\b"),
111
+ ("that's wrong", rf"\bthat{APOS}s\s+wrong\b"),
112
+ ("that is wrong", r"\bthat\s+is\s+wrong\b"),
113
+ ("not like that", r"\bnot\s+like\s+that\b"),
114
+ ("you should have", r"\byou\s+should\s+have\b"),
115
+ ("remember to", r"\bremember\s+to\b"),
116
+ ("stop using", r"\bstop\s+using\b"),
117
+ ]
118
+
119
+ SKIP_EXACT = {"[Request interrupted by user]"}
120
+ SKIP_PREFIXES = ("<local-command-caveat>", "<command-name>", "<local-command-stdout>")
121
+
122
+ # A turn carrying this marker is a prompt this skill itself sent to a judge
123
+ # (SKILL.md Step 4), not a human correcting an agent. Defined once here so
124
+ # every future run recognizes its own prior-run prompts in the logs and
125
+ # refuses to mine them.
126
+ SELF_SESSION_SENTINEL = "<!-- doctor-md-agents:self-session -->"
127
+
128
+ # Default grouping-key length for template detection (overridable via
129
+ # --template-prefix-chars). Deliberately short: a template's identity is
130
+ # its opening line(s), not whatever variable payload -- an id, a JSON blob
131
+ # -- follows. A key long enough to reach into that payload makes every
132
+ # instance of the same template hash differently and defeats the filter.
133
+ TEMPLATE_PREFIX_CHARS = 160
134
+ TEMPLATE_REPORT_PREFIX_CHARS = 160
135
+
136
+ PASTED_CONTENT_RE = re.compile(r"<pasted_content\b[^>]*>.*?</pasted_content>", re.DOTALL | re.IGNORECASE)
137
+
138
+ # Codex injects the project's own AGENTS.md/CLAUDE.md text into a fake
139
+ # "user" turn at session start (and after context compaction). Left
140
+ # unfiltered, every correction pattern that also appears in the user's own
141
+ # instruction file (many do -- "nunca", "siempre", "no uses"...) would fire
142
+ # on every single session.
143
+ CODEX_INJECTED_RE = re.compile(r"^#\s+(?:AGENTS|CLAUDE)\.md\s+instructions\s+for\b", re.IGNORECASE)
144
+ GENERIC_INJECTED_TAGS = (
145
+ "environment_context", "user_instructions", "system-reminder", "recommended_plugins",
146
+ "permissions", "collaboration_mode", "turn_context", "user_info", "skills_instructions",
147
+ )
148
+
149
+ MAX_CORRECTION_TEXT_CHARS = 4000
150
+
151
+ # A short, intentionally modest stopword list (ES + EN) plus the seed
152
+ # patterns' own trigger words, so clusters key on the *subject* of the
153
+ # correction rather than on "siempre"/"nunca"/"usa" appearing in almost
154
+ # every hit.
155
+ STOPWORDS = {
156
+ "the", "and", "for", "that", "this", "with", "from", "have", "has", "had", "you", "your",
157
+ "yours", "are", "was", "were", "been", "being", "not", "but", "into", "onto", "they", "them",
158
+ "their", "what", "when", "where", "which", "who", "whom", "why", "how", "then", "than", "also",
159
+ "just", "like", "about", "again", "always", "never", "should", "would", "could", "please",
160
+ "some", "more", "most", "very", "only", "over", "under", "after", "before", "during", "while",
161
+ "because", "still", "even", "told", "didn", "did", "doesn", "does", "wrong", "remember", "stop",
162
+ "use", "uses", "used", "using",
163
+ "para", "esto", "esta", "estos", "estas", "pero", "como", "cuando", "donde", "cual", "cuales",
164
+ "quien", "quienes", "porque", "tambien", "mas", "menos", "muy", "solo", "sobre", "bajo",
165
+ "despues", "antes", "mientras", "todavia", "aun", "siempre", "nunca", "otra", "otro", "vez",
166
+ "dijiste", "dije", "hagas", "haces", "usar", "usa", "usas", "pon", "ponme", "deberia",
167
+ "deberias", "haber", "acuerdate", "recuerda", "perdona", "mal", "eres", "sera", "seria",
168
+ "tiene", "tienen", "tenia", "hace", "hizo", "haciendo", "vuelve",
169
+ }
170
+
171
+
172
+ def compile_patterns(extra_file):
173
+ patterns = [(label, re.compile(regex, re.IGNORECASE)) for label, regex in SEED_PATTERNS]
174
+ if extra_file:
175
+ text = Path(extra_file).expanduser().read_text(encoding="utf-8", errors="replace")
176
+ for line in text.splitlines():
177
+ line = line.strip()
178
+ if not line or line.startswith("#"):
179
+ continue
180
+ try:
181
+ patterns.append((line, re.compile(line, re.IGNORECASE)))
182
+ except re.error as exc:
183
+ print(f"warning: skipping invalid pattern {line!r}: {exc}", file=sys.stderr)
184
+ return patterns
185
+
186
+
187
+ def first_match(patterns, text):
188
+ for label, rx in patterns:
189
+ if rx.search(text):
190
+ return label
191
+ return None
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Text extraction helpers
196
+ # ---------------------------------------------------------------------------
197
+
198
+
199
+ def truncate(text, limit):
200
+ text = text.strip()
201
+ if len(text) <= limit:
202
+ return text
203
+ return text[:limit] + f" …[truncated {len(text) - limit} chars]"
204
+
205
+
206
+ def strip_pasted_content(text):
207
+ return PASTED_CONTENT_RE.sub("", text)
208
+
209
+
210
+ def normalize_prefix(text, length=TEMPLATE_PREFIX_CHARS):
211
+ """Fold whitespace and take the first `length` chars -- the key used to
212
+ group repeated-template candidates regardless of which session wrote
213
+ them or how the surrounding whitespace happens to differ."""
214
+ return re.sub(r"\s+", " ", text).strip()[:length]
215
+
216
+
217
+ def turn_contains_sentinel(turn):
218
+ return SELF_SESSION_SENTINEL in (turn.get("text") or "") or SELF_SESSION_SENTINEL in (turn.get("condensed") or "")
219
+
220
+
221
+ def looks_injected_generic(text):
222
+ head = text.lstrip()[:80]
223
+ return head.startswith("<") and any(tag in head for tag in GENERIC_INJECTED_TAGS)
224
+
225
+
226
+ def clean_user_text(raw_text):
227
+ """Apply the skip/strip rules; return None if the turn is not real."""
228
+ if raw_text is None:
229
+ return None
230
+ stripped_start = raw_text.lstrip()
231
+ if raw_text.strip() in SKIP_EXACT:
232
+ return None
233
+ if stripped_start.startswith(SKIP_PREFIXES):
234
+ return None
235
+ without_pasted = strip_pasted_content(raw_text)
236
+ if without_pasted.strip() == "":
237
+ return None
238
+ return without_pasted
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Claude Code
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def claude_text_blocks(content):
247
+ """Return (joined_text_or_None, tool_use_blocks, saw_only_tool_result)."""
248
+ if isinstance(content, str):
249
+ return content, [], False
250
+ if not isinstance(content, list):
251
+ return None, [], False
252
+ texts = []
253
+ tool_uses = []
254
+ saw_any_block = False
255
+ saw_non_tool_result = False
256
+ for block in content:
257
+ if not isinstance(block, dict):
258
+ continue
259
+ saw_any_block = True
260
+ btype = block.get("type")
261
+ if btype == "text":
262
+ saw_non_tool_result = True
263
+ t = block.get("text")
264
+ if isinstance(t, str) and t:
265
+ texts.append(t)
266
+ elif btype == "tool_use":
267
+ saw_non_tool_result = True
268
+ tool_uses.append(block)
269
+ elif btype == "tool_result":
270
+ pass # tool_result blocks never count as user text
271
+ else:
272
+ saw_non_tool_result = True
273
+ only_tool_result = saw_any_block and not saw_non_tool_result
274
+ text = "\n".join(texts) if texts else None
275
+ return text, tool_uses, only_tool_result
276
+
277
+
278
+ def condense_tool_uses(tool_uses, limit):
279
+ parts = []
280
+ for block in tool_uses:
281
+ name = block.get("name") or "tool"
282
+ args = block.get("input") or {}
283
+ args_text = args if isinstance(args, str) else json.dumps(args, ensure_ascii=False)
284
+ parts.append(f"{name}({args_text[:60]})")
285
+ return truncate("; ".join(parts), limit)
286
+
287
+
288
+ def iter_claude_turns(path, max_context):
289
+ """Yield raw turn dicts for one Claude Code session file."""
290
+ try:
291
+ fh = path.open("r", encoding="utf-8", errors="replace")
292
+ except OSError:
293
+ return
294
+ with fh:
295
+ for line_no, line in enumerate(fh):
296
+ try:
297
+ obj = json.loads(line)
298
+ except (json.JSONDecodeError, ValueError):
299
+ continue
300
+ rtype = obj.get("type")
301
+ if rtype not in ("user", "assistant"):
302
+ continue
303
+ if obj.get("isSidechain"):
304
+ continue
305
+ message = obj.get("message")
306
+ if not isinstance(message, dict):
307
+ continue
308
+ role = message.get("role") or rtype
309
+ content = message.get("content")
310
+
311
+ session_id = obj.get("sessionId")
312
+ if not session_id:
313
+ continue
314
+ uuid = obj.get("uuid") or f"{path}:{line_no}"
315
+ ts = obj.get("timestamp")
316
+
317
+ if role == "user":
318
+ if obj.get("isMeta"):
319
+ continue
320
+ raw_text, _tool_uses, only_tool_result = claude_text_blocks(content)
321
+ if only_tool_result or raw_text is None:
322
+ continue # tool_result-only record is not a user turn
323
+ text = clean_user_text(raw_text)
324
+ yield {
325
+ "session_id": session_id, "harness": "claude", "uuid": uuid,
326
+ "timestamp": ts, "cwd": obj.get("cwd"), "git_branch": obj.get("gitBranch"),
327
+ "role": "user", "text": text,
328
+ "condensed": truncate(text, max_context) if text else "",
329
+ }
330
+ else:
331
+ raw_text, tool_uses, _ = claude_text_blocks(content)
332
+ if raw_text:
333
+ condensed = truncate(raw_text, max_context)
334
+ elif tool_uses:
335
+ condensed = condense_tool_uses(tool_uses, max_context)
336
+ else:
337
+ condensed = ""
338
+ yield {
339
+ "session_id": session_id, "harness": "claude", "uuid": uuid,
340
+ "timestamp": ts, "cwd": obj.get("cwd"), "git_branch": obj.get("gitBranch"),
341
+ "role": "assistant", "text": None, "condensed": condensed,
342
+ }
343
+
344
+
345
+ def find_claude_files(claude_home, cutoff):
346
+ projects = claude_home / "projects"
347
+ if not projects.is_dir():
348
+ return []
349
+ files = []
350
+ for path in projects.glob("*/*.jsonl"):
351
+ try:
352
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
353
+ except OSError:
354
+ continue
355
+ if mtime >= cutoff:
356
+ files.append(path)
357
+ return sorted(files)
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Codex (best-effort; shape confirmed against scripts/collect_sessions.py's
362
+ # parse_codex_session and against real ~/.codex/sessions rollout files)
363
+ # ---------------------------------------------------------------------------
364
+
365
+
366
+ def codex_extract_text(content):
367
+ if isinstance(content, str):
368
+ return content
369
+ if not isinstance(content, list):
370
+ return None
371
+ parts = []
372
+ for block in content:
373
+ if isinstance(block, dict):
374
+ t = block.get("text")
375
+ if isinstance(t, str) and t:
376
+ parts.append(t)
377
+ return "\n".join(parts) if parts else None
378
+
379
+
380
+ def looks_injected_codex(text):
381
+ if CODEX_INJECTED_RE.match(text.lstrip()):
382
+ return True
383
+ return looks_injected_generic(text)
384
+
385
+
386
+ def iter_codex_turns(path, max_context):
387
+ try:
388
+ fh = path.open("r", encoding="utf-8", errors="replace")
389
+ except OSError:
390
+ return
391
+ session_id = None
392
+ cwd = None
393
+ with fh:
394
+ for line_no, obj_line in enumerate(fh):
395
+ try:
396
+ obj = json.loads(obj_line)
397
+ except (json.JSONDecodeError, ValueError):
398
+ continue
399
+ rtype = obj.get("type")
400
+ payload = obj.get("payload")
401
+ if not isinstance(payload, dict):
402
+ continue
403
+ ts = obj.get("timestamp")
404
+
405
+ if rtype == "session_meta":
406
+ session_id = payload.get("id") or payload.get("session_id")
407
+ cwd = payload.get("cwd")
408
+ source = payload.get("source")
409
+ thread_source = payload.get("thread_source")
410
+ is_subagent = thread_source == "subagent" or (
411
+ isinstance(source, dict) and "subagent" in source
412
+ )
413
+ if is_subagent:
414
+ return
415
+ continue
416
+
417
+ if rtype != "response_item" or not session_id:
418
+ continue
419
+
420
+ ptype = payload.get("type")
421
+ uuid = payload.get("id") or f"{path}:{line_no}"
422
+
423
+ if ptype == "message" and payload.get("role") == "user":
424
+ raw_text = codex_extract_text(payload.get("content"))
425
+ if raw_text is None or looks_injected_codex(raw_text):
426
+ continue
427
+ text = clean_user_text(raw_text)
428
+ yield {
429
+ "session_id": session_id, "harness": "codex", "uuid": uuid,
430
+ "timestamp": ts, "cwd": cwd, "git_branch": None,
431
+ "role": "user", "text": text,
432
+ "condensed": truncate(text, max_context) if text else "",
433
+ }
434
+ elif ptype == "message" and payload.get("role") == "assistant":
435
+ raw_text = codex_extract_text(payload.get("content"))
436
+ condensed = truncate(raw_text, max_context) if raw_text else ""
437
+ yield {
438
+ "session_id": session_id, "harness": "codex", "uuid": uuid,
439
+ "timestamp": ts, "cwd": cwd, "git_branch": None,
440
+ "role": "assistant", "text": None, "condensed": condensed,
441
+ }
442
+ elif ptype in ("function_call", "custom_tool_call", "local_shell_call"):
443
+ name = payload.get("name") or ptype
444
+ args = payload.get("arguments") or payload.get("input") or ""
445
+ if not isinstance(args, str):
446
+ args = json.dumps(args, ensure_ascii=False)
447
+ condensed = truncate(f"{name}({args[:60]})", max_context)
448
+ yield {
449
+ "session_id": session_id, "harness": "codex", "uuid": uuid,
450
+ "timestamp": ts, "cwd": cwd, "git_branch": None,
451
+ "role": "assistant", "text": None, "condensed": condensed,
452
+ }
453
+
454
+
455
+ def find_codex_files(codex_home, cutoff):
456
+ files = []
457
+ for sub in ("sessions", "archived_sessions"):
458
+ root = codex_home / sub
459
+ if not root.is_dir():
460
+ continue
461
+ for path in root.rglob("rollout-*.jsonl"):
462
+ try:
463
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
464
+ except OSError:
465
+ continue
466
+ if mtime >= cutoff:
467
+ files.append(path)
468
+ return sorted(files)
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # Timestamp / scope helpers
473
+ # ---------------------------------------------------------------------------
474
+
475
+
476
+ def parse_ts(value):
477
+ if not value:
478
+ return None
479
+ text = str(value).strip()
480
+ if text.endswith("Z"):
481
+ text = text[:-1] + "+00:00"
482
+ try:
483
+ dt = datetime.fromisoformat(text)
484
+ except ValueError:
485
+ return None
486
+ if dt.tzinfo is None:
487
+ dt = dt.replace(tzinfo=timezone.utc)
488
+ return dt.astimezone(timezone.utc)
489
+
490
+
491
+ def normalize_repo(repo_arg):
492
+ return os.path.normpath(os.path.expanduser(repo_arg))
493
+
494
+
495
+ def cwd_in_scope(cwd, repo_norms):
496
+ if not cwd:
497
+ return False
498
+ cwd_norm = os.path.normpath(os.path.expanduser(cwd))
499
+ for repo_norm in repo_norms:
500
+ if cwd_norm == repo_norm or cwd_norm.startswith(repo_norm + os.sep):
501
+ return True
502
+ return False
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # Clustering
507
+ # ---------------------------------------------------------------------------
508
+
509
+ WORD_RE = re.compile(r"[^\W\d_]+", re.UNICODE)
510
+
511
+
512
+ def salient_tokens(text):
513
+ tokens = [t.lower() for t in WORD_RE.findall(text)]
514
+ return [t for t in tokens if len(t) >= 4 and t not in STOPWORDS]
515
+
516
+
517
+ def build_clusters(corrections):
518
+ """Deterministic, order-preserving greedy clustering by shared salient
519
+ tokens (>=2 shared tokens joins the first qualifying cluster). This is a
520
+ rough grouping meant for a human to merge/split/rename, not an answer."""
521
+ clusters = [] # each: {"token_set": set, "token_counts": Counter, "indexes": [], "sessions": set}
522
+ for index, corr in enumerate(corrections):
523
+ tokens = set(salient_tokens(corr["text"]))
524
+ target = None
525
+ if tokens:
526
+ for cluster in clusters:
527
+ if len(tokens & cluster["token_set"]) >= 2:
528
+ target = cluster
529
+ break
530
+ if target is None:
531
+ target = {"token_set": set(), "token_counts": Counter(), "indexes": [], "sessions": set()}
532
+ clusters.append(target)
533
+ target["token_set"] |= tokens
534
+ target["token_counts"].update(tokens)
535
+ target["indexes"].append(index)
536
+ target["sessions"].add(corr["session_id"])
537
+
538
+ out = []
539
+ for cluster in clusters:
540
+ counted = cluster["token_counts"]
541
+ ranked = sorted(counted.items(), key=lambda kv: (-kv[1], kv[0]))
542
+ key_tokens = [t for t, _ in ranked[:3]]
543
+ out.append({
544
+ "key": " ".join(key_tokens) if key_tokens else "(no salient tokens)",
545
+ "count": len(cluster["indexes"]),
546
+ "sessions": sorted(cluster["sessions"]),
547
+ "correction_indexes": cluster["indexes"],
548
+ })
549
+ out.sort(key=lambda c: (-c["count"], c["key"]))
550
+ return out
551
+
552
+
553
+ # ---------------------------------------------------------------------------
554
+ # Main
555
+ # ---------------------------------------------------------------------------
556
+
557
+
558
+ def parse_args(argv=None):
559
+ p = argparse.ArgumentParser(description=__doc__)
560
+ p.add_argument("--out", required=True)
561
+ p.add_argument("--days", type=int, default=90)
562
+ p.add_argument("--repo", action="append", default=[])
563
+ p.add_argument("--all-conversations", action="store_true")
564
+ p.add_argument("--claude-home", default=os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude"))
565
+ p.add_argument("--codex-home", default="~/.codex")
566
+ p.add_argument("--patterns", default=None, help="extra newline-separated regex patterns")
567
+ p.add_argument("--max-context", type=int, default=400,
568
+ help="chars of preceding-assistant excerpt to keep (default: 400)")
569
+ p.add_argument("--max-turn-chars", type=int, default=1200,
570
+ help="a user turn longer than this cannot be a typed correction (default: 1200)")
571
+ p.add_argument("--template-threshold", type=int, default=3,
572
+ help="drop a candidate as a machine template when its normalized text "
573
+ "repeats across this many or more distinct sessions (default: 3)")
574
+ p.add_argument("--template-prefix-chars", type=int, default=TEMPLATE_PREFIX_CHARS,
575
+ help="chars of whitespace-folded text used as the template-matching key "
576
+ "(default: 160) -- a template's identity is its opening, not the "
577
+ "variable payload (an id, a JSON blob) that follows it")
578
+ return p.parse_args(argv)
579
+
580
+
581
+ def main(argv=None):
582
+ args = parse_args(argv)
583
+ if args.all_conversations and args.repo:
584
+ print("error: --all-conversations cannot be combined with --repo", file=sys.stderr)
585
+ sys.exit(2)
586
+
587
+ out_dir = Path(args.out).expanduser()
588
+ out_dir.mkdir(parents=True, exist_ok=True)
589
+
590
+ claude_home = Path(args.claude_home).expanduser()
591
+ codex_home = Path(args.codex_home).expanduser()
592
+ cutoff = datetime.now(timezone.utc) - timedelta(days=args.days)
593
+ repo_norms = [normalize_repo(r) for r in args.repo]
594
+
595
+ patterns = compile_patterns(args.patterns)
596
+
597
+ claude_files = find_claude_files(claude_home, cutoff)
598
+ if not claude_files and not (claude_home / "projects").is_dir():
599
+ print(f"error: Claude Code project history not found at {claude_home / 'projects'}",
600
+ file=sys.stderr)
601
+ sys.exit(1)
602
+
603
+ codex_files = find_codex_files(codex_home, cutoff)
604
+ sources = ["claude"]
605
+ if codex_files:
606
+ sources.append("codex")
607
+
608
+ session_files_scanned = len(claude_files) + len(codex_files)
609
+
610
+ # session_id -> {"turns": {turn_key: turn_dict}}
611
+ sessions = {}
612
+ for path in claude_files:
613
+ for turn in iter_claude_turns(path, args.max_context):
614
+ bucket = sessions.setdefault(turn["session_id"], {})
615
+ key = turn["uuid"]
616
+ bucket[key] = turn
617
+ for path in codex_files:
618
+ for turn in iter_codex_turns(path, args.max_context):
619
+ bucket = sessions.setdefault(turn["session_id"], {})
620
+ key = f"codex:{turn['uuid']}"
621
+ bucket[key] = turn
622
+
623
+ # Pass 1: walk every in-scope session and collect pattern-matched
624
+ # candidates. Nothing here is decided to be a real correction yet --
625
+ # the template filter (pass 2, below) needs every candidate gathered
626
+ # first, because "repeated across N distinct sessions" cannot be known
627
+ # from a single session's turns in isolation.
628
+ candidates = [] # list of correction dicts (pending template decision)
629
+ candidate_prefixes = [] # parallel list: normalize_prefix(text) per candidate
630
+ user_turns_total = 0
631
+ sessions_scanned = 0
632
+ sessions_in_scope = 0
633
+ turns_dropped_too_long = 0
634
+ turns_dropped_self_session = 0
635
+ sessions_skipped_self_session = 0
636
+
637
+ for session_id in sorted(sessions):
638
+ all_turns = list(sessions[session_id].values())
639
+
640
+ def sort_key(t):
641
+ ts = parse_ts(t["timestamp"])
642
+ return (ts is None, ts or datetime.min.replace(tzinfo=timezone.utc))
643
+
644
+ all_turns.sort(key=sort_key)
645
+
646
+ # A self-session is a property of the whole session, independent of
647
+ # which of its turns happen to fall inside --days: check the full,
648
+ # unwindowed turn list.
649
+ session_has_sentinel = any(turn_contains_sentinel(t) for t in all_turns)
650
+
651
+ # window filter at turn level
652
+ turns = [t for t in all_turns if parse_ts(t["timestamp"]) is None or parse_ts(t["timestamp"]) >= cutoff]
653
+ if not turns:
654
+ continue
655
+ sessions_scanned += 1
656
+
657
+ cwd = next((t["cwd"] for t in turns if t.get("cwd")), None)
658
+ in_scope = args.all_conversations or cwd_in_scope(cwd, repo_norms)
659
+ if not in_scope:
660
+ continue
661
+ sessions_in_scope += 1
662
+
663
+ git_branch = next((t["git_branch"] for t in turns if t.get("git_branch")), None)
664
+
665
+ if session_has_sentinel:
666
+ # This skill's own prior-run judging prompts, not a human
667
+ # correcting an agent -- the whole session is this skill
668
+ # talking to itself. Every real user turn in it still counts
669
+ # toward user_turns (it is a genuine turn in scope) and is
670
+ # fully accounted for by turns_dropped_self_session, so the
671
+ # arithmetic user_turns - dropped_* stays auditable.
672
+ session_user_turns = sum(1 for t in turns if t["role"] == "user" and t["text"])
673
+ user_turns_total += session_user_turns
674
+ turns_dropped_self_session += session_user_turns
675
+ sessions_skipped_self_session += 1
676
+ continue
677
+
678
+ for i, turn in enumerate(turns):
679
+ if turn["role"] != "user" or not turn["text"]:
680
+ continue
681
+ text = turn["text"]
682
+ user_turns_total += 1
683
+
684
+ if len(text) > args.max_turn_chars:
685
+ # Not a typed correction -- dropped before pattern matching.
686
+ turns_dropped_too_long += 1
687
+ continue
688
+
689
+ pattern = first_match(patterns, text)
690
+ if not pattern:
691
+ continue
692
+ preceding = ""
693
+ for j in range(i - 1, -1, -1):
694
+ if turns[j]["role"] == "assistant":
695
+ preceding = turns[j]["condensed"]
696
+ break
697
+ candidates.append({
698
+ "session_id": session_id,
699
+ "harness": turn["harness"],
700
+ "timestamp": turn["timestamp"],
701
+ "cwd": cwd,
702
+ "git_branch": git_branch,
703
+ "pattern": pattern,
704
+ "text": truncate(text, MAX_CORRECTION_TEXT_CHARS),
705
+ "preceding": preceding,
706
+ })
707
+ candidate_prefixes.append(normalize_prefix(text, length=args.template_prefix_chars))
708
+
709
+ # Pass 2: group candidates by normalized prefix and count DISTINCT
710
+ # sessions per group -- the same text repeated many times inside one
711
+ # session is not a template, only the same text spread across several
712
+ # sessions is.
713
+ prefix_sessions = {} # normalized prefix -> set of session ids
714
+ prefix_indexes = {} # normalized prefix -> [candidate indexes]
715
+ for idx, corr in enumerate(candidates):
716
+ prefix = candidate_prefixes[idx]
717
+ prefix_sessions.setdefault(prefix, set()).add(corr["session_id"])
718
+ prefix_indexes.setdefault(prefix, []).append(idx)
719
+
720
+ templated_indexes = set()
721
+ dropped_templates = []
722
+ for prefix, sess_set in prefix_sessions.items():
723
+ if len(sess_set) >= args.template_threshold:
724
+ templated_indexes.update(prefix_indexes[prefix])
725
+ dropped_templates.append({
726
+ "prefix": prefix[:TEMPLATE_REPORT_PREFIX_CHARS],
727
+ "sessions": len(sess_set),
728
+ "occurrences": len(prefix_indexes[prefix]),
729
+ })
730
+ dropped_templates.sort(key=lambda d: (-d["sessions"], -d["occurrences"]))
731
+ turns_dropped_templated = len(templated_indexes)
732
+
733
+ corrections = []
734
+ sessions_with_corrections = set()
735
+ for idx, corr in enumerate(candidates):
736
+ if idx in templated_indexes:
737
+ continue
738
+ corrections.append(corr)
739
+ sessions_with_corrections.add(corr["session_id"])
740
+
741
+ clusters = build_clusters(corrections)
742
+
743
+ report = {
744
+ "generated_at": datetime.now(timezone.utc).isoformat(),
745
+ "window_days": args.days,
746
+ "sources": sources,
747
+ "stats": {
748
+ "session_files_scanned": session_files_scanned,
749
+ "sessions_scanned": sessions_scanned,
750
+ "sessions_in_scope": sessions_in_scope,
751
+ "user_turns": user_turns_total,
752
+ "corrections_found": len(corrections),
753
+ "sessions_with_corrections": len(sessions_with_corrections),
754
+ "turns_dropped_too_long": turns_dropped_too_long,
755
+ "turns_dropped_templated": turns_dropped_templated,
756
+ "turns_dropped_self_session": turns_dropped_self_session,
757
+ "sessions_skipped_self_session": sessions_skipped_self_session,
758
+ },
759
+ "corrections": corrections,
760
+ "clusters": clusters,
761
+ "dropped_templates": dropped_templates,
762
+ }
763
+ (out_dir / "corrections.json").write_text(json.dumps(report, indent=2))
764
+
765
+ print(f"corrections.json: {out_dir / 'corrections.json'}")
766
+ print(f"sources: {', '.join(sources)}")
767
+ print(f"session files: {session_files_scanned} scanned")
768
+ print(f"sessions: {sessions_scanned} scanned, {sessions_in_scope} in scope")
769
+ print(f"user turns: {user_turns_total}")
770
+ print(f"corrections found: {len(corrections)} across {len(sessions_with_corrections)} sessions")
771
+ print(f"dropped too long: {turns_dropped_too_long}")
772
+ print(f"dropped templated: {turns_dropped_templated} ({len(dropped_templates)} template group(s))")
773
+ print(f"dropped self-session: {turns_dropped_self_session} turns "
774
+ f"across {sessions_skipped_self_session} session(s)")
775
+ print("top clusters:")
776
+ for cluster in clusters[:5]:
777
+ print(f" {cluster['count']:4d} {cluster['key']}")
778
+
779
+
780
+ if __name__ == "__main__":
781
+ main()