@iseoane/agent-depot 0.2.0 → 0.3.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 (86) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +117 -3
  3. package/dist/src/app-cli.js +172 -0
  4. package/dist/src/app-flow.js +457 -0
  5. package/dist/src/app-latest.js +63 -0
  6. package/dist/src/app-owned-skills.js +42 -0
  7. package/dist/src/app-recipes.js +116 -0
  8. package/dist/src/app-schema.js +45 -0
  9. package/dist/src/app-version.js +46 -0
  10. package/dist/src/atomic-file.js +26 -0
  11. package/dist/src/cli.js +1256 -0
  12. package/dist/src/git-source.js +409 -0
  13. package/dist/src/path-safety.js +31 -0
  14. package/dist/src/process-runner.js +41 -0
  15. package/dist/src/project-installation.js +1411 -0
  16. package/dist/src/project-manifest.js +511 -0
  17. package/dist/src/skill-adoption.js +77 -0
  18. package/dist/src/skill-discovery.js +467 -0
  19. package/dist/src/skill-hosts.js +103 -0
  20. package/dist/src/skill-install.js +374 -0
  21. package/dist/src/skill-removal.js +119 -0
  22. package/dist/src/skill-update.js +244 -0
  23. package/dist/src/source-state.js +240 -0
  24. package/dist/src/sources.js +391 -0
  25. package/dist/src/tui/adoption-actions.js +144 -0
  26. package/dist/src/tui/app-actions.js +157 -0
  27. package/dist/src/tui/app.js +63 -0
  28. package/dist/src/tui/batch.js +17 -0
  29. package/dist/src/tui/bulk-actions.js +243 -0
  30. package/dist/src/tui/catalog-actions.js +9 -0
  31. package/dist/src/tui/catalog-browse.js +59 -0
  32. package/dist/src/tui/catalog-filter.js +20 -0
  33. package/dist/src/tui/catalog-flow.js +107 -0
  34. package/dist/src/tui/catalog-installs.js +193 -0
  35. package/dist/src/tui/catalog-panel.js +47 -0
  36. package/dist/src/tui/catalog-state.js +92 -0
  37. package/dist/src/tui/catalog-tree.js +35 -0
  38. package/dist/src/tui/catalog-view.js +89 -0
  39. package/dist/src/tui/environment.js +1 -0
  40. package/dist/src/tui/installations-browse.js +136 -0
  41. package/dist/src/tui/installations-mode.js +1 -0
  42. package/dist/src/tui/installations-panel.js +27 -0
  43. package/dist/src/tui/installations-rows.js +11 -0
  44. package/dist/src/tui/installations-tree.js +80 -0
  45. package/dist/src/tui/installations-view.js +154 -0
  46. package/dist/src/tui/installations.js +172 -0
  47. package/dist/src/tui/keys.js +31 -0
  48. package/dist/src/tui/manage-actions.js +174 -0
  49. package/dist/src/tui/manage-panel.js +65 -0
  50. package/dist/src/tui/mode-keys.js +61 -0
  51. package/dist/src/tui/panel-parts.js +23 -0
  52. package/dist/src/tui/render.js +8 -0
  53. package/dist/src/tui/sources-mode.js +17 -0
  54. package/dist/src/tui/sources-view.js +276 -0
  55. package/dist/src/tui/theme.js +29 -0
  56. package/dist/src/tui/tree-navigation.js +68 -0
  57. package/dist/src/tui/tree.js +53 -0
  58. package/dist/src/tui/unmanaged-actions.js +178 -0
  59. package/dist/src/tui/updates-flow.js +89 -0
  60. package/dist/src/tui/updates-keys.js +104 -0
  61. package/dist/src/tui/updates-mode.js +1 -0
  62. package/dist/src/tui/updates-model.js +48 -0
  63. package/dist/src/tui/updates-panel.js +44 -0
  64. package/dist/src/tui/updates-view.js +101 -0
  65. package/dist/src/tui/updates.js +230 -0
  66. package/dist/src/tui/view-state.js +42 -0
  67. package/dist/src/tui/window.js +31 -0
  68. package/dist/src/unmanaged-removal.js +60 -0
  69. package/dist/src/update-batch.js +376 -0
  70. package/dist/src/update-flow.js +135 -0
  71. package/dist/src/usage-error.js +7 -0
  72. package/dist/src/user-global-skill-inventory.js +565 -0
  73. package/dist/src/user-method.js +14 -0
  74. package/package.json +7 -4
  75. package/skills/agent-depot-apprecipe/SKILL.md +44 -0
  76. package/skills/agent-depot-apprecipe/assets/example.md +25 -0
  77. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
  78. package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
  79. package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
  80. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
  81. package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
  82. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
  83. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
  84. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
  85. package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
  86. package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
