@matt82198/aesop 0.1.0-beta.4 → 0.1.0-beta.5

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 (174) hide show
  1. package/CHANGELOG.md +46 -5
  2. package/README.md +57 -1
  3. package/aesop.config.example.json +8 -1
  4. package/bin/CLAUDE.md +30 -2
  5. package/bin/cli.js +286 -73
  6. package/daemons/CLAUDE.md +4 -1
  7. package/daemons/run-watchdog.sh +5 -2
  8. package/docs/RELEASING.md +159 -0
  9. package/docs/archive/README.md +3 -0
  10. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  11. package/docs/case-study-portfolio.md +61 -0
  12. package/docs/self-stats-data.json +11 -0
  13. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  14. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  15. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  16. package/hooks/CLAUDE.md +23 -0
  17. package/mcp/CLAUDE.md +213 -0
  18. package/mcp/package.json +26 -0
  19. package/mcp/server.mjs +543 -0
  20. package/monitor/CHARTER.md +76 -110
  21. package/monitor/collect-signals.mjs +85 -0
  22. package/package.json +4 -1
  23. package/scan/fleet-scan.example.mjs +292 -0
  24. package/skills/healthcheck/SKILL.md +44 -0
  25. package/state_store/CLAUDE.md +39 -0
  26. package/state_store/__init__.py +24 -0
  27. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  29. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  30. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  31. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  32. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  33. package/state_store/api.py +35 -0
  34. package/state_store/export.py +19 -0
  35. package/state_store/ingest.py +24 -0
  36. package/state_store/projections.py +52 -0
  37. package/state_store/store.py +102 -0
  38. package/tools/CLAUDE.md +30 -149
  39. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  40. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  41. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  42. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  43. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  44. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  45. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  46. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  52. package/tools/alert_bridge.py +449 -0
  53. package/tools/ci_merge_wait.py +121 -8
  54. package/tools/fleet_prompt_extractor.py +134 -0
  55. package/tools/healthcheck.py +296 -0
  56. package/tools/secret_scan.py +8 -1
  57. package/tools/self_stats.py +509 -0
  58. package/tools/session_usage_summary.py +198 -0
  59. package/tools/svg_to_png.mjs +50 -0
  60. package/tools/transcript_replay.py +236 -0
  61. package/tools/transcript_timeline.py +184 -0
  62. package/tools/verify_dash.py +361 -542
  63. package/tools/verify_submit_encoding.py +12 -4
  64. package/ui/CLAUDE.md +57 -41
  65. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  66. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  67. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  68. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  69. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  70. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  71. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  72. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  73. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  74. package/ui/agents.py +9 -5
  75. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  76. package/ui/api/submit.py +44 -5
  77. package/ui/collectors.py +184 -35
  78. package/ui/config.py +9 -0
  79. package/ui/cost.py +260 -0
  80. package/ui/csrf.py +7 -3
  81. package/ui/handler.py +254 -4
  82. package/ui/render.py +26 -6
  83. package/ui/sse.py +16 -1
  84. package/ui/web/.gitattributes +13 -0
  85. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  86. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  87. package/ui/web/dist/index.html +14 -0
  88. package/ui/web/index.html +13 -0
  89. package/ui/web/package-lock.json +2225 -0
  90. package/ui/web/package.json +26 -0
  91. package/ui/web/src/App.test.tsx +74 -0
  92. package/ui/web/src/App.tsx +142 -0
  93. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  94. package/ui/web/src/components/AgentRow.css +187 -0
  95. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  96. package/ui/web/src/components/AgentRow.tsx +207 -0
  97. package/ui/web/src/components/AgentsPanel.css +108 -0
  98. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  99. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  100. package/ui/web/src/components/AlertsPanel.css +88 -0
  101. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  102. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  103. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  104. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  105. package/ui/web/src/components/CostChart.css +110 -0
  106. package/ui/web/src/components/CostChart.test.tsx +144 -0
  107. package/ui/web/src/components/CostChart.tsx +152 -0
  108. package/ui/web/src/components/CostTable.css +93 -0
  109. package/ui/web/src/components/CostTable.test.tsx +165 -0
  110. package/ui/web/src/components/CostTable.tsx +94 -0
  111. package/ui/web/src/components/EventsFeed.css +68 -0
  112. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  113. package/ui/web/src/components/EventsFeed.tsx +31 -0
  114. package/ui/web/src/components/HealthHeader.css +137 -0
  115. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  116. package/ui/web/src/components/HealthHeader.tsx +281 -0
  117. package/ui/web/src/components/InboxForm.css +135 -0
  118. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  119. package/ui/web/src/components/InboxForm.tsx +116 -0
  120. package/ui/web/src/components/MessagesTail.module.css +144 -0
  121. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  122. package/ui/web/src/components/MessagesTail.tsx +94 -0
  123. package/ui/web/src/components/ReposPanel.css +90 -0
  124. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  125. package/ui/web/src/components/ReposPanel.tsx +67 -0
  126. package/ui/web/src/components/Scorecard.css +106 -0
  127. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  128. package/ui/web/src/components/Scorecard.tsx +85 -0
  129. package/ui/web/src/components/Timeline.module.css +151 -0
  130. package/ui/web/src/components/Timeline.test.tsx +215 -0
  131. package/ui/web/src/components/Timeline.tsx +99 -0
  132. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  133. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  134. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  135. package/ui/web/src/components/TrackerCard.tsx +160 -0
  136. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  137. package/ui/web/src/components/TrackerForm.tsx +144 -0
  138. package/ui/web/src/lib/api.ts +218 -0
  139. package/ui/web/src/lib/format.test.ts +89 -0
  140. package/ui/web/src/lib/format.ts +103 -0
  141. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  142. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  143. package/ui/web/src/lib/types.ts +230 -0
  144. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  145. package/ui/web/src/lib/useHashRoute.ts +23 -0
  146. package/ui/web/src/lib/useSSE.ts +175 -0
  147. package/ui/web/src/main.tsx +10 -0
  148. package/ui/web/src/styles/global.css +179 -0
  149. package/ui/web/src/styles/theme.css +184 -0
  150. package/ui/web/src/styles/work.css +572 -0
  151. package/ui/web/src/test/fixtures.ts +385 -0
  152. package/ui/web/src/test/setup.ts +49 -0
  153. package/ui/web/src/views/Activity.module.css +43 -0
  154. package/ui/web/src/views/Activity.test.tsx +89 -0
  155. package/ui/web/src/views/Activity.tsx +31 -0
  156. package/ui/web/src/views/Cost.css +87 -0
  157. package/ui/web/src/views/Cost.test.tsx +142 -0
  158. package/ui/web/src/views/Cost.tsx +54 -0
  159. package/ui/web/src/views/Overview.css +51 -0
  160. package/ui/web/src/views/Overview.test.tsx +76 -0
  161. package/ui/web/src/views/Overview.tsx +46 -0
  162. package/ui/web/src/views/Work.test.tsx +82 -0
  163. package/ui/web/src/views/Work.tsx +79 -0
  164. package/ui/web/src/vite-env.d.ts +10 -0
  165. package/ui/web/tsconfig.json +22 -0
  166. package/ui/web/vite.config.ts +25 -0
  167. package/ui/web/vitest.config.ts +12 -0
  168. package/ui/templates/dashboard.html +0 -1202
  169. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  170. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  171. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  172. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  173. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  174. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * svg_to_png.mjs — Rasterize an SVG file to PNG using @resvg/resvg-js.
