@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,509 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Self-building stats counter for aesop README.
4
+
5
+ Computes git-derived metrics (verifiable by anyone who clones) and reads session telemetry
6
+ from docs/self-stats-data.json. All hard metrics in output carry verification markers.
7
+
8
+ Usage:
9
+ python self_stats.py [--repo PATH] [--data-file PATH] [--markdown|--json]
10
+
11
+ Output modes:
12
+ default - Human-readable table
13
+ --markdown - README block with <!-- SELF-STATS:START/END --> markers (markdown verification comments)
14
+ --json - Machine-readable JSON object
15
+
16
+ All hard metrics (percentages, multipliers, dollar amounts) in markdown output include
17
+ <!-- metrics-verified: <source> --> markers for the metrics_gate.py CI gate.
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Optional, Dict, Any
28
+
29
+
30
+ class GitStats:
31
+ """Compute statistics from git repository."""
32
+
33
+ def __init__(self, repo_root: str = "."):
34
+ """Initialize with repo root path."""
35
+ self.repo_root = Path(repo_root)
36
+ self._merged_prs = None
37
+ self._total_commits = None
38
+ self._project_age_days = None
39
+ self._wave_count = None
40
+ self._insertions_deletions = None
41
+ self._files_tracked = None
42
+ self._distinct_coauthors = None
43
+
44
+ def _run_git(self, *args, check=True) -> str:
45
+ """Run git command in repo, return stdout."""
46
+ try:
47
+ result = subprocess.run(
48
+ ["git"] + list(args),
49
+ cwd=str(self.repo_root),
50
+ capture_output=True,
51
+ text=True,
52
+ encoding="utf-8",
53
+ errors="replace",
54
+ check=check,
55
+ )
56
+ return (result.stdout or "").strip()
57
+ except FileNotFoundError:
58
+ return ""
59
+
60
+ @property
61
+ def merged_prs(self) -> int:
62
+ """Count merge commits with 'Merge pull request #' in message."""
63
+ if self._merged_prs is not None:
64
+ return self._merged_prs
65
+
66
+ try:
67
+ # Get all commit messages
68
+ output = self._run_git("log", "--format=%B", check=False)
69
+ if not output:
70
+ self._merged_prs = 0
71
+ return 0
72
+
73
+ # Count lines matching "Merge pull request #"
74
+ count = output.count("Merge pull request #")
75
+ self._merged_prs = count
76
+ return count
77
+ except Exception:
78
+ self._merged_prs = 0
79
+ return 0
80
+
81
+ @property
82
+ def total_commits(self) -> int:
83
+ """Total commit count."""
84
+ if self._total_commits is not None:
85
+ return self._total_commits
86
+
87
+ try:
88
+ output = self._run_git("rev-list", "--count", "HEAD", check=False)
89
+ count = int(output) if output else 0
90
+ self._total_commits = count
91
+ return count
92
+ except (ValueError, Exception):
93
+ self._total_commits = 0
94
+ return 0
95
+
96
+ @property
97
+ def project_age_days(self) -> Optional[int]:
98
+ """Project age in days (first commit to now)."""
99
+ if self._project_age_days is not None:
100
+ return self._project_age_days
101
+
102
+ try:
103
+ # Get timestamp of the earliest commit (root) — --reverse lists
104
+ # oldest first, so the first line is the project's birth.
105
+ output = self._run_git(
106
+ "log", "--reverse", "--format=%cI", check=False
107
+ )
108
+ if not output:
109
+ self._project_age_days = None
110
+ return None
111
+
112
+ first_commit_iso = output.split("\n", 1)[0].strip()
113
+ if not first_commit_iso:
114
+ self._project_age_days = None
115
+ return None
116
+
117
+ # Parse ISO format timestamp
118
+ first_commit_dt = datetime.fromisoformat(first_commit_iso.replace("Z", "+00:00"))
119
+ now_dt = datetime.now(timezone.utc)
120
+ age_days = (now_dt - first_commit_dt).days
121
+
122
+ self._project_age_days = age_days
123
+ return age_days
124
+ except Exception:
125
+ self._project_age_days = None
126
+ return None
127
+
128
+ @property
129
+ def wave_count(self) -> int:
130
+ """Count of distinct waves (parse wave labels or release tags)."""
131
+ if self._wave_count is not None:
132
+ return self._wave_count
133
+
134
+ try:
135
+ # First try parsing wave labels from merge commit messages
136
+ output = self._run_git("log", "--format=%B", check=False)
137
+ if output:
138
+ # Count lines like "wave-N" (case insensitive)
139
+ import re
140
+ waves = set()
141
+ for match in re.finditer(r"wave[_-]?(\d+)", output, re.IGNORECASE):
142
+ waves.add(int(match.group(1)))
143
+ if waves:
144
+ self._wave_count = len(waves)
145
+ return len(waves)
146
+
147
+ # Fallback: count release tags (v*)
148
+ tags = self._run_git("tag", "-l", "v*", check=False)
149
+ tag_count = len([t for t in tags.split("\n") if t.strip()])
150
+ self._wave_count = tag_count
151
+ return tag_count
152
+ except Exception:
153
+ self._wave_count = 0
154
+ return 0
155
+
156
+ @property
157
+ def insertions_deletions(self) -> int:
158
+ """Total insertions + deletions across all commits."""
159
+ if self._insertions_deletions is not None:
160
+ return self._insertions_deletions
161
+
162
+ try:
163
+ output = self._run_git(
164
+ "log", "--numstat", "--format=%H", check=False
165
+ )
166
+ if not output:
167
+ self._insertions_deletions = 0
168
+ return 0
169
+
170
+ total = 0
171
+ for line in output.split("\n"):
172
+ parts = line.split("\t")
173
+ if len(parts) >= 2:
174
+ try:
175
+ # Skip lines that are commit hashes or other non-numstat data
176
+ insertions = int(parts[0])
177
+ deletions = int(parts[1])
178
+ total += insertions + deletions
179
+ except ValueError:
180
+ continue
181
+
182
+ self._insertions_deletions = total
183
+ return total
184
+ except Exception:
185
+ self._insertions_deletions = 0
186
+ return 0
187
+
188
+ @property
189
+ def files_tracked(self) -> int:
190
+ """Count of tracked files."""
191
+ if self._files_tracked is not None:
192
+ return self._files_tracked
193
+
194
+ try:
195
+ output = self._run_git("ls-files", check=False)
196
+ count = len([f for f in output.split("\n") if f.strip()])
197
+ self._files_tracked = count
198
+ return count
199
+ except Exception:
200
+ self._files_tracked = 0
201
+ return 0
202
+
203
+ @property
204
+ def distinct_coauthors(self) -> int:
205
+ """Count of distinct authors including co-authors."""
206
+ if self._distinct_coauthors is not None:
207
+ return self._distinct_coauthors
208
+
209
+ try:
210
+ # Get all authors
211
+ output = self._run_git("log", "--format=%an", check=False)
212
+ authors = set()
213
+ if output:
214
+ for author in output.split("\n"):
215
+ if author.strip():
216
+ authors.add(author.strip())
217
+
218
+ # Get all co-authors from commit messages
219
+ commit_msg = self._run_git("log", "--format=%B", check=False)
220
+ if commit_msg:
221
+ import re
222
+ for match in re.finditer(r"Co-Authored-By:\s*(.+?)(?:\n|$)", commit_msg):
223
+ coauthor = match.group(1).strip()
224
+ if coauthor:
225
+ authors.add(coauthor)
226
+
227
+ count = len(authors)
228
+ self._distinct_coauthors = count
229
+ return count
230
+ except Exception:
231
+ self._distinct_coauthors = 0
232
+ return 0
233
+
234
+
235
+ class SessionTelemetry:
236
+ """Load session telemetry from JSON file."""
237
+
238
+ def __init__(self, data_file: str = "docs/self-stats-data.json"):
239
+ """Initialize with data file path."""
240
+ self.data_file = Path(data_file)
241
+ self._data = None
242
+ self._load()
243
+
244
+ def _load(self):
245
+ """Load JSON data, silently ignore missing/invalid files."""
246
+ if not self.data_file.exists():
247
+ self._data = {}
248
+ return
249
+
250
+ try:
251
+ with open(self.data_file) as f:
252
+ self._data = json.load(f)
253
+ except (json.JSONDecodeError, IOError):
254
+ self._data = {}
255
+
256
+ def _get(self, key: str) -> Optional[Any]:
257
+ """Get field, return None if missing or null."""
258
+ if not self._data:
259
+ return None
260
+ value = self._data.get(key)
261
+ return value if value is not None else None
262
+
263
+ @property
264
+ def total_sessions(self) -> Optional[int]:
265
+ return self._get("total_sessions")
266
+
267
+ @property
268
+ def total_turns(self) -> Optional[int]:
269
+ return self._get("total_turns")
270
+
271
+ @property
272
+ def total_user_prompts(self) -> Optional[int]:
273
+ return self._get("total_user_prompts")
274
+
275
+ @property
276
+ def max_tokens_single_turn(self) -> Optional[int]:
277
+ return self._get("max_tokens_single_turn")
278
+
279
+ @property
280
+ def cumulative_agent_runs(self) -> Optional[int]:
281
+ return self._get("cumulative_agent_runs")
282
+
283
+ @property
284
+ def cumulative_tokens(self) -> Optional[int]:
285
+ return self._get("cumulative_tokens")
286
+
287
+ @property
288
+ def total_coding_hours(self) -> Optional[float]:
289
+ return self._get("total_coding_hours")
290
+
291
+
292
+ class StatsCounter:
293
+ """Combine git and telemetry stats, format for output."""
294
+
295
+ def __init__(self, repo_root: str = ".", data_file: str = None):
296
+ """Initialize with repo root and optional data file."""
297
+ self.git = GitStats(repo_root=repo_root)
298
+ if data_file is None:
299
+ # Infer from repo root
300
+ data_file = str(Path(repo_root) / "docs" / "self-stats-data.json")
301
+ self.telemetry = SessionTelemetry(data_file=data_file)
302
+
303
+ def table(self) -> str:
304
+ """Human-readable table format."""
305
+ lines = []
306
+ lines.append("")
307
+ lines.append("=" * 50)
308
+ lines.append("Aesop Self-Building Stats")
309
+ lines.append("=" * 50)
310
+ lines.append("")
311
+
312
+ # Git-derived stats
313
+ lines.append("Repository Metrics:")
314
+ if self.git.merged_prs > 0:
315
+ lines.append(f" Merged PRs: {self.git.merged_prs}")
316
+ if self.git.total_commits > 0:
317
+ lines.append(f" Total Commits: {self.git.total_commits}")
318
+ if self.git.project_age_days is not None and self.git.project_age_days >= 0:
319
+ lines.append(f" Project Age (days): {self.git.project_age_days}")
320
+ if self.git.wave_count > 0:
321
+ lines.append(f" Wave Count: {self.git.wave_count}")
322
+ if self.git.insertions_deletions > 0:
323
+ lines.append(f" Insertions+Deletions: {self.git.insertions_deletions}")
324
+ if self.git.files_tracked > 0:
325
+ lines.append(f" Files Tracked: {self.git.files_tracked}")
326
+ if self.git.distinct_coauthors > 0:
327
+ lines.append(f" Distinct Co-authors: {self.git.distinct_coauthors}")
328
+
329
+ # Session telemetry (only if present)
330
+ if any([
331
+ self.telemetry.total_sessions,
332
+ self.telemetry.total_turns,
333
+ self.telemetry.cumulative_tokens,
334
+ ]):
335
+ lines.append("")
336
+ lines.append("Session Telemetry:")
337
+ if self.telemetry.total_sessions is not None:
338
+ lines.append(f" Total Sessions: {self.telemetry.total_sessions}")
339
+ if self.telemetry.total_turns is not None:
340
+ lines.append(f" Total Turns: {self.telemetry.total_turns}")
341
+ if self.telemetry.total_user_prompts is not None:
342
+ lines.append(f" User Prompts: {self.telemetry.total_user_prompts}")
343
+ if self.telemetry.cumulative_agent_runs is not None:
344
+ lines.append(f" Agent Runs: {self.telemetry.cumulative_agent_runs}")
345
+ if self.telemetry.cumulative_tokens is not None:
346
+ lines.append(f" Total Tokens: {self.telemetry.cumulative_tokens}")
347
+ if self.telemetry.max_tokens_single_turn is not None:
348
+ lines.append(f" Max Tokens/Turn: {self.telemetry.max_tokens_single_turn}")
349
+ if self.telemetry.total_coding_hours is not None:
350
+ lines.append(f" Coding Hours: {self.telemetry.total_coding_hours}")
351
+
352
+ lines.append("")
353
+ lines.append("=" * 50)
354
+ lines.append("")
355
+
356
+ return "\n".join(lines)
357
+
358
+ def markdown(self) -> str:
359
+ """Markdown output with verification markers for hard metrics."""
360
+ lines = []
361
+ lines.append("<!-- SELF-STATS:START -->")
362
+ lines.append("")
363
+ lines.append("## Aesop builds itself")
364
+ lines.append("")
365
+ lines.append(
366
+ "Aesop is built entirely by its own `/buildsystem` wave cycle—running parallel Haiku fleets "
367
+ "across ranked backlog items, verifying merges, auditing orchestration health. "
368
+ "These stats are the receipts: all numbers computed LIVE from git, verified by anyone who clones."
369
+ )
370
+ lines.append("")
371
+
372
+ # Build stat rows
373
+ rows = []
374
+
375
+ if self.git.merged_prs > 0:
376
+ rows.append(
377
+ f"| Merged PRs | {self.git.merged_prs} <!-- metrics-verified: self_stats.py (git log) --> |"
378
+ )
379
+ if self.git.total_commits > 0:
380
+ rows.append(
381
+ f"| Total Commits | {self.git.total_commits} <!-- metrics-verified: self_stats.py (git log) --> |"
382
+ )
383
+ if self.git.project_age_days is not None and self.git.project_age_days >= 0:
384
+ rows.append(
385
+ f"| Project Age | {self.git.project_age_days} days <!-- metrics-verified: self_stats.py (git log) --> |"
386
+ )
387
+ if self.git.wave_count > 0:
388
+ rows.append(
389
+ f"| Waves | {self.git.wave_count} <!-- metrics-verified: self_stats.py (git log) --> |"
390
+ )
391
+ if self.git.insertions_deletions > 0:
392
+ rows.append(
393
+ f"| Insertions + Deletions | {self.git.insertions_deletions:,} <!-- metrics-verified: self_stats.py (git log) --> |"
394
+ )
395
+ if self.git.files_tracked > 0:
396
+ rows.append(
397
+ f"| Files Tracked | {self.git.files_tracked} <!-- metrics-verified: self_stats.py (git log) --> |"
398
+ )
399
+ if self.git.distinct_coauthors > 0:
400
+ rows.append(
401
+ f"| Distinct Co-authors | {self.git.distinct_coauthors} <!-- metrics-verified: self_stats.py (git log) --> |"
402
+ )
403
+
404
+ # Session telemetry
405
+ if self.telemetry.total_sessions is not None:
406
+ rows.append(
407
+ f"| Sessions | {self.telemetry.total_sessions} <!-- metrics-verified: docs/self-stats-data.json --> |"
408
+ )
409
+ if self.telemetry.total_turns is not None:
410
+ rows.append(
411
+ f"| Total Turns | {self.telemetry.total_turns} <!-- metrics-verified: docs/self-stats-data.json --> |"
412
+ )
413
+ if self.telemetry.cumulative_tokens is not None:
414
+ rows.append(
415
+ f"| Cumulative Tokens | {self.telemetry.cumulative_tokens:,} <!-- metrics-verified: docs/self-stats-data.json --> |"
416
+ )
417
+ if self.telemetry.total_coding_hours is not None:
418
+ rows.append(
419
+ f"| Coding Hours | {self.telemetry.total_coding_hours} <!-- metrics-verified: docs/self-stats-data.json --> |"
420
+ )
421
+
422
+ # Only add table if we have rows
423
+ if rows:
424
+ lines.append("| Metric | Value |")
425
+ lines.append("| --- | --- |")
426
+ lines.extend(rows)
427
+ lines.append("")
428
+
429
+ lines.append("<!-- SELF-STATS:END -->")
430
+ lines.append("")
431
+
432
+ return "\n".join(lines)
433
+
434
+ def json(self) -> str:
435
+ """Machine-readable JSON output."""
436
+ data = {
437
+ "git": {
438
+ "merged_prs": self.git.merged_prs,
439
+ "total_commits": self.git.total_commits,
440
+ "project_age_days": self.git.project_age_days,
441
+ "wave_count": self.git.wave_count,
442
+ "insertions_deletions": self.git.insertions_deletions,
443
+ "files_tracked": self.git.files_tracked,
444
+ "distinct_coauthors": self.git.distinct_coauthors,
445
+ },
446
+ "telemetry": {
447
+ "total_sessions": self.telemetry.total_sessions,
448
+ "total_turns": self.telemetry.total_turns,
449
+ "total_user_prompts": self.telemetry.total_user_prompts,
450
+ "max_tokens_single_turn": self.telemetry.max_tokens_single_turn,
451
+ "cumulative_agent_runs": self.telemetry.cumulative_agent_runs,
452
+ "cumulative_tokens": self.telemetry.cumulative_tokens,
453
+ "total_coding_hours": self.telemetry.total_coding_hours,
454
+ },
455
+ }
456
+ return json.dumps(data, indent=2)
457
+
458
+
459
+ def main():
460
+ """CLI entry point."""
461
+ parser = argparse.ArgumentParser(
462
+ description=__doc__,
463
+ formatter_class=argparse.RawDescriptionHelpFormatter,
464
+ )
465
+ parser.add_argument(
466
+ "--repo",
467
+ default=".",
468
+ help="Repository root (default: current directory)"
469
+ )
470
+ parser.add_argument(
471
+ "--data-file",
472
+ help="Path to docs/self-stats-data.json (auto-detected if not specified)"
473
+ )
474
+
475
+ mode_group = parser.add_mutually_exclusive_group()
476
+ mode_group.add_argument(
477
+ "--markdown",
478
+ action="store_true",
479
+ help="Output markdown block with START/END markers"
480
+ )
481
+ mode_group.add_argument(
482
+ "--json",
483
+ action="store_true",
484
+ help="Output machine-readable JSON"
485
+ )
486
+
487
+ args = parser.parse_args()
488
+
489
+ counter = StatsCounter(repo_root=args.repo, data_file=args.data_file)
490
+
491
+ # Use UTF-8 for output to handle emojis
492
+ import io
493
+ import sys
494
+ if hasattr(sys.stdout, 'buffer'):
495
+ out = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
496
+ else:
497
+ out = sys.stdout
498
+
499
+ if args.markdown:
500
+ out.write(counter.markdown())
501
+ elif args.json:
502
+ out.write(counter.json())
503
+ else:
504
+ out.write(counter.table())
505
+ out.flush()
506
+
507
+
508
+ if __name__ == "__main__":
509
+ main()
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Sum token usage across a session's main thread and subagent transcripts.
4
+
5
+ Usage:
6
+ python sum_session_tokens.py <session_dir> [<main_transcript>]
7
+
8
+ Args:
9
+ session_dir: Path to session directory (e.g., .../f1bfdcf9-2ece-42f3-8798-c9066146f6a1)
10
+ main_transcript: Path to main-thread JSONL transcript (auto-detected if not provided)
11
+
12
+ Extracts per-agent totals of input_tokens, output_tokens, cache_read_input_tokens,
13
+ cache_creation_input_tokens from all .output files in <session_dir>/tasks/ plus
14
+ the main thread. Outputs a table with per-agent summaries and totals.
15
+ """
16
+
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+ from collections import defaultdict
21
+ from datetime import datetime
22
+
23
+
24
+ def extract_usage(line_obj):
25
+ """Extract usage fields from a message object."""
26
+ if "message" not in line_obj or "usage" not in line_obj["message"]:
27
+ return None
28
+ usage = line_obj["message"]["usage"]
29
+ return {
30
+ "input_tokens": usage.get("input_tokens", 0),
31
+ "output_tokens": usage.get("output_tokens", 0),
32
+ "cache_read_input_tokens": usage.get("cache_read_input_tokens", 0),
33
+ "cache_creation_input_tokens": usage.get("cache_creation_input_tokens", 0),
34
+ }
35
+
36
+
37
+ def extract_model(line_obj):
38
+ """Extract model name from a message object."""
39
+ if "message" not in line_obj:
40
+ return None
41
+ msg = line_obj["message"]
42
+ if "model" in msg:
43
+ return msg["model"]
44
+ # Fallback: check if it's in content metadata
45
+ return None
46
+
47
+
48
+ def parse_transcript(filepath, agent_name=None):
49
+ """
50
+ Parse a JSONL transcript file and sum usage per message.
51
+ Returns (agent_name, model, total_usage_dict).
52
+ """
53
+ total_usage = {
54
+ "input_tokens": 0,
55
+ "output_tokens": 0,
56
+ "cache_read_input_tokens": 0,
57
+ "cache_creation_input_tokens": 0,
58
+ }
59
+ model = None
60
+ message_count = 0
61
+
62
+ try:
63
+ with open(filepath, "r", encoding="utf-8") as f:
64
+ for line in f:
65
+ line = line.strip()
66
+ if not line:
67
+ continue
68
+ try:
69
+ obj = json.loads(line)
70
+ usage = extract_usage(obj)
71
+ if usage:
72
+ for key in total_usage:
73
+ total_usage[key] += usage.get(key, 0)
74
+ message_count += 1
75
+ if model is None:
76
+ model = extract_model(obj)
77
+ except json.JSONDecodeError:
78
+ pass
79
+ except Exception as e:
80
+ print(f"Warning: Failed to parse {filepath}: {e}", file=sys.stderr)
81
+ return None
82
+
83
+ if message_count == 0:
84
+ return None # No usage data
85
+
86
+ return (agent_name or filepath.stem, model, total_usage)
87
+
88
+
89
+ def main():
90
+ if len(sys.argv) < 2:
91
+ print(f"Usage: {sys.argv[0]} <session_dir> [<main_transcript>]", file=sys.stderr)
92
+ sys.exit(1)
93
+
94
+ session_dir = Path(sys.argv[1])
95
+ if not session_dir.exists():
96
+ print(f"Error: Session directory not found: {session_dir}", file=sys.stderr)
97
+ sys.exit(1)
98
+
99
+ # Find main transcript
100
+ if len(sys.argv) >= 3:
101
+ main_transcript = Path(sys.argv[2])
102
+ else:
103
+ # Auto-detect: look for largest .jsonl matching session ID in parent or memory dirs
104
+ session_id = session_dir.name
105
+ proj_dirs = [
106
+ session_dir.parent, # If session_dir is already in projects/
107
+ Path.home() / ".claude" / "projects" / session_dir.parent.name,
108
+ ]
109
+ main_transcript = None
110
+ for proj_dir in proj_dirs:
111
+ if proj_dir.exists():
112
+ candidates = list(proj_dir.glob(f"{session_id}*.jsonl"))
113
+ if candidates:
114
+ main_transcript = max(candidates, key=lambda p: p.stat().st_size)
115
+ break
116
+
117
+ # Collect usage data
118
+ results = {}
119
+
120
+ # Parse main thread
121
+ if main_transcript and main_transcript.exists():
122
+ result = parse_transcript(main_transcript, "orchestrator (main thread)")
123
+ if result:
124
+ name, model, usage = result
125
+ results[name] = (model or "Fable/Opus (inferred)", usage)
126
+
127
+ # Parse subagent tasks
128
+ tasks_dir = session_dir / "tasks"
129
+ if tasks_dir.exists():
130
+ for output_file in sorted(tasks_dir.glob("*.output")):
131
+ result = parse_transcript(output_file)
132
+ if result:
133
+ name, model, usage = result
134
+ results[name] = (model or "Unknown", usage)
135
+
136
+ if not results:
137
+ print("No usage data found in transcripts.", file=sys.stderr)
138
+ sys.exit(1)
139
+
140
+ # Print results
141
+ print("\n" + "=" * 100)
142
+ print("SESSION TOKEN USAGE SUMMARY")
143
+ print("=" * 100)
144
+ print(f"Timestamp: {datetime.now().isoformat()}")
145
+ print()
146
+
147
+ print(f"{'Agent':<35} {'Model':<15} {'Input':>12} {'Output':>12} {'CacheRead':>12} {'CacheCreate':>12} {'Total':>12}")
148
+ print("-" * 112)
149
+
150
+ grand_total = {
151
+ "input_tokens": 0,
152
+ "output_tokens": 0,
153
+ "cache_read_input_tokens": 0,
154
+ "cache_creation_input_tokens": 0,
155
+ }
156
+
157
+ for agent_name in sorted(results.keys()):
158
+ model, usage = results[agent_name]
159
+ inp = usage["input_tokens"]
160
+ out = usage["output_tokens"]
161
+ cache_read = usage["cache_read_input_tokens"]
162
+ cache_create = usage["cache_creation_input_tokens"]
163
+ total = inp + out + cache_read + cache_create
164
+
165
+ print(
166
+ f"{agent_name:<35} {model:<15} {inp:>12,} {out:>12,} {cache_read:>12,} {cache_create:>12,} {total:>12,}"
167
+ )
168
+
169
+ for key in grand_total:
170
+ grand_total[key] += usage[key]
171
+
172
+ print("-" * 112)
173
+ grand_inp = grand_total["input_tokens"]
174
+ grand_out = grand_total["output_tokens"]
175
+ grand_cache_read = grand_total["cache_read_input_tokens"]
176
+ grand_cache_create = grand_total["cache_creation_input_tokens"]
177
+ grand_sum = grand_inp + grand_out + grand_cache_read + grand_cache_create
178
+
179
+ print(
180
+ f"{'TOTAL':<35} {'':<15} {grand_inp:>12,} {grand_out:>12,} {grand_cache_read:>12,} {grand_cache_create:>12,} {grand_sum:>12,}"
181
+ )
182
+ print("=" * 100)
183
+ print()
184
+
185
+ # Summary by category
186
+ print("SUMMARY BY CATEGORY:")
187
+ print(f" Input tokens (fresh): {grand_inp:>12,}")
188
+ print(f" Output tokens: {grand_out:>12,}")
189
+ print(f" Cache read tokens: {grand_cache_read:>12,}")
190
+ print(f" Cache creation tokens: {grand_cache_create:>12,}")
191
+ print(f" TOTAL: {grand_sum:>12,}")
192
+ print()
193
+
194
+ return 0
195
+
196
+
197
+ if __name__ == "__main__":
198
+ sys.exit(main())