@@ -1,780 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Tests for the session collector (collect_sessions.py)."""
3
-
4
- import json
5
- import os
6
- import tempfile
7
- import unittest
8
- from datetime import datetime, timedelta, timezone
9
- from pathlib import Path
10
-
11
- from collect_sessions import (
12
- detect_skills_from_entries,
13
- discover_skills,
14
- find_claude_session_files,
15
- find_grok_session_files,
16
- find_pi_session_files,
17
- parse_claude_session,
18
- parse_codex_session,
19
- parse_grok_session,
20
- parse_pi_session,
21
- parse_zcode_session,
22
- session_matches_repos,
23
- )
24
-
25
- # Additional imports for the CLI-level tests below, which drive main() and
26
- # parse_args() through the module object itself.
27
- import contextlib
28
- import io
29
- import sys
30
- from unittest import mock
31
-
32
- import collect_sessions as cs
33
-
34
- PREVIOUS_FILE_LIMIT = 8 * 1024 * 1024
35
-
36
-
37
- def write_jsonl(path, records):
38
- path.parent.mkdir(parents=True, exist_ok=True)
39
- path.write_text("\n".join(json.dumps(record) for record in records) + "\n")
40
-
41
-
42
- def write_jsonl_past_previous_limit(path, first_record, last_records):
43
- path.parent.mkdir(parents=True, exist_ok=True)
44
- filler = json.dumps({"type": "ignored", "padding": "x" * 1024}) + "\n"
45
- with path.open("w", encoding="utf-8") as stream:
46
- stream.write(json.dumps(first_record) + "\n")
47
- while stream.tell() <= PREVIOUS_FILE_LIMIT:
48
- stream.write(filler)
49
- for record in last_records:
50
- stream.write(json.dumps(record) + "\n")
51
-
52
-
53
- class ClaudeSessionTests(unittest.TestCase):
54
- def test_discovers_skills_and_matches_sessions_across_projects(self):
55
- with tempfile.TemporaryDirectory() as tmp:
56
- root = Path(tmp)
57
- first = root / "first"
58
- second = root / "second"
59
- first_skill = first / ".agents" / "skills" / "alpha" / "SKILL.md"
60
- second_skill = second / ".claude" / "skills" / "beta" / "SKILL.md"
61
- first_skill.parent.mkdir(parents=True)
62
- second_skill.parent.mkdir(parents=True)
63
- first_skill.write_text("---\ndescription: Alpha\n---\n")
64
- second_skill.write_text("---\ndescription: Beta\n---\n")
65
-
66
- skills = discover_skills(
67
- [first, second],
68
- root / "codex-home",
69
- [],
70
- False,
71
- )
72
-
73
- self.assertEqual(set(skills), {"alpha", "beta"})
74
- self.assertTrue(
75
- session_matches_repos(second / "src", [first, second])
76
- )
77
- self.assertFalse(
78
- session_matches_repos(root / "elsewhere", [first, second])
79
- )
80
-
81
- def test_discovers_global_skills_without_repositories(self):
82
- with tempfile.TemporaryDirectory() as tmp:
83
- root = Path(tmp)
84
- homes = {
85
- name: root / f"{name}-home"
86
- for name in ("codex", "pi", "grok", "zcode")
87
- }
88
- for name, home in homes.items():
89
- skill = home / "skills" / f"{name}-skill" / "SKILL.md"
90
- skill.parent.mkdir(parents=True)
91
- skill.write_text(f"---\ndescription: {name.title()} skill\n---\n")
92
-
93
- skills = discover_skills(
94
- [],
95
- homes["codex"],
96
- [],
97
- True,
98
- pi_home=homes["pi"],
99
- grok_home=homes["grok"],
100
- zcode_home=homes["zcode"],
101
- )
102
-
103
- self.assertTrue(
104
- {
105
- "codex-skill",
106
- "pi-skill",
107
- "grok-skill",
108
- "zcode-skill",
109
- }.issubset(skills)
110
- )
111
-
112
- def test_detects_skills_from_deferred_tool_entries(self):
113
- entries = [
114
- ("tool:Skill", '{"skill": "alpha"}'),
115
- ("tool:read", '{"path": "/repo/.agents/skills/beta/SKILL.md"}'),
116
- ("assistant", "Mentioning gamma here does not count."),
117
- ]
118
-
119
- self.assertEqual(
120
- detect_skills_from_entries(entries, {"alpha", "beta", "gamma"}),
121
- {"alpha", "beta"},
122
- )
123
-
124
- def test_discovers_parent_sessions_and_optional_subagents(self):
125
- with tempfile.TemporaryDirectory() as tmp:
126
- claude_home = Path(tmp)
127
- parent = claude_home / "projects" / "-repo" / "parent.jsonl"
128
- subagent = (
129
- claude_home
130
- / "projects"
131
- / "-repo"
132
- / "parent"
133
- / "subagents"
134
- / "agent-child.jsonl"
135
- )
136
- old = claude_home / "projects" / "-repo" / "old.jsonl"
137
- for path in (parent, subagent, old):
138
- write_jsonl(path, [{"type": "user"}])
139
- old_time = (datetime.now(timezone.utc) - timedelta(days=10)).timestamp()
140
- os.utime(old, (old_time, old_time))
141
- cutoff = datetime.now(timezone.utc) - timedelta(days=1)
142
-
143
- parents = find_claude_session_files(claude_home, cutoff, False)
144
- with_subagents = find_claude_session_files(claude_home, cutoff, True)
145
-
146
- self.assertEqual([path for _, path in parents], [parent])
147
- self.assertEqual(
148
- {path for _, path in with_subagents},
149
- {parent, subagent},
150
- )
151
-
152
- def test_parses_messages_tools_skills_and_stats(self):
153
- with tempfile.TemporaryDirectory() as tmp:
154
- path = Path(tmp) / "session.jsonl"
155
- common = {
156
- "sessionId": "session-1",
157
- "cwd": "/tmp/repo",
158
- "timestamp": "2026-08-20T10:00:00Z",
159
- "version": "1.0.0",
160
- }
161
- write_jsonl(path, [
162
- {
163
- **common,
164
- "type": "user",
165
- "uuid": "user-1",
166
- "message": {"role": "user", "content": "Improve my skill"},
167
- },
168
- {
169
- **common,
170
- "type": "assistant",
171
- "uuid": "assistant-1",
172
- "message": {
173
- "id": "message-1",
174
- "role": "assistant",
175
- "content": [
176
- {"type": "text", "text": "I will inspect it."},
177
- {
178
- "type": "tool_use",
179
- "name": "Skill",
180
- "input": {"skill": "update-skill"},
181
- },
182
- ],
183
- },
184
- },
185
- {
186
- **common,
187
- "type": "assistant",
188
- "uuid": "assistant-2",
189
- "message": {
190
- "id": "message-1",
191
- "role": "assistant",
192
- "content": [
193
- {
194
- "type": "tool_use",
195
- "name": "Edit",
196
- "input": {"file_path": "/tmp/repo/SKILL.md"},
197
- }
198
- ],
199
- },
200
- },
201
- {
202
- **common,
203
- "type": "user",
204
- "uuid": "result-1",
205
- "message": {
206
- "role": "user",
207
- "content": [
208
- {
209
- "type": "tool_result",
210
- "is_error": True,
211
- "content": "permission denied",
212
- }
213
- ],
214
- },
215
- },
216
- ])
217
-
218
- meta, stats, entries, skills = parse_claude_session(
219
- path,
220
- {"update-skill"},
221
- False,
222
- )
223
-
224
- self.assertEqual(meta["id"], "session-1")
225
- self.assertEqual(meta["cwd"], "/tmp/repo")
226
- self.assertEqual(stats["user_turns"], 1)
227
- self.assertEqual(stats["assistant_turns"], 1)
228
- self.assertEqual(stats["tool_calls"], 2)
229
- self.assertEqual(stats["error_outputs"], 1)
230
- self.assertTrue(stats["has_code_edits"])
231
- self.assertEqual(skills, ["update-skill"])
232
- self.assertIn(("user", "Improve my skill"), entries)
233
- self.assertIn(("assistant", "I will inspect it."), entries)
234
-
235
- def test_excludes_sidechains_by_default(self):
236
- with tempfile.TemporaryDirectory() as tmp:
237
- path = Path(tmp) / "agent-child.jsonl"
238
- write_jsonl(path, [{
239
- "type": "user",
240
- "sessionId": "session-1",
241
- "agentId": "child-1",
242
- "isSidechain": True,
243
- "cwd": "/tmp/repo",
244
- "timestamp": "2026-08-20T10:00:00Z",
245
- "message": {"role": "user", "content": "Investigate"},
246
- }])
247
-
248
- self.assertIsNone(parse_claude_session(path, set(), False))
249
- parsed = parse_claude_session(path, set(), True)
250
- self.assertEqual(parsed[0]["id"], "session-1-child-1")
251
- self.assertEqual(parsed[0]["thread_source"], "subagent")
252
-
253
-
254
- class PiSessionTests(unittest.TestCase):
255
- def test_parses_messages_tools_skills_and_stats(self):
256
- with tempfile.TemporaryDirectory() as tmp:
257
- path = Path(tmp) / "2026-08-20T10-00-00-000Z_session-1.jsonl"
258
- write_jsonl(path, [
259
- {
260
- "type": "session",
261
- "id": "session-1",
262
- "cwd": "/tmp/repo",
263
- "timestamp": "2026-08-20T10:00:00.000Z",
264
- },
265
- {"type": "model_change", "provider": "zai", "modelId": "glm"},
266
- {
267
- "type": "message",
268
- "timestamp": "2026-08-20T10:00:01.000Z",
269
- "message": {
270
- "role": "user",
271
- "content": [{"type": "text", "text": "Improve my skill"}],
272
- },
273
- },
274
- {
275
- "type": "message",
276
- "timestamp": "2026-08-20T10:00:02.000Z",
277
- "message": {
278
- "role": "assistant",
279
- "content": [
280
- {"type": "thinking", "thinking": "plan the edit"},
281
- {"type": "text", "text": "I will inspect it."},
282
- {
283
- "type": "toolCall",
284
- "name": "read",
285
- "arguments": {
286
- "path": "/repo/.agents/skills/update-skill/SKILL.md"
287
- },
288
- },
289
- ],
290
- },
291
- },
292
- {
293
- "type": "message",
294
- "timestamp": "2026-08-20T10:00:03.000Z",
295
- "message": {
296
- "role": "toolResult",
297
- "toolName": "read",
298
- "content": [{"type": "text", "text": "--- description: alpha"}],
299
- },
300
- },
301
- {
302
- "type": "message",
303
- "timestamp": "2026-08-20T10:00:04.000Z",
304
- "message": {
305
- "role": "assistant",
306
- "content": [
307
- {
308
- "type": "toolCall",
309
- "name": "edit",
310
- "arguments": {
311
- "path": "/tmp/repo/SKILL.md",
312
- "oldText": "a",
313
- "newText": "b",
314
- },
315
- },
316
- ],
317
- },
318
- },
319
- {
320
- "type": "message",
321
- "timestamp": "2026-08-20T10:00:05.000Z",
322
- "message": {
323
- "role": "toolResult",
324
- "toolName": "edit",
325
- "content": [
326
- {"type": "text", "text": "oldText not found", "isError": True}
327
- ],
328
- },
329
- },
330
- ])
331
-
332
- meta, stats, entries, skills = parse_pi_session(path, {"update-skill"}, False)
333
-
334
- self.assertEqual(meta["id"], "session-1")
335
- self.assertEqual(meta["cwd"], "/tmp/repo")
336
- self.assertEqual(meta["originator"], "pi")
337
- self.assertEqual(stats["user_turns"], 1)
338
- self.assertEqual(stats["assistant_turns"], 2)
339
- self.assertEqual(stats["tool_calls"], 2)
340
- self.assertEqual(stats["repeated_tool_calls"], 0)
341
- self.assertEqual(stats["error_outputs"], 1)
342
- self.assertTrue(stats["has_code_edits"])
343
- self.assertEqual(stats["first_ts"], "2026-08-20T10:00:00.000Z")
344
- self.assertEqual(stats["last_ts"], "2026-08-20T10:00:05.000Z")
345
- self.assertEqual(skills, ["update-skill"])
346
- self.assertIn(("user", "Improve my skill"), entries)
347
- self.assertIn(("assistant", "I will inspect it."), entries)
348
- self.assertIn(("output", "--- description: alpha"), entries)
349
- self.assertFalse(any(role == "thinking" for role, _ in entries))
350
-
351
- def test_accepts_include_subagents_without_changing_result(self):
352
- with tempfile.TemporaryDirectory() as tmp:
353
- path = Path(tmp) / "session-2.jsonl"
354
- write_jsonl(path, [
355
- {"type": "session", "id": "session-2", "cwd": "/tmp/repo",
356
- "timestamp": "2026-08-20T10:00:00.000Z"},
357
- {"type": "message", "timestamp": "2026-08-20T10:00:01.000Z",
358
- "message": {"role": "user", "content": "Hello there"}},
359
- ])
360
-
361
- without_flag = parse_pi_session(path, set(), False)
362
- with_flag = parse_pi_session(path, set(), True)
363
-
364
- self.assertEqual(without_flag, with_flag)
365
- self.assertEqual(with_flag[0]["originator"], "pi")
366
-
367
- def test_finds_recent_session_files_only(self):
368
- with tempfile.TemporaryDirectory() as tmp:
369
- pi_home = Path(tmp)
370
- recent = pi_home / "sessions" / "--tmp-repo" / "recent.jsonl"
371
- old = pi_home / "sessions" / "--tmp-repo" / "old.jsonl"
372
- for path in (recent, old):
373
- write_jsonl(path, [{"type": "session", "id": "x"}])
374
- old_time = (datetime.now(timezone.utc) - timedelta(days=10)).timestamp()
375
- os.utime(old, (old_time, old_time))
376
- cutoff = datetime.now(timezone.utc) - timedelta(days=1)
377
-
378
- files = find_pi_session_files(pi_home, cutoff)
379
-
380
- self.assertEqual([path for _, path in files], [recent])
381
-
382
-
383
- class GrokSessionTests(unittest.TestCase):
384
- def test_parses_tool_calls_and_skips_synthetic_messages(self):
385
- with tempfile.TemporaryDirectory() as tmp:
386
- grok_home = Path(tmp)
387
- session_dir = grok_home / "sessions" / "%2Ftmp%2Frepo" / "abc-123"
388
- path = session_dir / "chat_history.jsonl"
389
- write_jsonl(path, [
390
- {"type": "system", "content": "You are Grok."},
391
- {
392
- "type": "user",
393
- "synthetic_reason": "system_reminder",
394
- "content": [{"type": "text", "text": "<system-reminder>skills</system-reminder>"}],
395
- },
396
- {"type": "user", "content": "Fix the collector"},
397
- {
398
- "type": "reasoning",
399
- "id": "rs-1",
400
- "summary": "[]",
401
- "encrypted_content": "xx",
402
- "status": "completed",
403
- },
404
- {
405
- "type": "assistant",
406
- "content": "I will read the skill.",
407
- "tool_calls": [{
408
- "id": "call-1",
409
- "name": "read_file",
410
- "arguments": "{\"path\": \"/repo/.grok/skills/update-skill/SKILL.md\"}",
411
- }],
412
- },
413
- {"type": "tool_result", "tool_call_id": "call-1", "content": "file body"},
414
- {
415
- "type": "assistant",
416
- "content": "Now writing the file.",
417
- "tool_calls": [{
418
- "id": "call-2",
419
- "name": "write",
420
- "arguments": "{\"path\": \"/tmp/repo/out.txt\", \"content\": \"hi\"}",
421
- }],
422
- },
423
- ])
424
- cutoff = datetime.now(timezone.utc) - timedelta(days=1)
425
-
426
- files = find_grok_session_files(grok_home, cutoff)
427
- self.assertEqual([found for _, found in files], [path])
428
-
429
- meta, stats, entries, skills = parse_grok_session(path, {"update-skill"}, False)
430
-
431
- self.assertEqual(meta["id"], "abc-123")
432
- self.assertEqual(meta["cwd"], "/tmp/repo")
433
- self.assertEqual(meta["originator"], "grok")
434
- self.assertEqual(stats["user_turns"], 1)
435
- self.assertEqual(stats["assistant_turns"], 2)
436
- self.assertEqual(stats["tool_calls"], 2)
437
- self.assertEqual(stats["error_outputs"], 0)
438
- self.assertTrue(stats["has_code_edits"])
439
- self.assertEqual(skills, ["update-skill"])
440
- self.assertEqual(entries[0], ("user", "Fix the collector"))
441
- self.assertNotIn("system-reminder", json.dumps(entries))
442
-
443
-
444
- class ZcodeSessionTests(unittest.TestCase):
445
- def test_parses_last_request_messages_and_tool_calls(self):
446
- with tempfile.TemporaryDirectory() as tmp:
447
- path = Path(tmp) / "model-io-sess_1.jsonl"
448
- empty_request = {"request": {"body": {"messages": []}}}
449
- full_request = {
450
- "request": {
451
- "body": {
452
- "messages": [
453
- {"role": "system", "content": "You are ZCode."},
454
- {"role": "user", "content": "Update the skill"},
455
- {
456
- "role": "assistant",
457
- "content": "Checking.",
458
- "tool_calls": [{
459
- "id": "c1",
460
- "function": {
461
- "name": "read_file",
462
- "arguments": "{\"path\": \"/r/.agents/skills/update-skill/SKILL.md\"}",
463
- },
464
- }],
465
- },
466
- {"role": "tool", "content": "skill body"},
467
- ]
468
- }
469
- }
470
- }
471
- path.write_text(
472
- json.dumps(empty_request) + "\n" + json.dumps(full_request) + "\n"
473
- )
474
-
475
- meta, stats, entries, skills = parse_zcode_session(path, {"update-skill"}, False)
476
-
477
- self.assertEqual(meta["id"], "sess_1")
478
- self.assertEqual(meta["originator"], "zcode")
479
- self.assertEqual(stats["user_turns"], 1)
480
- self.assertEqual(stats["assistant_turns"], 1)
481
- self.assertEqual(stats["tool_calls"], 1)
482
- self.assertEqual(skills, ["update-skill"])
483
- self.assertEqual(entries[0], ("user", "Update the skill"))
484
-
485
- def test_returns_none_without_request_records(self):
486
- with tempfile.TemporaryDirectory() as tmp:
487
- path = Path(tmp) / "model-io-sess_2.jsonl"
488
- path.write_text("{\"unrelated\": true}\n")
489
-
490
- self.assertIsNone(parse_zcode_session(path, set(), False))
491
-
492
-
493
- class StreamingSessionReadTests(unittest.TestCase):
494
- def test_claude_and_codex_parse_records_after_previous_file_limit(self):
495
- with tempfile.TemporaryDirectory() as tmp:
496
- root = Path(tmp)
497
- claude_path = root / "claude.jsonl"
498
- codex_path = root / "codex.jsonl"
499
- write_jsonl_past_previous_limit(
500
- claude_path,
501
- {
502
- "type": "user",
503
- "sessionId": "claude-session",
504
- "cwd": "/tmp/repo",
505
- "timestamp": "2026-08-30T10:00:00Z",
506
- "message": {"role": "user", "content": "hello"},
507
- },
508
- [{
509
- "type": "assistant",
510
- "sessionId": "claude-session",
511
- "cwd": "/tmp/repo",
512
- "timestamp": "2026-08-30T10:01:00Z",
513
- "message": {
514
- "id": "late-message",
515
- "role": "assistant",
516
- "content": [
517
- {"type": "text", "text": "late claude message"},
518
- {
519
- "type": "tool_use",
520
- "name": "Read",
521
- "input": {
522
- "file_path": "/tmp/.agents/skills/late-skill/SKILL.md"
523
- },
524
- },
525
- ],
526
- },
527
- }],
528
- )
529
- write_jsonl_past_previous_limit(
530
- codex_path,
531
- {
532
- "type": "session_meta",
533
- "payload": {"id": "codex-session", "cwd": "/tmp/repo"},
534
- },
535
- [{
536
- "type": "response_item",
537
- "timestamp": "2026-08-30T10:00:30Z",
538
- "payload": {
539
- "type": "function_call",
540
- "name": "read_file",
541
- "arguments": {
542
- "path": "/tmp/.codex/skills/late-skill/SKILL.md",
543
- "operation": "apply_patch",
544
- },
545
- },
546
- }, {
547
- "type": "response_item",
548
- "timestamp": "2026-08-30T10:01:00Z",
549
- "payload": {
550
- "type": "message",
551
- "role": "assistant",
552
- "content": [{"type": "text", "text": "late codex message"}],
553
- },
554
- }],
555
- )
556
-
557
- parsed_claude = parse_claude_session(claude_path, {"late-skill"}, False)
558
- parsed_codex = parse_codex_session(codex_path, {"late-skill"}, False)
559
-
560
- self.assertEqual(parsed_claude[1]["assistant_turns"], 1)
561
- self.assertEqual(parsed_claude[1]["tool_calls"], 1)
562
- self.assertEqual(parsed_claude[3], ["late-skill"])
563
- self.assertIn(("assistant", "late claude message"), parsed_claude[2])
564
- self.assertEqual(parsed_codex[1]["tool_calls"], 1)
565
- self.assertTrue(parsed_codex[1]["has_code_edits"])
566
- self.assertEqual(parsed_codex[3], ["late-skill"])
567
- self.assertIn(("assistant", "late codex message"), parsed_codex[2])
568
-
569
- def test_codex_keeps_only_transcript_head_and_tail_while_parsing(self):
570
- with tempfile.TemporaryDirectory() as tmp:
571
- path = Path(tmp) / "codex.jsonl"
572
- records = [{
573
- "type": "session_meta",
574
- "payload": {"id": "codex-session", "cwd": "/tmp/repo"},
575
- }]
576
- for index in range(201):
577
- records.extend(({
578
- "type": "event_msg",
579
- "payload": {"type": "agent_message"},
580
- }, {
581
- "type": "response_item",
582
- "payload": {
583
- "type": "message",
584
- "role": "assistant",
585
- "content": [{"type": "text", "text": f"message-{index}"}],
586
- },
587
- }))
588
- write_jsonl(path, records)
589
-
590
- _, stats, entries, _ = parse_codex_session(path, set(), False)
591
-
592
- self.assertEqual(stats["assistant_turns"], 201)
593
- self.assertEqual(len(entries), 141)
594
- self.assertEqual(entries[0], ("assistant", "message-0"))
595
- self.assertEqual(entries[99], ("assistant", "message-99"))
596
- self.assertEqual(entries[100], ("note", "[... 61 entries omitted ...]"))
597
- self.assertEqual(entries[101], ("assistant", "message-161"))
598
- self.assertEqual(entries[-1], ("assistant", "message-200"))
599
-
600
-
601
- # ---------------------------------------------------------------------------
602
- # CLI-level tests: --sample modes and --harness validation.
603
- # ---------------------------------------------------------------------------
604
-
605
-
606
- def write_claude_session_file(path, session_id, cwd="/nonexistent/repo", skill=None,
607
- timestamp="2026-08-20T10:00:00Z"):
608
- """A minimal Claude Code session with exactly one user turn and one
609
- assistant turn carrying a tool_use call -- main() drops any session with
610
- zero assistant_turns or zero tool_calls before sampling even sees it, so
611
- every sampling fixture needs at least this much to be considered at all.
612
- `cwd` deliberately does not exist on disk: --all-conversations re-infers
613
- repos via `git -C <cwd> rev-parse ...`, and a non-existent cwd short-
614
- circuits that (Path.is_dir() is False) before any subprocess runs.
615
- """
616
- common = {"sessionId": session_id, "cwd": cwd, "timestamp": timestamp}
617
- if skill:
618
- tool_block = {"type": "tool_use", "name": "Skill", "input": {"skill": skill}}
619
- else:
620
- tool_block = {"type": "tool_use", "name": "Edit", "input": {"file_path": f"{cwd}/file.py"}}
621
- write_jsonl(path, [
622
- {**common, "type": "user", "uuid": f"{session_id}-user-1",
623
- "message": {"role": "user", "content": "hello"}},
624
- {**common, "type": "assistant", "uuid": f"{session_id}-assistant-1",
625
- "message": {"id": f"{session_id}-m1", "role": "assistant",
626
- "content": [{"type": "text", "text": "working on it"}, tool_block]}},
627
- ])
628
-
629
-
630
- def set_mtime(path, seconds_ago):
631
- """Give a fixture file a precise, distinct mtime so sampling order
632
- (which sorts by file mtime, newest first) is deterministic in a test."""
633
- t = (datetime.now(timezone.utc) - timedelta(seconds=seconds_ago)).timestamp()
634
- os.utime(path, (t, t))
635
-
636
-
637
- def write_skill_fixture(skills_dir, name):
638
- skill_md = skills_dir / name / "SKILL.md"
639
- skill_md.parent.mkdir(parents=True, exist_ok=True)
640
- skill_md.write_text(f"---\ndescription: {name.title()}\n---\n")
641
-
642
-
643
- def run_collect(claude_home, out_dir, extra_args=None, **kwargs):
644
- """Drive collect_sessions.main() through its real CLI. main() has no
645
- argv parameter of its own (unlike this skill's other scripts) -- it
646
- always reads sys.argv, so the only faithful way to exercise the actual
647
- argument-parsing and sampling pipeline is to patch sys.argv, exactly as
648
- a real invocation would populate it, and let main() run for real."""
649
- argv = [
650
- "collect_sessions.py", "--out", str(out_dir), "--claude-home", str(claude_home),
651
- "--all-conversations", "--harness", "claude", "--days", "3650",
652
- ]
653
- argv.extend(extra_args or [])
654
- for key, value in kwargs.items():
655
- flag = "--" + key.replace("_", "-")
656
- argv.extend([flag, str(value)])
657
- with mock.patch.object(sys, "argv", argv):
658
- cs.main()
659
- return json.loads((Path(out_dir) / "inventory.json").read_text())
660
-
661
-
662
- class SamplingTests(unittest.TestCase):
663
- """--sample {newest,skill-stratified} selects how sessions are sampled."""
664
-
665
- def test_newest_sample_selects_n_distinct_sessions_despite_resumed_duplicate(self):
666
- # The trap the newest-sample comment in collect_sessions.py warns
667
- # about: the same session id can appear via more than one file (a
668
- # resumed session). The sampled set is keyed by `_key`
669
- # (harness:id), so distinctness must be measured by that key set's
670
- # size, not by counting list entries or slicing the first N -- a
671
- # naive `sessions[:max_sessions]` here would count the resumed
672
- # session's two file-entries as two sessions and under-sample by one
673
- # distinct session.
674
- with tempfile.TemporaryDirectory() as tmp:
675
- claude_home = Path(tmp) / "claude"
676
- proj = claude_home / "projects" / "p1"
677
- resumed_a = proj / "resumed-a.jsonl"
678
- resumed_b = proj / "resumed-b.jsonl"
679
- session_b = proj / "session-b.jsonl"
680
- session_c = proj / "session-c.jsonl"
681
- write_claude_session_file(resumed_a, "resumed-session")
682
- write_claude_session_file(resumed_b, "resumed-session") # same id, second file
683
- write_claude_session_file(session_b, "session-b")
684
- write_claude_session_file(session_c, "session-c")
685
- set_mtime(resumed_a, 10) # newest
686
- set_mtime(resumed_b, 20) # 2nd newest, SAME session id as resumed_a
687
- set_mtime(session_b, 30) # 3rd newest, distinct
688
- set_mtime(session_c, 40) # oldest, distinct -- must be excluded at max_sessions=2
689
-
690
- inventory = run_collect(claude_home, Path(tmp) / "out",
691
- sample="newest", max_sessions=2)
692
-
693
- self.assertEqual(inventory["stats"]["sessions_sampled"], 2)
694
- sampled_ids = {s["meta"]["id"] for s in inventory["sessions"] if s["sampled"]}
695
- self.assertEqual(len(sampled_ids), 2)
696
- self.assertEqual(sampled_ids, {"resumed-session", "session-b"})
697
- self.assertNotIn("session-c", sampled_ids)
698
-
699
- def test_newest_sample_ignores_skill_usage(self):
700
- # The skill-stratified mode draws skill-using sessions first,
701
- # regardless of recency. --sample newest must NOT do that -- the
702
- # newest sessions win even when an older session is the only one
703
- # that used the sole installed skill.
704
- with tempfile.TemporaryDirectory() as tmp:
705
- claude_home = Path(tmp) / "claude"
706
- proj = claude_home / "projects" / "p1"
707
- skills_dir = Path(tmp) / "skills-fixture"
708
- write_skill_fixture(skills_dir, "alpha")
709
-
710
- newest1 = proj / "newest1.jsonl"
711
- newest2 = proj / "newest2.jsonl"
712
- older_with_skill = proj / "older-skill.jsonl"
713
- oldest = proj / "oldest.jsonl"
714
- write_claude_session_file(newest1, "newest-1")
715
- write_claude_session_file(newest2, "newest-2")
716
- write_claude_session_file(older_with_skill, "older-skill-session", skill="alpha")
717
- write_claude_session_file(oldest, "oldest-session")
718
- set_mtime(newest1, 10)
719
- set_mtime(newest2, 20)
720
- set_mtime(older_with_skill, 30)
721
- set_mtime(oldest, 40)
722
-
723
- inventory = run_collect(claude_home, Path(tmp) / "out",
724
- extra_args=["--skills-dir", str(skills_dir)],
725
- sample="newest", max_sessions=2)
726
-
727
- sampled_ids = {s["meta"]["id"] for s in inventory["sessions"] if s["sampled"]}
728
- self.assertEqual(sampled_ids, {"newest-1", "newest-2"})
729
- self.assertNotIn("older-skill-session", sampled_ids)
730
-
731
- def test_skill_stratified_applies_per_skill_and_no_skill_caps(self):
732
- # Pins the skill-stratified branch: skill-using sessions are drawn first (capped
733
- # by --per-skill), then no-skill sessions fill the rest (capped by
734
- # --no-skill), both newest-first within their own pool.
735
- with tempfile.TemporaryDirectory() as tmp:
736
- claude_home = Path(tmp) / "claude"
737
- proj = claude_home / "projects" / "p1"
738
- skills_dir = Path(tmp) / "skills-fixture"
739
- write_skill_fixture(skills_dir, "alpha")
740
-
741
- a1, a2 = proj / "a1.jsonl", proj / "a2.jsonl"
742
- n1, n2, n3 = proj / "n1.jsonl", proj / "n2.jsonl", proj / "n3.jsonl"
743
- write_claude_session_file(a1, "alpha-1", skill="alpha")
744
- write_claude_session_file(a2, "alpha-2", skill="alpha")
745
- write_claude_session_file(n1, "no-skill-1")
746
- write_claude_session_file(n2, "no-skill-2")
747
- write_claude_session_file(n3, "no-skill-3")
748
- set_mtime(a1, 10)
749
- set_mtime(a2, 20)
750
- set_mtime(n1, 30)
751
- set_mtime(n2, 40)
752
- set_mtime(n3, 50)
753
-
754
- inventory = run_collect(claude_home, Path(tmp) / "out",
755
- extra_args=["--skills-dir", str(skills_dir)],
756
- sample="skill-stratified", max_sessions=10,
757
- per_skill=1, no_skill=2)
758
-
759
- sampled_ids = {s["meta"]["id"] for s in inventory["sessions"] if s["sampled"]}
760
- # per_skill=1 caps "alpha" at its newest instance only;
761
- # no_skill=2 caps the no-skill pool at its two newest.
762
- self.assertEqual(sampled_ids, {"alpha-1", "no-skill-1", "no-skill-2"})
763
- self.assertNotIn("alpha-2", sampled_ids)
764
- self.assertNotIn("no-skill-3", sampled_ids)
765
-
766
-
767
- class HarnessValidationTests(unittest.TestCase):
768
- def test_unknown_harness_is_rejected_by_argparse_cleanly(self):
769
- captured_stderr = io.StringIO()
770
- with mock.patch.object(sys, "argv", ["collect_sessions.py", "--harness", "bogus"]):
771
- with contextlib.redirect_stderr(captured_stderr):
772
- with self.assertRaises(SystemExit) as ctx:
773
- cs.parse_args()
774
- self.assertNotEqual(ctx.exception.code, 0)
775
- self.assertNotIn("Traceback (most recent call last)", captured_stderr.getvalue())
776
- self.assertIn("bogus", captured_stderr.getvalue())
777
-
778
-
779
- if __name__ == "__main__":
780
- unittest.main()