@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,1112 @@
1
+ #!/usr/bin/env python3
2
+ """Gather local agent-session history (Claude Code, Codex, Pi, Grok Build, ZCode) for scoring.
3
+
4
+ The script looks through the session logs each supported harness leaves on
5
+ disk, finds the skills installed for the chosen projects, works out which
6
+ sessions touched which skills, picks a sample, and writes:
7
+
8
+ <out>/inventory.json - skills, per-session stats, sampling decisions
9
+ <out>/transcripts/<id>.md - condensed transcripts for the sampled sessions
10
+
11
+ Everything happens on the local machine; nothing is sent anywhere.
12
+ Python 3.9+, standard library only.
13
+
14
+ Design in one paragraph: every harness has a small *reader* class that turns
15
+ its native log format into one flat stream of neutral events (a user said
16
+ something, a tool was called, a tool returned...). A single digest routine
17
+ consumes any such stream and produces the statistics, the condensed
18
+ transcript entries and the set of skills seen, so the counting rules live in
19
+ exactly one place.
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ from collections import Counter, deque
29
+ from datetime import datetime, timedelta, timezone
30
+ from pathlib import Path
31
+ from typing import Iterator, NamedTuple
32
+ from urllib.parse import unquote
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Tunables
36
+ # ---------------------------------------------------------------------------
37
+
38
+ CHAT_CLIP = 1500 # characters kept from a user/assistant message
39
+ TOOL_CLIP = 500 # characters kept from a tool call or tool output
40
+ ENTRY_CEILING = 160 # transcripts longer than this are cut down
41
+ KEEP_FIRST = 100 # entries retained from the start of a long transcript
42
+ KEEP_LAST = 40 # entries retained from the end of a long transcript
43
+ ERROR_SCAN_WINDOW = 2000 # leading characters of an output searched for failure words
44
+
45
+ EDIT_MARKERS = ("apply_patch", "*** Begin Patch", "edit_file", "create_file", "str_replace", "write_file")
46
+ CLAUDE_EDIT_TOOLS = frozenset({"Edit", "MultiEdit", "NotebookEdit", "Write"})
47
+ COMMON_EDIT_TOOLS = frozenset({"edit", "write", "apply_patch", "edit_file", "write_file", "str_replace", "search_replace"})
48
+ FAILURE_WORDS = ("error", "failed", "traceback")
49
+ INJECTED_TAGS = (
50
+ "environment_context", "user_instructions", "ENVIRONMENT", "system-reminder",
51
+ "permissions", "collaboration_mode", "recommended_plugins", "turn_context",
52
+ "user_info",
53
+ )
54
+ SKILL_MENTION_PATTERNS = (
55
+ re.compile(r"(?:^|/)skills/+([^/]+)/+"),
56
+ re.compile(r'"(?:skill|name|bundled_skill_id)"\s*:\s*"([^"]+)"'),
57
+ )
58
+ GIT_TIMEOUT_SECONDS = 10
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Small text helpers
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def clip(text: str, limit: int) -> str:
66
+ """Trim whitespace and cap the length, noting how much was dropped."""
67
+ text = text.strip()
68
+ if len(text) <= limit:
69
+ return text
70
+ return f"{text[:limit]} …[truncated {len(text) - limit} chars]"
71
+
72
+
73
+ def stringify(value) -> str:
74
+ """Render a tool argument / output payload as text."""
75
+ return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
76
+
77
+
78
+ def flatten_text(content) -> str:
79
+ """Join the textual parts of a message body (string or list of blocks)."""
80
+ if isinstance(content, str):
81
+ return content
82
+ if not isinstance(content, list):
83
+ return ""
84
+ pieces = []
85
+ for block in content:
86
+ if isinstance(block, str):
87
+ pieces.append(block)
88
+ elif isinstance(block, dict):
89
+ piece = block.get("text") or block.get("content") or ""
90
+ if isinstance(piece, str) and piece:
91
+ pieces.append(piece)
92
+ return "\n".join(pieces)
93
+
94
+
95
+ def is_harness_injection(text: str) -> bool:
96
+ """True for text a harness injects on the user's behalf (system reminders etc.)."""
97
+ opening = text.lstrip()[:80]
98
+ return opening.startswith("<") and any(tag in opening for tag in INJECTED_TAGS)
99
+
100
+
101
+ def mentions_failure(text: str) -> bool:
102
+ window = text[:ERROR_SCAN_WINDOW].lower()
103
+ return any(word in window for word in FAILURE_WORDS)
104
+
105
+
106
+ def skill_mentions(text: str) -> set:
107
+ """Names that look like installed skills inside one tool-call payload."""
108
+ text = text.replace("\\", "/")
109
+ found = set()
110
+ for pattern in SKILL_MENTION_PATTERNS:
111
+ found.update(pattern.findall(text))
112
+ return found
113
+
114
+
115
+ def message_blocks(content) -> list:
116
+ """Normalise a message body to a list of content blocks."""
117
+ return content if isinstance(content, list) else [{"type": "text", "text": content}]
118
+
119
+
120
+ def read_jsonl(path: Path) -> Iterator[dict]:
121
+ """Stream the JSON objects of a JSONL file, one line at a time."""
122
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
123
+ for raw in handle:
124
+ try:
125
+ obj = json.loads(raw)
126
+ except ValueError:
127
+ continue
128
+ if isinstance(obj, dict):
129
+ yield obj
130
+
131
+
132
+ def file_mtime(path: Path):
133
+ try:
134
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
135
+ except OSError:
136
+ return None
137
+
138
+
139
+ def recent_files(paths, cutoff: datetime):
140
+ """Pair each path with its mtime, drop stale ones, order newest first."""
141
+ hits = []
142
+ for path in paths:
143
+ stamp = file_mtime(path)
144
+ if stamp is not None and stamp >= cutoff:
145
+ hits.append((stamp, path))
146
+ hits.sort(key=lambda pair: pair[0], reverse=True)
147
+ return hits
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Neutral event stream
152
+ # ---------------------------------------------------------------------------
153
+
154
+ STAMP = "stamp" # text = a timestamp seen in the log
155
+ USER_TURN = "user_turn" # one human turn happened
156
+ AGENT_TURN = "agent_turn" # one assistant turn happened
157
+ USER_SAID = "user_said" # text = transcript-worthy user message
158
+ AGENT_SAID = "agent_said" # text = transcript-worthy assistant message
159
+ TOOL_CALL = "tool_call" # name = tool, text = serialized arguments
160
+ TOOL_OUTPUT = "tool_output" # text = output, failed = harness flagged an error
161
+ SKILL_HINT = "skill_hint" # text = a skill name the harness stated explicitly
162
+
163
+
164
+ class Event(NamedTuple):
165
+ kind: str
166
+ text: str = ""
167
+ name: str = ""
168
+ failed: bool = False
169
+
170
+
171
+ class SkipSession(Exception):
172
+ """Raised by a reader to say the session must be left out entirely."""
173
+
174
+
175
+ class EntryLog:
176
+ """Ordered transcript entries; long logs keep a head and a tail only."""
177
+
178
+ def __init__(self):
179
+ self._front = []
180
+ self._back = None
181
+ self._seen = 0
182
+
183
+ def __len__(self):
184
+ return self._seen
185
+
186
+ def add(self, role: str, text: str):
187
+ self._seen += 1
188
+ if self._back is not None:
189
+ self._back.append((role, text))
190
+ return
191
+ self._front.append((role, text))
192
+ if len(self._front) > ENTRY_CEILING:
193
+ self._back = deque(self._front[-KEEP_LAST:], maxlen=KEEP_LAST)
194
+ del self._front[KEEP_FIRST:]
195
+
196
+ def entries(self):
197
+ if self._back is None:
198
+ return self._front
199
+ dropped = self._seen - KEEP_FIRST - KEEP_LAST
200
+ marker = ("note", f"[... {dropped} entries omitted ...]")
201
+ return [*self._front, marker, *self._back]
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Readers: one per harness, each translating a log file into Events
206
+ # ---------------------------------------------------------------------------
207
+
208
+ class Reader:
209
+ """Base class. Subclasses describe where a harness keeps sessions and how to decode them."""
210
+
211
+ key = ""
212
+ restrict_skills = True # intersect detected skills with the known set
213
+ edit_tools = COMMON_EDIT_TOOLS # tool names that imply code edits
214
+ drop_when_silent = False # discard sessions that yield no transcript entries
215
+
216
+ def __init__(self, path: Path, include_subagents: bool = False):
217
+ self.path = path
218
+ self.include_subagents = include_subagents
219
+
220
+ # -- discovery ---------------------------------------------------------
221
+ @classmethod
222
+ def required_dir(cls, home: Path) -> Path:
223
+ raise NotImplementedError
224
+
225
+ @classmethod
226
+ def absence_message(cls, home: Path) -> str:
227
+ raise NotImplementedError
228
+
229
+ @classmethod
230
+ def locate(cls, home: Path, cutoff: datetime, include_subagents: bool = False):
231
+ raise NotImplementedError
232
+
233
+ # -- decoding ----------------------------------------------------------
234
+ def events(self) -> Iterator[Event]:
235
+ raise NotImplementedError
236
+
237
+ def build_meta(self, first_ts) -> dict:
238
+ raise NotImplementedError
239
+
240
+
241
+ class ClaudeReader(Reader):
242
+ key = "claude"
243
+ restrict_skills = False
244
+ edit_tools = CLAUDE_EDIT_TOOLS
245
+
246
+ def __init__(self, path, include_subagents=False):
247
+ super().__init__(path, include_subagents)
248
+ self._meta = {}
249
+ self._sidechain = False
250
+ self._counted_messages = set()
251
+
252
+ @classmethod
253
+ def required_dir(cls, home):
254
+ return home / "projects"
255
+
256
+ @classmethod
257
+ def absence_message(cls, home):
258
+ return f"Claude Code project history not found at {home / 'projects'}"
259
+
260
+ @classmethod
261
+ def locate(cls, home, cutoff, include_subagents=False):
262
+ root = home / "projects"
263
+ if not root.is_dir():
264
+ return []
265
+ paths = list(root.glob("*/*.jsonl"))
266
+ if include_subagents:
267
+ paths += root.glob("*/*/subagents/*.jsonl")
268
+ return recent_files(paths, cutoff)
269
+
270
+ def _track_envelope(self, obj, ts):
271
+ agent = obj.get("agentId")
272
+ if not self._meta:
273
+ if not obj.get("sessionId"):
274
+ return
275
+ sid = obj["sessionId"]
276
+ self._meta = {
277
+ "id": f"{sid}-{agent}" if agent else sid,
278
+ "cwd": obj.get("cwd"),
279
+ "started_at": ts,
280
+ "originator": "claude-code",
281
+ "thread_source": "subagent" if obj.get("isSidechain") else None,
282
+ "cli_version": obj.get("version"),
283
+ "entrypoint": obj.get("entrypoint"),
284
+ }
285
+ return
286
+ meta = self._meta
287
+ for field, value in (("cwd", obj.get("cwd")), ("started_at", ts),
288
+ ("cli_version", obj.get("version")),
289
+ ("entrypoint", obj.get("entrypoint"))):
290
+ meta[field] = meta.get(field) or value
291
+ if agent and not meta["id"].endswith(f"-{agent}"):
292
+ meta["id"] = f"{obj.get('sessionId') or meta['id']}-{agent}"
293
+
294
+ def events(self):
295
+ for obj in read_jsonl(self.path):
296
+ ts = obj.get("timestamp")
297
+ if ts:
298
+ yield Event(STAMP, ts)
299
+ if obj.get("isSidechain"):
300
+ self._sidechain = True
301
+ if not self.include_subagents:
302
+ raise SkipSession
303
+ self._track_envelope(obj, ts)
304
+
305
+ message = obj.get("message")
306
+ if obj.get("type") not in ("user", "assistant") or not isinstance(message, dict):
307
+ continue
308
+ yield from self._decode_message(obj, message)
309
+
310
+ def _decode_message(self, obj, message):
311
+ role = message.get("role") or obj.get("type")
312
+ if role == "assistant":
313
+ msg_id = message.get("id") or obj.get("uuid")
314
+ if msg_id and msg_id not in self._counted_messages:
315
+ self._counted_messages.add(msg_id)
316
+ yield Event(AGENT_TURN)
317
+ human_spoke = False
318
+ for block in message_blocks(message.get("content")):
319
+ if not isinstance(block, dict):
320
+ continue
321
+ btype = block.get("type")
322
+ if btype == "text":
323
+ text = block.get("text")
324
+ if not isinstance(text, str) or not text or is_harness_injection(text):
325
+ continue
326
+ if role == "user":
327
+ human_spoke = True
328
+ yield Event(USER_SAID, text)
329
+ elif role == "assistant":
330
+ yield Event(AGENT_SAID, text)
331
+ elif btype == "tool_use":
332
+ name = str(block.get("name") or "unknown")
333
+ args = block.get("input") or {}
334
+ yield Event(TOOL_CALL, stringify(args), name)
335
+ if name == "Skill" and isinstance(args, dict) and args.get("skill"):
336
+ yield Event(SKILL_HINT, args["skill"])
337
+ elif btype == "tool_result":
338
+ yield Event(TOOL_OUTPUT, flatten_text(block.get("content")),
339
+ failed=bool(block.get("is_error")))
340
+ if role == "user" and human_spoke:
341
+ yield Event(USER_TURN)
342
+
343
+ def build_meta(self, first_ts):
344
+ if not self._meta:
345
+ return {
346
+ "id": self.path.stem,
347
+ "cwd": None,
348
+ "started_at": first_ts,
349
+ "originator": "claude-code",
350
+ "thread_source": "subagent" if self._sidechain else None,
351
+ }
352
+ if self._sidechain:
353
+ self._meta["thread_source"] = "subagent"
354
+ return self._meta
355
+
356
+
357
+ class CodexReader(Reader):
358
+ key = "codex"
359
+ restrict_skills = False
360
+ edit_tools = frozenset()
361
+ _CALL_TYPES = ("function_call", "custom_tool_call", "local_shell_call")
362
+ _OUTPUT_TYPES = ("function_call_output", "custom_tool_call_output")
363
+
364
+ def __init__(self, path, include_subagents=False):
365
+ super().__init__(path, include_subagents)
366
+ self._meta = {}
367
+
368
+ @classmethod
369
+ def required_dir(cls, home):
370
+ return home
371
+
372
+ @classmethod
373
+ def absence_message(cls, home):
374
+ return f"Codex home not found at {home}"
375
+
376
+ @classmethod
377
+ def locate(cls, home, cutoff, include_subagents=False):
378
+ paths = []
379
+ for sub in ("sessions", "archived_sessions"):
380
+ folder = home / sub
381
+ if folder.is_dir():
382
+ paths.extend(folder.rglob("rollout-*.jsonl"))
383
+ return recent_files(paths, cutoff)
384
+
385
+ def _read_header(self, payload):
386
+ self._meta = {
387
+ "id": payload.get("id") or payload.get("session_id") or self.path.stem,
388
+ "cwd": payload.get("cwd"),
389
+ "started_at": payload.get("timestamp"),
390
+ "originator": payload.get("originator"),
391
+ "thread_source": payload.get("thread_source"),
392
+ "cli_version": payload.get("cli_version"),
393
+ }
394
+ origin = payload.get("source")
395
+ delegated = payload.get("thread_source") == "subagent" or (
396
+ isinstance(origin, dict) and "subagent" in origin
397
+ )
398
+ if delegated and not self.include_subagents:
399
+ raise SkipSession
400
+
401
+ def events(self):
402
+ for obj in read_jsonl(self.path):
403
+ payload = obj.get("payload") or {}
404
+ if not isinstance(payload, dict):
405
+ continue
406
+ if obj.get("timestamp"):
407
+ yield Event(STAMP, obj["timestamp"])
408
+ record = obj.get("type")
409
+ inner = payload.get("type")
410
+ if record == "session_meta":
411
+ self._read_header(payload)
412
+ elif record == "event_msg":
413
+ if inner == "user_message":
414
+ yield Event(USER_TURN)
415
+ elif inner == "agent_message":
416
+ yield Event(AGENT_TURN)
417
+ elif record == "response_item":
418
+ yield from self._decode_item(payload, inner)
419
+
420
+ def _decode_item(self, payload, inner):
421
+ if inner == "message":
422
+ text = flatten_text(payload.get("content"))
423
+ role = payload.get("role")
424
+ if not text:
425
+ return
426
+ if role == "user" and not is_harness_injection(text):
427
+ yield Event(USER_SAID, text)
428
+ elif role == "assistant":
429
+ yield Event(AGENT_SAID, text)
430
+ elif inner in self._CALL_TYPES:
431
+ args = payload.get("arguments") or payload.get("input") or ""
432
+ yield Event(TOOL_CALL, stringify(args), payload.get("name") or inner)
433
+ elif inner in self._OUTPUT_TYPES:
434
+ yield Event(TOOL_OUTPUT, stringify(payload.get("output") or ""))
435
+
436
+ def build_meta(self, first_ts):
437
+ return self._meta or {"id": self.path.stem, "cwd": None, "started_at": first_ts}
438
+
439
+
440
+ class PiReader(Reader):
441
+ key = "pi"
442
+
443
+ def __init__(self, path, include_subagents=False):
444
+ super().__init__(path, include_subagents)
445
+ self._meta = {}
446
+
447
+ @classmethod
448
+ def required_dir(cls, home):
449
+ return home / "sessions"
450
+
451
+ @classmethod
452
+ def absence_message(cls, home):
453
+ return f"Pi session home not found at {home / 'sessions'}"
454
+
455
+ @classmethod
456
+ def locate(cls, home, cutoff, include_subagents=False):
457
+ root = home / "sessions"
458
+ return recent_files(root.glob("*/*.jsonl"), cutoff) if root.is_dir() else []
459
+
460
+ def _read_header(self, obj):
461
+ ts = obj.get("timestamp")
462
+ if not self._meta:
463
+ self._meta = {
464
+ "id": obj.get("id") or self.path.stem,
465
+ "cwd": obj.get("cwd"),
466
+ "started_at": ts,
467
+ "originator": "pi",
468
+ "thread_source": None,
469
+ }
470
+ else:
471
+ self._meta["cwd"] = self._meta.get("cwd") or obj.get("cwd")
472
+ self._meta["started_at"] = self._meta.get("started_at") or ts
473
+
474
+ def events(self):
475
+ for obj in read_jsonl(self.path):
476
+ if obj.get("timestamp"):
477
+ yield Event(STAMP, obj["timestamp"])
478
+ kind = obj.get("type")
479
+ if kind == "session":
480
+ self._read_header(obj)
481
+ elif kind == "message" and isinstance(obj.get("message"), dict):
482
+ yield from self._decode_message(obj["message"])
483
+
484
+ def _decode_message(self, message):
485
+ role = message.get("role")
486
+ blocks = message_blocks(message.get("content"))
487
+ if role == "toolResult":
488
+ message_failed = bool(message.get("isError"))
489
+ for block in blocks:
490
+ if not isinstance(block, dict):
491
+ continue
492
+ text = block.get("text") or ""
493
+ if isinstance(text, str) and text:
494
+ yield Event(TOOL_OUTPUT, text, failed=message_failed or bool(block.get("isError")))
495
+ return
496
+ if role == "assistant":
497
+ yield Event(AGENT_TURN)
498
+ human_spoke = False
499
+ for block in blocks:
500
+ if not isinstance(block, dict):
501
+ continue
502
+ btype = block.get("type")
503
+ if btype == "text":
504
+ text = block.get("text")
505
+ if not isinstance(text, str) or not text or is_harness_injection(text):
506
+ continue
507
+ if role == "user":
508
+ human_spoke = True
509
+ yield Event(USER_SAID, text)
510
+ elif role == "assistant":
511
+ yield Event(AGENT_SAID, text)
512
+ elif btype == "toolCall":
513
+ yield Event(TOOL_CALL, stringify(block.get("arguments") or {}),
514
+ str(block.get("name") or "unknown"))
515
+ if role == "user" and human_spoke:
516
+ yield Event(USER_TURN)
517
+
518
+ def build_meta(self, first_ts):
519
+ return self._meta or {
520
+ "id": self.path.stem,
521
+ "cwd": None,
522
+ "started_at": first_ts,
523
+ "originator": "pi",
524
+ "thread_source": None,
525
+ }
526
+
527
+
528
+ def openai_style_calls(message) -> list:
529
+ """(tool name, argument text) pairs from an OpenAI-style `tool_calls` list."""
530
+ pairs = []
531
+ for call in message.get("tool_calls") or []:
532
+ if not isinstance(call, dict):
533
+ continue
534
+ function = call.get("function") or {}
535
+ name = call.get("name") or function.get("name") or "unknown"
536
+ args = call.get("arguments")
537
+ if args is None:
538
+ args = function.get("arguments")
539
+ pairs.append((str(name), stringify(args)))
540
+ return pairs
541
+
542
+
543
+ def chat_events(role: str, message: dict) -> Iterator[Event]:
544
+ """Events for one chat-completions style message; role is user/assistant/result."""
545
+ text = flatten_text(message.get("content"))
546
+ if role == "user":
547
+ if text and not is_harness_injection(text):
548
+ yield Event(USER_TURN)
549
+ yield Event(USER_SAID, text)
550
+ elif role == "assistant":
551
+ if text:
552
+ yield Event(AGENT_TURN)
553
+ yield Event(AGENT_SAID, text)
554
+ for name, args in openai_style_calls(message):
555
+ yield Event(TOOL_CALL, args, name)
556
+ elif role == "result" and text:
557
+ yield Event(TOOL_OUTPUT, text)
558
+
559
+
560
+ class GrokReader(Reader):
561
+ key = "grok"
562
+ _IGNORED_TYPES = ("system", "reasoning", "backend_tool_call")
563
+ _ROLE_OF_TYPE = {"user": "user", "assistant": "assistant", "tool_result": "result"}
564
+
565
+ @classmethod
566
+ def required_dir(cls, home):
567
+ return home / "sessions"
568
+
569
+ @classmethod
570
+ def absence_message(cls, home):
571
+ return f"Grok Build session home not found at {home / 'sessions'}"
572
+
573
+ @classmethod
574
+ def locate(cls, home, cutoff, include_subagents=False):
575
+ root = home / "sessions"
576
+ return recent_files(root.glob("*/*/chat_history.jsonl"), cutoff) if root.is_dir() else []
577
+
578
+ def events(self):
579
+ for obj in read_jsonl(self.path):
580
+ if obj.get("type") in self._IGNORED_TYPES or obj.get("synthetic_reason"):
581
+ continue
582
+ role = self._ROLE_OF_TYPE.get(obj.get("type"))
583
+ if role:
584
+ yield from chat_events(role, obj)
585
+
586
+ def build_meta(self, first_ts):
587
+ return {
588
+ "id": self.path.parent.name,
589
+ "cwd": unquote(self.path.parent.parent.name),
590
+ "started_at": None,
591
+ "originator": "grok",
592
+ "thread_source": None,
593
+ }
594
+
595
+
596
+ class ZcodeReader(Reader):
597
+ key = "zcode"
598
+ drop_when_silent = True
599
+ _ROLE_OF_ROLE = {"user": "user", "assistant": "assistant", "tool": "result"}
600
+
601
+ @classmethod
602
+ def required_dir(cls, home):
603
+ return home / "cli" / "rollout"
604
+
605
+ @classmethod
606
+ def absence_message(cls, home):
607
+ return f"ZCode model-io rollouts not found at {home / 'cli' / 'rollout'}"
608
+
609
+ @classmethod
610
+ def locate(cls, home, cutoff, include_subagents=False):
611
+ root = home / "cli" / "rollout"
612
+ return recent_files(root.glob("model-io-*.jsonl"), cutoff) if root.is_dir() else []
613
+
614
+ def events(self):
615
+ # Each line is a full request dump; only the final non-empty message
616
+ # list describes the session as it ended, so keep just that one.
617
+ latest = None
618
+ for obj in read_jsonl(self.path):
619
+ body = (obj.get("request") or {}).get("body") or {}
620
+ batch = body.get("messages")
621
+ if isinstance(batch, list) and batch:
622
+ latest = batch
623
+ if not latest:
624
+ raise SkipSession
625
+ for message in latest:
626
+ if isinstance(message, dict):
627
+ role = self._ROLE_OF_ROLE.get(message.get("role"))
628
+ if role:
629
+ yield from chat_events(role, message)
630
+
631
+ def build_meta(self, first_ts):
632
+ stem = self.path.stem
633
+ prefix = "model-io-"
634
+ return {
635
+ "id": stem[len(prefix):] if stem.startswith(prefix) else stem,
636
+ "cwd": None,
637
+ "started_at": None,
638
+ "originator": "zcode",
639
+ "thread_source": None,
640
+ }
641
+
642
+
643
+ READERS = {cls.key: cls for cls in (ClaudeReader, CodexReader, PiReader, GrokReader, ZcodeReader)}
644
+
645
+
646
+ # ---------------------------------------------------------------------------
647
+ # Digest: the single place where events become statistics and transcript
648
+ # ---------------------------------------------------------------------------
649
+
650
+ def digest_session(reader: Reader, known_skills):
651
+ """Consume a reader's events -> (meta, stats, entries, skills) or None to skip."""
652
+ known = set(known_skills)
653
+ tally = Counter()
654
+ calls_seen = set()
655
+ tools_used = set()
656
+ skills = set()
657
+ log = EntryLog()
658
+ first_ts = last_ts = None
659
+ edit_marker_hit = False
660
+
661
+ try:
662
+ for ev in reader.events():
663
+ kind = ev.kind
664
+ if kind == STAMP:
665
+ first_ts = first_ts or ev.text
666
+ last_ts = ev.text
667
+ elif kind == USER_TURN:
668
+ tally["user_turns"] += 1
669
+ elif kind == AGENT_TURN:
670
+ tally["assistant_turns"] += 1
671
+ elif kind == USER_SAID:
672
+ log.add("user", clip(ev.text, CHAT_CLIP))
673
+ elif kind == AGENT_SAID:
674
+ log.add("assistant", clip(ev.text, CHAT_CLIP))
675
+ elif kind == TOOL_CALL:
676
+ tally["tool_calls"] += 1
677
+ signature = (ev.name, ev.text)
678
+ if signature in calls_seen:
679
+ tally["repeated_tool_calls"] += 1
680
+ calls_seen.add(signature)
681
+ tools_used.add(ev.name)
682
+ skills |= skill_mentions(ev.text)
683
+ edit_marker_hit = edit_marker_hit or any(m in ev.text for m in EDIT_MARKERS)
684
+ log.add(f"tool:{ev.name}", clip(ev.text, TOOL_CLIP))
685
+ elif kind == TOOL_OUTPUT:
686
+ if ev.failed or mentions_failure(ev.text):
687
+ tally["error_outputs"] += 1
688
+ log.add("output", clip(ev.text, TOOL_CLIP))
689
+ elif kind == SKILL_HINT:
690
+ skills.add(ev.text)
691
+ except (SkipSession, OSError):
692
+ return None
693
+
694
+ if reader.drop_when_silent and not len(log):
695
+ return None
696
+
697
+ stats = {
698
+ name: tally[name]
699
+ for name in ("user_turns", "assistant_turns", "tool_calls",
700
+ "repeated_tool_calls", "error_outputs")
701
+ }
702
+ stats["first_ts"] = first_ts
703
+ stats["last_ts"] = last_ts
704
+ stats["has_code_edits"] = bool(tools_used & reader.edit_tools) or edit_marker_hit
705
+ if reader.restrict_skills:
706
+ skills &= known
707
+ return reader.build_meta(first_ts), stats, log.entries(), sorted(skills)
708
+
709
+
710
+ # Stable module-level entry points (other scripts import these).
711
+
712
+ def find_claude_session_files(claude_home: Path, cutoff: datetime, include_subagents: bool):
713
+ return ClaudeReader.locate(claude_home, cutoff, include_subagents)
714
+
715
+
716
+ def parse_claude_session(path: Path, skill_names, include_subagents: bool):
717
+ return digest_session(ClaudeReader(path, include_subagents), skill_names)
718
+
719
+
720
+ def find_codex_session_files(codex_home: Path, cutoff: datetime):
721
+ return CodexReader.locate(codex_home, cutoff)
722
+
723
+
724
+ def parse_codex_session(path: Path, skill_names, include_subagents: bool):
725
+ return digest_session(CodexReader(path, include_subagents), skill_names)
726
+
727
+
728
+ def find_pi_session_files(pi_home: Path, cutoff: datetime):
729
+ return PiReader.locate(pi_home, cutoff)
730
+
731
+
732
+ def parse_pi_session(path: Path, skill_names, include_subagents: bool):
733
+ return digest_session(PiReader(path, include_subagents), skill_names)
734
+
735
+
736
+ def find_grok_session_files(grok_home: Path, cutoff: datetime):
737
+ return GrokReader.locate(grok_home, cutoff)
738
+
739
+
740
+ def parse_grok_session(path: Path, skill_names, include_subagents: bool):
741
+ return digest_session(GrokReader(path, include_subagents), skill_names)
742
+
743
+
744
+ def find_zcode_session_files(zcode_home: Path, cutoff: datetime):
745
+ return ZcodeReader.locate(zcode_home, cutoff)
746
+
747
+
748
+ def parse_zcode_session(path: Path, skill_names, include_subagents: bool):
749
+ return digest_session(ZcodeReader(path, include_subagents), skill_names)
750
+
751
+
752
+ # ---------------------------------------------------------------------------
753
+ # Repositories and skills
754
+ # ---------------------------------------------------------------------------
755
+
756
+ def git_toplevel(directory=None):
757
+ """Repository root containing `directory` (or the cwd), or None."""
758
+ command = ["git"] + (["-C", str(directory)] if directory else []) + ["rev-parse", "--show-toplevel"]
759
+ try:
760
+ done = subprocess.run(command, capture_output=True, text=True, timeout=GIT_TIMEOUT_SECONDS)
761
+ except (subprocess.TimeoutExpired, OSError):
762
+ return None
763
+ top = done.stdout.strip()
764
+ return Path(top).resolve() if done.returncode == 0 and top else None
765
+
766
+
767
+ def resolve_project_roots(explicit):
768
+ """Turn --repo values into distinct absolute paths (default: git root of cwd)."""
769
+ if not explicit:
770
+ return [git_toplevel() or Path.cwd().resolve()]
771
+ roots = []
772
+ for value in explicit:
773
+ root = Path(value).expanduser().resolve()
774
+ if root not in roots:
775
+ roots.append(root)
776
+ return roots
777
+
778
+
779
+ def cwd_belongs_to(cwd, repo: Path) -> bool:
780
+ """Does a session's recorded working directory belong to `repo`?
781
+
782
+ Accepts a cwd nested under the repo root, and also any cwd whose last
783
+ component (or any component) equals the repo's directory name; that
784
+ catches worktrees stored elsewhere and sessions copied from another
785
+ machine. Name matching can be generous when unrelated projects share a
786
+ folder name, which is acceptable for a report.
787
+ """
788
+ if not cwd:
789
+ return False
790
+ location = Path(cwd)
791
+ try:
792
+ location.resolve().relative_to(repo)
793
+ return True
794
+ except (ValueError, OSError):
795
+ pass
796
+ return location.name == repo.name or repo.name in location.parts
797
+
798
+
799
+ def session_matches_repos(cwd, repos) -> bool:
800
+ return any(cwd_belongs_to(cwd, repo) for repo in repos)
801
+
802
+
803
+ def repos_seen_in(sessions):
804
+ """Git roots of every existing working directory the sessions recorded."""
805
+ roots = []
806
+ for session in sessions:
807
+ cwd = session["meta"].get("cwd")
808
+ if not cwd:
809
+ continue
810
+ folder = Path(cwd).expanduser()
811
+ if not folder.is_dir():
812
+ continue
813
+ top = git_toplevel(folder)
814
+ if top is not None and top not in roots:
815
+ roots.append(top)
816
+ return roots
817
+
818
+
819
+ def skill_search_roots(repos, codex_home, extra_dirs, include_global, pi_home, grok_home, zcode_home):
820
+ roots = []
821
+ for repo in repos:
822
+ roots += [repo / ".agents" / "skills", repo / ".claude" / "skills", repo / ".codex" / "skills"]
823
+ if include_global:
824
+ roots += [codex_home / "skills", Path.home() / ".agents" / "skills", Path.home() / ".claude" / "skills"]
825
+ roots += [Path(home) / "skills" for home in (pi_home, grok_home, zcode_home) if home is not None]
826
+ roots += [Path(extra).expanduser() for extra in extra_dirs]
827
+ return roots
828
+
829
+
830
+ def describe_skill(manifest: Path):
831
+ try:
832
+ body = manifest.read_text(errors="replace")
833
+ info = manifest.stat()
834
+ except OSError:
835
+ return None
836
+ found = re.search(r"^description:\s*(.+)$", body, re.MULTILINE)
837
+ summary = found.group(1).strip().strip("\"'")[:300] if found else ""
838
+ return {
839
+ "name": manifest.parent.name,
840
+ "path": str(manifest),
841
+ "description": summary,
842
+ "bytes": info.st_size,
843
+ "modified_at": datetime.fromtimestamp(info.st_mtime, tz=timezone.utc).isoformat(),
844
+ }
845
+
846
+
847
+ def discover_skills(repos, codex_home: Path, extra_dirs, include_global: bool,
848
+ pi_home: Path = None, grok_home: Path = None, zcode_home: Path = None):
849
+ """Map skill name -> descriptor; the first root that provides a name wins."""
850
+ if isinstance(repos, Path):
851
+ repos = [repos]
852
+ catalogue = {}
853
+ for root in skill_search_roots(repos, codex_home, extra_dirs, include_global,
854
+ pi_home, grok_home, zcode_home):
855
+ if not root.is_dir():
856
+ continue
857
+ for manifest in sorted(root.glob("*/SKILL.md")):
858
+ if manifest.parent.name in catalogue:
859
+ continue
860
+ entry = describe_skill(manifest)
861
+ if entry is not None:
862
+ catalogue[entry["name"]] = entry
863
+ return catalogue
864
+
865
+
866
+ def detect_skills_from_entries(entries, skill_names):
867
+ """Skills referenced by tool-call entries (chat text is deliberately ignored)."""
868
+ haystack = "\n".join(
869
+ text for role, text in entries if role == "skill" or role.startswith("tool:")
870
+ ).replace("\\", "/")
871
+ hits = set()
872
+ for name in skill_names:
873
+ signs = (
874
+ f"skills/{name}/",
875
+ f"{name}/SKILL.md",
876
+ f'"skill": "{name}"',
877
+ f'"name": "{name}"',
878
+ f'"bundled_skill_id": "{name}"',
879
+ )
880
+ if any(sign in haystack for sign in signs):
881
+ hits.add(name)
882
+ return hits
883
+
884
+
885
+ # ---------------------------------------------------------------------------
886
+ # Sampling
887
+ # ---------------------------------------------------------------------------
888
+
889
+ def sample_by_skill(sessions, limit, per_skill, no_skill, skill_names):
890
+ """Fill the sample per skill first, then top up with skill-less sessions."""
891
+ chosen = set()
892
+ quota_used = {name: 0 for name in skill_names}
893
+ for item in sessions:
894
+ if len(chosen) >= limit:
895
+ break
896
+ for name in item["skills_used"]:
897
+ if quota_used.get(name, 0) < per_skill:
898
+ quota_used[name] = quota_used.get(name, 0) + 1
899
+ chosen.add(item["_key"])
900
+ break
901
+ plain_taken = 0
902
+ for item in sessions:
903
+ if len(chosen) >= limit or plain_taken >= no_skill:
904
+ break
905
+ if not item["skills_used"] and item["_key"] not in chosen:
906
+ chosen.add(item["_key"])
907
+ plain_taken += 1
908
+ return chosen
909
+
910
+
911
+ # ---------------------------------------------------------------------------
912
+ # Output
913
+ # ---------------------------------------------------------------------------
914
+
915
+ def render_transcript(meta, stats, skills_used, entries) -> str:
916
+ header = [
917
+ f"# Session {meta.get('id')}",
918
+ f"- cwd: {meta.get('cwd')}",
919
+ f"- started: {meta.get('started_at') or stats.get('first_ts')}",
920
+ f"- skills detected: {', '.join(skills_used) or '(none)'}",
921
+ f"- stats: {stats['user_turns']} user turns, {stats['assistant_turns']} assistant turns, "
922
+ f"{stats['tool_calls']} tool calls ({stats['repeated_tool_calls']} repeated), "
923
+ f"{stats['error_outputs']} error-ish outputs, code edits: {stats['has_code_edits']}",
924
+ "",
925
+ "## Condensed transcript",
926
+ "",
927
+ ]
928
+ body = []
929
+ for role, text in entries:
930
+ body += [f"[{role}] {text}", ""]
931
+ return "\n".join(header + body)
932
+
933
+
934
+ def parse_args():
935
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
936
+ ap.add_argument("--harness", choices=("auto", "all", *READERS), default="auto",
937
+ help="session source (default: auto; scans every locally available source)")
938
+ ap.add_argument("--claude-home", default=os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude"),
939
+ help="Claude Code config directory (default: CLAUDE_CONFIG_DIR or ~/.claude)")
940
+ ap.add_argument("--codex-home", default=os.environ.get("CODEX_HOME", "~/.codex"))
941
+ ap.add_argument("--pi-home", default="~/.pi/agent", help="Pi agent home (default: ~/.pi/agent)")
942
+ ap.add_argument("--grok-home", default="~/.grok", help="Grok Build home (default: ~/.grok)")
943
+ ap.add_argument("--zcode-home", default="~/.zcode", help="ZCode home (default: ~/.zcode)")
944
+ ap.add_argument("--repo", action="append", default=[],
945
+ help="project to include (repeatable; default: git root of cwd, else cwd)")
946
+ ap.add_argument("--all-conversations", action="store_true",
947
+ help="score conversations from every project represented in local history")
948
+ ap.add_argument("--include-global-skills", action="store_true",
949
+ help="also discover skills outside the repo (~/.codex/skills, ~/.agents/skills, "
950
+ "~/.claude/skills, ~/.pi/agent/skills, ~/.grok/skills, ~/.zcode/skills)")
951
+ ap.add_argument("--days", type=int, default=45, help="only consider sessions modified in the last N days")
952
+ ap.add_argument("--max-sessions", type=int, default=12, help="max sessions to sample for scoring")
953
+ ap.add_argument("--per-skill", type=int, default=3, help="max sampled sessions per skill")
954
+ ap.add_argument("--no-skill", type=int, default=4, help="max sampled sessions that used no skill")
955
+ ap.add_argument("--skills-dir", action="append", default=[], help="extra skills directory to scan (repeatable)")
956
+ ap.add_argument("--include-subagents", action="store_true", help="include subagent/child sessions")
957
+ ap.add_argument("--out", default="./doctor-md-skill-report")
958
+ return ap.parse_args()
959
+
960
+
961
+ class Scan:
962
+ """Running totals while the selected sources are read."""
963
+
964
+ def __init__(self):
965
+ self.sessions = []
966
+ self.sources = {}
967
+ self.files_seen = 0
968
+ self.in_scope = 0
969
+
970
+
971
+ def scan_source(reader_cls, home, args, cutoff, skills, repos, scan: Scan):
972
+ files = reader_cls.locate(home, cutoff, args.include_subagents)
973
+ scan.sources[reader_cls.key] = {"home": str(home), "records_in_window": len(files)}
974
+ scan.files_seen += len(files)
975
+ for mtime, path in files:
976
+ parsed = digest_session(reader_cls(path, args.include_subagents), skills.keys())
977
+ if parsed is None:
978
+ continue
979
+ meta, stats, entries, used = parsed
980
+ if not args.all_conversations and not session_matches_repos(meta.get("cwd"), repos):
981
+ continue
982
+ scan.in_scope += 1
983
+ if stats["assistant_turns"] < 1 or stats["tool_calls"] < 1:
984
+ continue
985
+ scan.sessions.append({
986
+ "harness": reader_cls.key,
987
+ "meta": meta,
988
+ "stats": stats,
989
+ "skills_used": used,
990
+ "file": str(path),
991
+ "modified_at": mtime.isoformat(),
992
+ "_entries": entries,
993
+ })
994
+
995
+
996
+ def main():
997
+ args = parse_args()
998
+ if args.all_conversations and args.repo:
999
+ print("error: --all-conversations cannot be combined with --repo", file=sys.stderr)
1000
+ sys.exit(2)
1001
+
1002
+ homes = {
1003
+ "claude": Path(args.claude_home).expanduser(),
1004
+ "codex": Path(args.codex_home).expanduser(),
1005
+ "pi": Path(args.pi_home).expanduser(),
1006
+ "grok": Path(args.grok_home).expanduser(),
1007
+ "zcode": Path(args.zcode_home).expanduser(),
1008
+ }
1009
+ out_dir = Path(args.out).expanduser()
1010
+ transcripts_dir = out_dir / "transcripts"
1011
+ transcripts_dir.mkdir(parents=True, exist_ok=True)
1012
+
1013
+ def find_skills(repo_list):
1014
+ return discover_skills(repo_list, homes["codex"], args.skills_dir, args.include_global_skills,
1015
+ pi_home=homes["pi"], grok_home=homes["grok"], zcode_home=homes["zcode"])
1016
+
1017
+ repos = [] if args.all_conversations else resolve_project_roots(args.repo)
1018
+ skills = find_skills(repos)
1019
+ cutoff = datetime.now(timezone.utc) - timedelta(days=args.days)
1020
+
1021
+ scan = Scan()
1022
+ for key, reader_cls in READERS.items():
1023
+ home = homes[key]
1024
+ wanted = args.harness in ("auto", "all", key)
1025
+ if wanted and reader_cls.required_dir(home).is_dir():
1026
+ scan_source(reader_cls, home, args, cutoff, skills, repos, scan)
1027
+ elif args.harness == key:
1028
+ print(f"error: {reader_cls.absence_message(home)}", file=sys.stderr)
1029
+ sys.exit(1)
1030
+
1031
+ if not scan.sources:
1032
+ print("error: no supported session source found (Claude Code, Codex, Pi, Grok, or ZCode)",
1033
+ file=sys.stderr)
1034
+ sys.exit(1)
1035
+
1036
+ sessions = scan.sessions
1037
+ if args.all_conversations:
1038
+ repos = repos_seen_in(sessions)
1039
+ skills = find_skills(repos)
1040
+ installed = set(skills)
1041
+ for session in sessions:
1042
+ extra = detect_skills_from_entries(session["_entries"], installed)
1043
+ session["skills_used"] = sorted((set(session["skills_used"]) | extra) & installed)
1044
+
1045
+ sessions.sort(key=lambda item: item["modified_at"], reverse=True)
1046
+ for session in sessions:
1047
+ session["_key"] = f"{session['harness']}:{session['meta']['id']}"
1048
+
1049
+ chosen = sample_by_skill(sessions, args.max_sessions, args.per_skill, args.no_skill, skills)
1050
+
1051
+ for session in sessions:
1052
+ session["sampled"] = session["_key"] in chosen
1053
+ if session["sampled"]:
1054
+ target = transcripts_dir / f"{session['harness']}-{session['meta']['id']}.md"
1055
+ target.write_text(render_transcript(session["meta"], session["stats"],
1056
+ session["skills_used"], session["_entries"]))
1057
+ session["transcript_path"] = str(target)
1058
+ del session["_entries"]
1059
+ del session["_key"]
1060
+
1061
+ usage = {name: 0 for name in skills}
1062
+ for session in sessions:
1063
+ for name in session["skills_used"]:
1064
+ usage[name] += 1
1065
+
1066
+ if args.all_conversations:
1067
+ scope_kind, scope_label = "all", "all-conversations"
1068
+ else:
1069
+ scope_kind = "projects"
1070
+ scope_label = repos[0].name if len(repos) == 1 else "multiple-projects"
1071
+
1072
+ active = scan.sources
1073
+ inventory = {
1074
+ "generated_at": datetime.now(timezone.utc).isoformat(),
1075
+ "harness": next(iter(active)) if len(active) == 1 else "mixed",
1076
+ "sources": active,
1077
+ **{f"{key}_home": (str(homes[key]) if key in active else None) for key in READERS},
1078
+ "conversation_scope": scope_kind,
1079
+ "repo": str(repos[0]) if len(repos) == 1 else None,
1080
+ "repos": [str(repo) for repo in repos],
1081
+ "repo_name": scope_label,
1082
+ "repo_names": [repo.name for repo in repos],
1083
+ "window_days": args.days,
1084
+ "skills": sorted(skills.values(), key=lambda entry: entry["name"]),
1085
+ "skill_usage": usage,
1086
+ "stats": {
1087
+ "session_files_in_window": scan.files_seen,
1088
+ "session_records_in_window": scan.files_seen,
1089
+ "sessions_in_repo": scan.in_scope,
1090
+ "sessions_in_scope": scan.in_scope,
1091
+ "sessions_considered": len(sessions),
1092
+ "sessions_sampled": len(chosen),
1093
+ "skills_found": len(skills),
1094
+ "skills_used": sum(1 for count in usage.values() if count > 0),
1095
+ },
1096
+ "sessions": sessions,
1097
+ }
1098
+ (out_dir / "inventory.json").write_text(json.dumps(inventory, indent=2))
1099
+
1100
+ totals = inventory["stats"]
1101
+ scope_text = "all conversations" if args.all_conversations else ", ".join(str(r) for r in repos)
1102
+ print(f"scope: {scope_text}")
1103
+ print(f"sources: {', '.join(active)}")
1104
+ print(f"skills found: {totals['skills_found']} ({totals['skills_used']} used in window)")
1105
+ print(f"sessions in window: {totals['session_records_in_window']} records, "
1106
+ f"{totals['sessions_in_scope']} in scope, {totals['sessions_considered']} scoreable")
1107
+ print(f"sessions sampled: {totals['sessions_sampled']} -> {transcripts_dir}")
1108
+ print(f"inventory: {out_dir / 'inventory.json'}")
1109
+
1110
+
1111
+ if __name__ == "__main__":
1112
+ main()