4
+ *
5
+ * Why this exists: on systems without rsvg-convert, ImageMagick, or Inkscape,
6
+ * @resvg/resvg-js ships a prebuilt native binary via npm with zero extra system
7
+ * deps, making it the reliable local converter for cross-platform automation.
8
+ *
9
+ * Usage:
10
+ * node svg_to_png.mjs <input.svg> <output.png> [width]
11
+ *
12
+ * If [width] is given, the SVG is rasterized at that pixel width (height keeps
13
+ * aspect ratio unless the SVG sets an explicit height). Requires
14
+ * @resvg/resvg-js to be installed (locally in the calling project, or
15
+ * globally) — install with: npm install @resvg/resvg-js
16
+ */
17
+ import { readFileSync, writeFileSync } from "node:fs";
18
+
19
+ let Resvg;
20
+ try {
21
+ const resvgModule = await import("@resvg/resvg-js");
22
+ Resvg = resvgModule.Resvg;
23
+ } catch (err) {
24
+ console.error(
25
+ "Error: @resvg/resvg-js is not installed.\n" +
26
+ "Install it with: npm install @resvg/resvg-js\n" +
27
+ "Or globally with: npm install -g @resvg/resvg-js"
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ const [, , inputPath, outputPath, widthArg] = process.argv;
33
+
34
+ if (!inputPath || !outputPath) {
35
+ console.error("Usage: node svg_to_png.mjs <input.svg> <output.png> [width]");
36
+ process.exit(1);
37
+ }
38
+
39
+ const svg = readFileSync(inputPath, "utf8");
40
+ const opts = {};
41
+ if (widthArg) {
42
+ opts.fitTo = { mode: "width", value: parseInt(widthArg, 10) };
43
+ }
44
+
45
+ const resvg = new Resvg(svg, opts);
46
+ const pngData = resvg.render();
47
+ const buffer = pngData.asPng();
48
+ writeFileSync(outputPath, buffer);
49
+
50
+ console.log(`${outputPath}: ${pngData.width}x${pngData.height}`);
@@ -0,0 +1,236 @@
1
+ """Replay post-commit edits/writes from Claude Code transcripts to recover uncommitted work."""
2
+ import argparse
3
+ import json
4
+ import os
5
+ import pathlib
6
+ import sys
7
+ from datetime import datetime
8
+
9
+
10
+ def apply_edit(text, old_string, new_string, replace_all=False):
11
+ """Apply a single edit to text. Returns updated text or None/undefined."""
12
+ if not isinstance(old_string, str) or not old_string or old_string not in text:
13
+ return None
14
+ if replace_all:
15
+ return text.split(old_string)[0] + new_string + text.split(old_string)[1]
16
+ idx = text.find(old_string)
17
+ return text[:idx] + new_string + text[idx + len(old_string) :]
18
+
19
+
20
+ def count_lines(text):
21
+ """Count lines in text."""
22
+ return len(text.split("\n"))
23
+
24
+
25
+ def fmt_time(ts_ms):
26
+ """Format timestamp in milliseconds to ISO format with Z suffix."""
27
+ if not ts_ms:
28
+ return "?"
29
+ return datetime.fromtimestamp(ts_ms / 1000).isoformat().replace("T", " ")[:19] + "Z"
30
+
31
+
32
+ def walk_jsonl(directory):
33
+ """Recursively find all .jsonl files in a directory."""
34
+ result = []
35
+ for root, dirs, files in os.walk(directory):
36
+ for f in files:
37
+ if f.endswith(".jsonl"):
38
+ result.append(os.path.join(root, f))
39
+ return result
40
+
41
+
42
+ def main():
43
+ parser = argparse.ArgumentParser(
44
+ description="Replay post-commit edits/writes from Claude Code transcripts to recover uncommitted work."
45
+ )
46
+ parser.add_argument(
47
+ "roots", nargs="+", help="Root directories containing .jsonl transcripts to scan"
48
+ )
49
+ parser.add_argument(
50
+ "-p", "--project", required=True, help="Project name/path substring to match"
51
+ )
52
+ parser.add_argument(
53
+ "-r", "--repo-dir", required=True, help="Root directory of the live repository"
54
+ )
55
+ parser.add_argument(
56
+ "-c", "--commit-time", required=True, help="Timestamp of last commit (ISO format, e.g., '2026-06-23T17:22:04Z')"
57
+ )
58
+ parser.add_argument(
59
+ "-o", "--output", required=True, help="Output directory for replayed files"
60
+ )
61
+
62
+ args = parser.parse_args()
63
+
64
+ # Parse commit time
65
+ commit_ms = int(datetime.fromisoformat(args.commit_time.replace("Z", "+00:00")).timestamp() * 1000)
66
+
67
+ output_dir = pathlib.Path(args.output)
68
+ if output_dir.exists():
69
+ import shutil
70
+ shutil.rmtree(output_dir)
71
+ output_dir.mkdir(parents=True, exist_ok=True)
72
+
73
+ # Gather .jsonl files
74
+ files = []
75
+ for root in args.roots:
76
+ if os.path.exists(root):
77
+ files.extend(walk_jsonl(root))
78
+
79
+ # Collect Write/Edit/MultiEdit operations
80
+ ops = [] # {rel, ts, tool, input, src}
81
+ for fp in files:
82
+ try:
83
+ with open(fp, "r", encoding="utf-8") as f:
84
+ lines = f.read().split("\n")
85
+ except Exception:
86
+ continue
87
+
88
+ src = os.path.basename(fp)
89
+ for line in lines:
90
+ if not line.strip():
91
+ continue
92
+ try:
93
+ obj = json.loads(line)
94
+ except json.JSONDecodeError:
95
+ continue
96
+
97
+ ts = 0
98
+ if obj.get("timestamp"):
99
+ ts = int(datetime.fromisoformat(obj["timestamp"]).timestamp() * 1000)
100
+
101
+ content = obj.get("message", {}).get("content")
102
+ if not isinstance(content, list):
103
+ continue
104
+
105
+ for c in content:
106
+ if c.get("type") != "tool_use":
107
+ continue
108
+ if c.get("name") not in ["Write", "Edit", "MultiEdit"]:
109
+ continue
110
+
111
+ inp = c.get("input") or {}
112
+ file_path = inp.get("file_path") or ""
113
+ file_path_normalized = file_path.replace("\\", "/")
114
+ if f"/{args.project}/" not in file_path_normalized:
115
+ continue
116
+
117
+ rel = file_path_normalized.split(f"/{args.project}/")[1]
118
+ ops.append(
119
+ {"rel": rel, "ts": ts, "tool": c["name"], "input": inp, "src": src}
120
+ )
121
+
122
+ ops.sort(key=lambda x: x["ts"])
123
+
124
+ # Filter post-commit ops
125
+ post = [o for o in ops if o["ts"] > commit_ms]
126
+
127
+ # Group by file
128
+ by_file = {}
129
+ for o in post:
130
+ if o["rel"] not in by_file:
131
+ by_file[o["rel"]] = []
132
+ by_file[o["rel"]].append(o)
133
+
134
+ report = [
135
+ f"# POST-COMMIT REPLAY — {len(post)} uncommitted ops across {len(by_file)} files "
136
+ f"(after {fmt_time(commit_ms)})\n"
137
+ ]
138
+ recovered = []
139
+
140
+ for rel in sorted(by_file.keys()):
141
+ ops_list = by_file[rel]
142
+ live_path = os.path.join(args.repo_dir, rel)
143
+ live_ok = os.path.isfile(live_path)
144
+ text = ""
145
+ if live_ok:
146
+ try:
147
+ with open(live_path, "r", encoding="utf-8") as f:
148
+ text = f.read()
149
+ except Exception:
150
+ live_ok = False
151
+
152
+ disk_len = len(text)
153
+ applied = 0
154
+ missed = 0
155
+ miss_details = []
156
+
157
+ for o in ops_list:
158
+ if o["tool"] == "Write":
159
+ text = o["input"].get("content") or text
160
+ applied += 1
161
+ elif o["tool"] == "Edit":
162
+ r = apply_edit(
163
+ text,
164
+ o["input"].get("old_string"),
165
+ o["input"].get("new_string"),
166
+ o["input"].get("replace_all"),
167
+ )
168
+ if isinstance(r, str):
169
+ text = r
170
+ applied += 1
171
+ else:
172
+ missed += 1
173
+ miss_details.append(
174
+ f"{fmt_time(o['ts'])} {(o['input'].get('old_string') or '')[:50].replace(chr(10), '\\n')}"
175
+ )
176
+ elif o["tool"] == "MultiEdit":
177
+ for e in o["input"].get("edits") or []:
178
+ r = apply_edit(
179
+ text,
180
+ e.get("old_string"),
181
+ e.get("new_string"),
182
+ e.get("replace_all"),
183
+ )
184
+ if isinstance(r, str):
185
+ text = r
186
+ applied += 1
187
+ else:
188
+ missed += 1
189
+ miss_details.append(
190
+ f"{fmt_time(o['ts'])} [multi] {(e.get('old_string') or '')[:50].replace(chr(10), '\\n')}"
191
+ )
192
+
193
+ changed = live_ok and text != (
194
+ open(live_path, "r", encoding="utf-8").read() if live_ok else ""
195
+ )
196
+ out_path = output_dir / rel
197
+ out_path.parent.mkdir(parents=True, exist_ok=True)
198
+ with open(out_path, "w", encoding="utf-8") as f:
199
+ f.write(text)
200
+
201
+ report.append(f"\n## {rel}")
202
+ report.append(
203
+ f" post-commit ops={len(ops_list)} "
204
+ f"(Write={len([x for x in ops_list if x['tool'] == 'Write'])} "
205
+ f"Edit={len([x for x in ops_list if x['tool'] != 'Write'])}) "
206
+ f"span {fmt_time(ops_list[0]['ts'])} → {fmt_time(ops_list[-1]['ts'])}"
207
+ )
208
+ report.append(
209
+ f" base(disk)={disk_len}b/{count_lines(open(live_path, 'r', encoding='utf-8').read() if live_ok else '')}L "
210
+ f"-> replayed={len(text)}b/{count_lines(text)}L "
211
+ f"| applied={applied} missed={missed} "
212
+ f"| {'*** DIFFERS FROM DISK ***' if changed else 'same as disk'}"
213
+ )
214
+ if miss_details:
215
+ report.append(" MISSED edits (old_string not found in base):")
216
+ for detail in miss_details:
217
+ report.append(f" - {detail}")
218
+ report.append("")
219
+
220
+ if changed:
221
+ recovered.append(rel)
222
+
223
+ report.append(f"\n# FILES WITH RECOVERABLE UNCOMMITTED WORK: {len(recovered)}")
224
+ for r in recovered:
225
+ report.append(f" {r}")
226
+
227
+ report_path = output_dir / "_REPLAY_REPORT.txt"
228
+ with open(report_path, "w", encoding="utf-8") as f:
229
+ f.write("\n".join(report))
230
+
231
+ print("\n".join(report))
232
+ print(f"\noutput dir: {output_dir}")
233
+
234
+
235
+ if __name__ == "__main__":
236
+ main()
@@ -0,0 +1,184 @@
1
+ """Extract timeline of file changes from Claude Code transcripts (Write/Edit/Read operations)."""
2
+ import argparse
3
+ import json
4
+ import os
5
+ import pathlib
6
+ from datetime import datetime
7
+
8
+
9
+ def fmt_time(ts_ms):
10
+ """Format timestamp in milliseconds to ISO format with Z suffix."""
11
+ if not ts_ms:
12
+ return "?"
13
+ return datetime.fromtimestamp(ts_ms / 1000).isoformat().replace("T", " ")[:19] + "Z"
14
+
15
+
16
+ def strip_line_nums(text):
17
+ """Remove line number prefixes (e.g., '123\\t') from tool output."""
18
+ lines = text.split("\n")
19
+ result = []
20
+ for line in lines:
21
+ # Strip pattern: leading spaces + digits + tab
22
+ stripped = line
23
+ if "\t" in line:
24
+ parts = line.split("\t", 1)
25
+ if parts[0].strip().isdigit():
26
+ stripped = parts[1]
27
+ result.append(stripped)
28
+ return "\n".join(result)
29
+
30
+
31
+ def result_text(content):
32
+ """Extract text from tool result content."""
33
+ if isinstance(content, str):
34
+ return content
35
+ if isinstance(content, list):
36
+ parts = []
37
+ for x in content:
38
+ if isinstance(x, str):
39
+ parts.append(x)
40
+ elif isinstance(x, dict):
41
+ parts.append(x.get("text", ""))
42
+ return "".join(parts)
43
+ if isinstance(content, dict):
44
+ return content.get("text", "")
45
+ return ""
46
+
47
+
48
+ def walk_jsonl(directory):
49
+ """Recursively find all .jsonl files in a directory."""
50
+ result = []
51
+ for root, dirs, files in os.walk(directory):
52
+ for f in files:
53
+ if f.endswith(".jsonl"):
54
+ result.append(os.path.join(root, f))
55
+ return result
56
+
57
+
58
+ def main():
59
+ parser = argparse.ArgumentParser(
60
+ description="Extract timeline of file changes (Write/Edit/Read) from Claude Code transcripts."
61
+ )
62
+ parser.add_argument(
63
+ "roots", nargs="+", help="Root directories containing .jsonl transcripts to scan"
64
+ )
65
+ parser.add_argument(
66
+ "-p", "--project", required=True, help="Project name/path substring to match"
67
+ )
68
+ parser.add_argument(
69
+ "-t", "--targets", nargs="+", required=True, help="Target file paths/substrings to inspect"
70
+ )
71
+
72
+ args = parser.parse_args()
73
+
74
+ # Gather .jsonl files
75
+ files = []
76
+ for root in args.roots:
77
+ if os.path.exists(root):
78
+ files.extend(walk_jsonl(root))
79
+
80
+ # Index operations by ID
81
+ use_by_id = {}
82
+ snaps = [] # {rel, ts, size, kind, src, content}
83
+
84
+ for fp in files:
85
+ try:
86
+ with open(fp, "r", encoding="utf-8") as f:
87
+ lines = f.read().split("\n")
88
+ except Exception:
89
+ continue
90
+
91
+ base = os.path.basename(fp)
92
+ for line in lines:
93
+ if not line.strip():
94
+ continue
95
+ try:
96
+ obj = json.loads(line)
97
+ except json.JSONDecodeError:
98
+ continue
99
+
100
+ ts = 0
101
+ if obj.get("timestamp"):
102
+ ts = int(datetime.fromisoformat(obj["timestamp"]).timestamp() * 1000)
103
+
104
+ content = obj.get("message", {}).get("content")
105
+ if not isinstance(content, list):
106
+ continue
107
+
108
+ for c in content:
109
+ if c.get("type") == "tool_use":
110
+ file_path = c.get("input", {}).get("file_path")
111
+ if file_path:
112
+ file_path_normalized = file_path.replace("\\", "/")
113
+ if f"/{args.project}/" in file_path_normalized:
114
+ rel = file_path_normalized.split(f"/{args.project}/")[1]
115
+ else:
116
+ rel = None
117
+ else:
118
+ rel = None
119
+
120
+ if c.get("name") in ["Write", "Edit", "MultiEdit", "Read"]:
121
+ use_by_id[c.get("id")] = {"tool": c.get("name"), "rel": rel, "ts": ts}
122
+
123
+ if (
124
+ rel
125
+ and c.get("name") == "Write"
126
+ and isinstance(c.get("input", {}).get("content"), str)
127
+ ):
128
+ snaps.append(
129
+ {
130
+ "rel": rel,
131
+ "ts": ts,
132
+ "size": len(c["input"]["content"]),
133
+ "kind": "Write",
134
+ "src": base,
135
+ "content": c["input"]["content"],
136
+ }
137
+ )
138
+
139
+ elif c.get("type") == "tool_result":
140
+ u = use_by_id.get(c.get("tool_use_id"))
141
+ if u and u.get("tool") == "Read" and u.get("rel"):
142
+ txt = strip_line_nums(result_text(c.get("content")))
143
+ if txt and len(txt) > 30:
144
+ snaps.append(
145
+ {
146
+ "rel": u["rel"],
147
+ "ts": u.get("ts") or ts,
148
+ "size": len(txt),
149
+ "kind": "Read",
150
+ "src": base,
151
+ "content": txt,
152
+ }
153
+ )
154
+
155
+ # Report on targets
156
+ for target in args.targets:
157
+ target_snaps = [s for s in snaps if s["rel"] and target in s["rel"]]
158
+ target_snaps.sort(key=lambda x: x["ts"])
159
+
160
+ print(f"\n===== {target} ===== ({len(target_snaps)} snapshots)")
161
+ for s in target_snaps:
162
+ print(
163
+ f" {fmt_time(s['ts'])} {str(s['size']).rjust(6)}b "
164
+ f"{s['kind']:<5} {s['src']}"
165
+ )
166
+
167
+ if target_snaps:
168
+ latest = target_snaps[-1]
169
+ largest = sorted(target_snaps, key=lambda x: (-x["size"], -x["ts"]))[0]
170
+ exact = target.split("/")[-1]
171
+
172
+ # Write snapshots
173
+ for snap, tag in [(latest, "LATEST"), (largest, "LARGEST")]:
174
+ if snap is latest and tag == "LARGEST" and snap is latest:
175
+ continue # Skip duplicate
176
+ safe_name = f"{exact}.{tag}".replace(os.sep, "_")
177
+ for c in r'<>:"|?*':
178
+ safe_name = safe_name.replace(c, "_")
179
+ # Write to current dir for simplicity (no output arg in this version)
180
+ print(f" -> {tag}: {snap['size']}b @ {fmt_time(snap['ts'])} ({snap['src']})")
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()